From 9decf172df7bcd6dd21ea7acd38d64bf040d317d Mon Sep 17 00:00:00 2001 From: Hyoseong Jo Date: Sun, 11 Jan 2026 17:04:16 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EA=B5=AC=ED=98=84=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/admin/user/reset-password.post.ts | 58 ----- backend/api/admin/vcs/status.get.ts | 59 ----- backend/api/admin/vcs/sync-all.post.ts | 63 ----- backend/api/auth/synology/callback.get.ts | 107 -------- backend/api/auth/synology/index.get.ts | 26 -- backend/api/commits/my-weekly.get.ts | 90 ------- .../api/employee/[id]/unlink-google.post.ts | 34 --- .../api/google-group/[id]/delete.delete.ts | 22 -- backend/api/google-group/[id]/messages.get.ts | 87 ------- backend/api/google-group/create.post.ts | 23 -- backend/api/google-group/list.get.ts | 28 --- backend/api/google-group/messages.get.ts | 139 ----------- backend/api/google-group/share-report.post.ts | 207 --------------- backend/api/meeting/[id]/analyze.post.ts | 105 -------- backend/api/meeting/[id]/confirm.post.ts | 82 ------ backend/api/project/[id]/commits.get.ts | 131 ---------- .../api/project/[id]/commits/refresh.post.ts | 31 --- backend/api/report/weekly/[id]/share.post.ts | 100 -------- backend/api/repository/[id]/index.delete.ts | 22 -- backend/api/repository/[id]/index.put.ts | 64 ----- backend/api/repository/[id]/sync.post.ts | 39 --- backend/api/repository/create.post.ts | 47 ---- backend/api/todo/[id]/complete.put.ts | 23 -- backend/api/todo/[id]/discard.put.ts | 28 --- backend/plugins/vcs-sync-cron.ts | 102 -------- backend/sql/add_synology_columns.sql | 19 -- backend/sql/create_report_share_log.sql | 21 -- backend/utils/git-sync.ts | 236 ------------------ backend/utils/google-token.ts | 66 ----- backend/utils/svn-sync.ts | 217 ---------------- 30 files changed, 2276 deletions(-) delete mode 100644 backend/api/admin/user/reset-password.post.ts delete mode 100644 backend/api/admin/vcs/status.get.ts delete mode 100644 backend/api/admin/vcs/sync-all.post.ts delete mode 100644 backend/api/auth/synology/callback.get.ts delete mode 100644 backend/api/auth/synology/index.get.ts delete mode 100644 backend/api/commits/my-weekly.get.ts delete mode 100644 backend/api/employee/[id]/unlink-google.post.ts delete mode 100644 backend/api/google-group/[id]/delete.delete.ts delete mode 100644 backend/api/google-group/[id]/messages.get.ts delete mode 100644 backend/api/google-group/create.post.ts delete mode 100644 backend/api/google-group/list.get.ts delete mode 100644 backend/api/google-group/messages.get.ts delete mode 100644 backend/api/google-group/share-report.post.ts delete mode 100644 backend/api/meeting/[id]/analyze.post.ts delete mode 100644 backend/api/meeting/[id]/confirm.post.ts delete mode 100644 backend/api/project/[id]/commits.get.ts delete mode 100644 backend/api/project/[id]/commits/refresh.post.ts delete mode 100644 backend/api/report/weekly/[id]/share.post.ts delete mode 100644 backend/api/repository/[id]/index.delete.ts delete mode 100644 backend/api/repository/[id]/index.put.ts delete mode 100644 backend/api/repository/[id]/sync.post.ts delete mode 100644 backend/api/repository/create.post.ts delete mode 100644 backend/api/todo/[id]/complete.put.ts delete mode 100644 backend/api/todo/[id]/discard.put.ts delete mode 100644 backend/plugins/vcs-sync-cron.ts delete mode 100644 backend/sql/add_synology_columns.sql delete mode 100644 backend/sql/create_report_share_log.sql delete mode 100644 backend/utils/git-sync.ts delete mode 100644 backend/utils/google-token.ts delete mode 100644 backend/utils/svn-sync.ts diff --git a/backend/api/admin/user/reset-password.post.ts b/backend/api/admin/user/reset-password.post.ts deleted file mode 100644 index 495fcf0..0000000 --- a/backend/api/admin/user/reset-password.post.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { query, execute } from '../../../utils/db' -import { hashPassword, generateTempPassword } from '../../../utils/password' -import { getClientIp } from '../../../utils/ip' -import { requireAuth } from '../../../utils/session' - -interface AdminResetPasswordBody { - employeeId: number -} - -/** - * 관리자 비밀번호 초기화 - * POST /api/admin/user/reset-password - */ -export default defineEventHandler(async (event) => { - const currentUserId = await requireAuth(event) - - // TODO: 관리자 권한 체크 (현재는 모든 로그인 사용자 허용) - - const body = await readBody(event) - const clientIp = getClientIp(event) - - if (!body.employeeId) { - throw createError({ statusCode: 400, message: '사용자 ID가 필요합니다.' }) - } - - // 대상 사용자 조회 - const employees = await query(` - SELECT employee_id, employee_name, employee_email - FROM wr_employee_info - WHERE employee_id = $1 - `, [body.employeeId]) - - if (employees.length === 0) { - throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' }) - } - - const employee = employees[0] - - // 임시 비밀번호 생성 - const tempPassword = generateTempPassword() - const hash = await hashPassword(tempPassword) - - // 비밀번호 업데이트 - await execute(` - UPDATE wr_employee_info - SET password_hash = $1, - updated_at = NOW(), - updated_ip = $2 - WHERE employee_id = $3 - `, [hash, clientIp, body.employeeId]) - - return { - success: true, - message: '비밀번호가 초기화되었습니다.', - tempPassword, - employeeName: employee.employee_name - } -}) diff --git a/backend/api/admin/vcs/status.get.ts b/backend/api/admin/vcs/status.get.ts deleted file mode 100644 index 69c6878..0000000 --- a/backend/api/admin/vcs/status.get.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { query } from '../../../utils/db' -import { requireAuth } from '../../../utils/session' - -/** - * 전체 VCS 동기화 상태 조회 (관리자용) - * GET /api/admin/vcs/status - */ -export default defineEventHandler(async (event) => { - await requireAuth(event) - - // 저장소별 동기화 상태 - const repos = await query(` - SELECT - r.repo_id, r.repo_name, r.repo_path, - r.last_sync_at, r.last_sync_status, r.last_sync_message, - s.server_type, s.server_name, - p.project_name, - (SELECT COUNT(*) FROM wr_commit_log c WHERE c.repo_id = r.repo_id) as commit_count - FROM wr_repository r - JOIN wr_vcs_server s ON r.server_id = s.server_id - LEFT JOIN wr_project_info p ON r.project_id = p.project_id - WHERE r.is_active = true - ORDER BY r.last_sync_at DESC NULLS LAST - `) - - // 전체 통계 - const stats = await query(` - SELECT - COUNT(DISTINCT r.repo_id) as total_repos, - COUNT(DISTINCT CASE WHEN r.last_sync_status = 'SUCCESS' THEN r.repo_id END) as success_repos, - COUNT(DISTINCT CASE WHEN r.last_sync_status = 'FAILED' THEN r.repo_id END) as failed_repos, - COUNT(DISTINCT CASE WHEN r.last_sync_at IS NULL THEN r.repo_id END) as never_synced, - (SELECT COUNT(*) FROM wr_commit_log) as total_commits - FROM wr_repository r - WHERE r.is_active = true - `) - - return { - repositories: repos.map(r => ({ - repoId: r.repo_id, - repoName: r.repo_name, - repoPath: r.repo_path, - serverType: r.server_type, - serverName: r.server_name, - projectName: r.project_name, - lastSyncAt: r.last_sync_at, - lastSyncStatus: r.last_sync_status, - lastSyncMessage: r.last_sync_message, - commitCount: parseInt(r.commit_count || '0') - })), - stats: { - totalRepos: parseInt(stats[0]?.total_repos || '0'), - successRepos: parseInt(stats[0]?.success_repos || '0'), - failedRepos: parseInt(stats[0]?.failed_repos || '0'), - neverSynced: parseInt(stats[0]?.never_synced || '0'), - totalCommits: parseInt(stats[0]?.total_commits || '0') - } - } -}) diff --git a/backend/api/admin/vcs/sync-all.post.ts b/backend/api/admin/vcs/sync-all.post.ts deleted file mode 100644 index 9f003db..0000000 --- a/backend/api/admin/vcs/sync-all.post.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { query } from '../../../utils/db' -import { requireAuth } from '../../../utils/session' -import { syncGitRepository } from '../../../utils/git-sync' -import { syncSvnRepository } from '../../../utils/svn-sync' - -/** - * 전체 VCS 저장소 동기화 (관리자용) - * POST /api/admin/vcs/sync-all - */ -export default defineEventHandler(async (event) => { - await requireAuth(event) - - // 모든 활성 저장소 조회 - const repos = await query(` - SELECT r.repo_id, r.repo_name, s.server_type - FROM wr_repository r - JOIN wr_vcs_server s ON r.server_id = s.server_id - WHERE r.is_active = true AND s.is_active = true - ORDER BY r.repo_name - `) - - const results: any[] = [] - - for (const repo of repos) { - try { - let result - if (repo.server_type === 'GIT') { - result = await syncGitRepository(repo.repo_id) - } else if (repo.server_type === 'SVN') { - result = await syncSvnRepository(repo.repo_id) - } else { - result = { success: false, message: '지원하지 않는 서버 타입' } - } - - results.push({ - repoId: repo.repo_id, - repoName: repo.repo_name, - serverType: repo.server_type, - ...result - }) - } catch (e: any) { - results.push({ - repoId: repo.repo_id, - repoName: repo.repo_name, - serverType: repo.server_type, - success: false, - message: e.message - }) - } - } - - const successCount = results.filter(r => r.success).length - const failCount = results.filter(r => !r.success).length - - return { - success: failCount === 0, - message: `동기화 완료: 성공 ${successCount}개, 실패 ${failCount}개`, - totalRepos: repos.length, - successCount, - failCount, - results - } -}) diff --git a/backend/api/auth/synology/callback.get.ts b/backend/api/auth/synology/callback.get.ts deleted file mode 100644 index 7e73df2..0000000 --- a/backend/api/auth/synology/callback.get.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { queryOne, execute } from '../../../utils/db' -import { createSession } from '../../../utils/session' -import { getClientIp } from '../../../utils/ip' - -/** - * Synology SSO 콜백 - * GET /api/auth/synology/callback - * - * Synology SSO Server에서 인증 후 리다이렉트되는 엔드포인트 - */ -export default defineEventHandler(async (event) => { - const config = useRuntimeConfig() - const query = getQuery(event) - const ip = getClientIp(event) - - const code = query.code as string - const error = query.error as string - - if (error) { - return sendRedirect(event, `/login?error=${encodeURIComponent('Synology 인증이 취소되었습니다.')}`) - } - - if (!code) { - return sendRedirect(event, '/login?error=' + encodeURIComponent('인증 코드가 없습니다.')) - } - - try { - // 1. 코드로 액세스 토큰 교환 - const tokenResponse = await $fetch(`${config.synologyServerUrl}/webman/sso/SSOAccessToken.cgi`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - code: code, - client_id: config.synologyClientId, - client_secret: config.synologyClientSecret, - redirect_uri: config.synologyRedirectUri - }).toString() - }) - - if (!tokenResponse.access_token) { - console.error('Synology token error:', tokenResponse) - return sendRedirect(event, '/login?error=' + encodeURIComponent('토큰 획득 실패')) - } - - // 2. 액세스 토큰으로 사용자 정보 조회 - const userResponse = await $fetch(`${config.synologyServerUrl}/webman/sso/SSOUserInfo.cgi`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${tokenResponse.access_token}` - } - }) - - if (!userResponse.data || !userResponse.data.email) { - console.error('Synology user info error:', userResponse) - return sendRedirect(event, '/login?error=' + encodeURIComponent('사용자 정보를 가져올 수 없습니다.')) - } - - const synologyEmail = userResponse.data.email - const synologyId = userResponse.data.user_id || userResponse.data.uid - const synologyName = userResponse.data.name || userResponse.data.username - - // 3. 이메일로 사용자 매칭 - const employee = await queryOne(` - SELECT employee_id, employee_name, is_active, password_hash, - synology_id, synology_email - FROM wr_employee_info - WHERE email = $1 - `, [synologyEmail]) - - if (!employee) { - return sendRedirect(event, '/login?error=' + encodeURIComponent('등록되지 않은 사용자입니다. 관리자에게 문의하세요.')) - } - - if (!employee.is_active) { - return sendRedirect(event, '/login?error=' + encodeURIComponent('비활성화된 계정입니다.')) - } - - // 4. Synology 계정 연결 정보 업데이트 - await execute(` - UPDATE wr_employee_info - SET synology_id = $1, synology_email = $2, synology_linked_at = NOW() - WHERE employee_id = $3 - `, [synologyId, synologyEmail, employee.employee_id]) - - // 5. 로그인 이력 기록 - await execute(` - INSERT INTO wr_login_history (employee_id, login_type, login_ip, login_at, login_success, login_email) - VALUES ($1, 'SYNOLOGY', $2, NOW(), true, $3) - `, [employee.employee_id, ip, synologyEmail]) - - // 6. 세션 생성 - await createSession(event, employee.employee_id) - - // 7. 비밀번호 미설정 시 설정 페이지로 - if (!employee.password_hash) { - return sendRedirect(event, '/set-password?from=synology') - } - - // 8. 메인 페이지로 리다이렉트 - return sendRedirect(event, '/') - - } catch (e: any) { - console.error('Synology OAuth error:', e) - return sendRedirect(event, '/login?error=' + encodeURIComponent('Synology 인증 중 오류가 발생했습니다.')) - } -}) diff --git a/backend/api/auth/synology/index.get.ts b/backend/api/auth/synology/index.get.ts deleted file mode 100644 index 783fa67..0000000 --- a/backend/api/auth/synology/index.get.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Synology SSO 로그인 시작 - * GET /api/auth/synology - * - * Synology SSO Server OAuth 2.0 인증 페이지로 리다이렉트 - */ -export default defineEventHandler(async (event) => { - const config = useRuntimeConfig() - - if (!config.synologyServerUrl || !config.synologyClientId) { - throw createError({ - statusCode: 500, - message: 'Synology SSO가 설정되지 않았습니다.' - }) - } - - // Synology SSO Server OAuth 인증 URL - const authUrl = new URL(`${config.synologyServerUrl}/webman/sso/SSOOauth.cgi`) - authUrl.searchParams.set('response_type', 'code') - authUrl.searchParams.set('client_id', config.synologyClientId) - authUrl.searchParams.set('redirect_uri', config.synologyRedirectUri) - authUrl.searchParams.set('scope', 'user_id') - authUrl.searchParams.set('state', crypto.randomUUID()) - - return sendRedirect(event, authUrl.toString()) -}) diff --git a/backend/api/commits/my-weekly.get.ts b/backend/api/commits/my-weekly.get.ts deleted file mode 100644 index c662e21..0000000 --- a/backend/api/commits/my-weekly.get.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { query } from '../../utils/db' -import { requireAuth } from '../../utils/session' - -/** - * 내 주간 커밋 조회 (주간보고 작성용) - * GET /api/commits/my-weekly - * - * Query params: - * - projectId: 프로젝트 ID (필수) - * - startDate: 주 시작일 (YYYY-MM-DD) - * - endDate: 주 종료일 (YYYY-MM-DD) - */ -export default defineEventHandler(async (event) => { - const employeeId = await requireAuth(event) - const queryParams = getQuery(event) - - const projectId = queryParams.projectId ? parseInt(queryParams.projectId as string) : null - const startDate = queryParams.startDate as string - const endDate = queryParams.endDate as string - - if (!projectId) { - throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' }) - } - - // 조건 빌드 - const conditions = [ - 'r.project_id = $1', - 'c.employee_id = $2' - ] - const values: any[] = [projectId, employeeId] - let paramIndex = 3 - - if (startDate) { - conditions.push(`c.commit_date >= $${paramIndex++}`) - values.push(startDate) - } - - if (endDate) { - conditions.push(`c.commit_date <= $${paramIndex++}::date + INTERVAL '1 day'`) - values.push(endDate) - } - - const whereClause = conditions.join(' AND ') - - // 내 커밋 목록 - const commits = await query(` - SELECT - c.commit_id, c.commit_hash, c.commit_message, c.commit_date, - c.files_changed, c.insertions, c.deletions, - r.repo_id, r.repo_name, - s.server_type - FROM wr_commit_log c - JOIN wr_repository r ON c.repo_id = r.repo_id - JOIN wr_vcs_server s ON r.server_id = s.server_id - WHERE ${whereClause} - ORDER BY c.commit_date DESC - LIMIT 100 - `, values) - - // 통계 - const statsResult = await query(` - SELECT - COUNT(*) as commit_count, - COALESCE(SUM(c.insertions), 0) as total_insertions, - COALESCE(SUM(c.deletions), 0) as total_deletions - FROM wr_commit_log c - JOIN wr_repository r ON c.repo_id = r.repo_id - WHERE ${whereClause} - `, values) - - return { - commits: commits.map(c => ({ - commitId: c.commit_id, - commitHash: c.commit_hash?.substring(0, 8), - commitMessage: c.commit_message, - commitDate: c.commit_date, - filesChanged: c.files_changed, - insertions: c.insertions, - deletions: c.deletions, - repoId: c.repo_id, - repoName: c.repo_name, - serverType: c.server_type - })), - stats: { - commitCount: parseInt(statsResult[0]?.commit_count || '0'), - totalInsertions: parseInt(statsResult[0]?.total_insertions || '0'), - totalDeletions: parseInt(statsResult[0]?.total_deletions || '0') - } - } -}) diff --git a/backend/api/employee/[id]/unlink-google.post.ts b/backend/api/employee/[id]/unlink-google.post.ts deleted file mode 100644 index afe34ab..0000000 --- a/backend/api/employee/[id]/unlink-google.post.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { execute } from '../../../utils/db' -import { getClientIp } from '../../../utils/ip' -import { requireAuth } from '../../../utils/session' - -/** - * Google 계정 연결 해제 - * POST /api/employee/[id]/unlink-google - */ -export default defineEventHandler(async (event) => { - await requireAuth(event) - - const employeeId = parseInt(event.context.params?.id || '0') - if (!employeeId) { - throw createError({ statusCode: 400, message: '사용자 ID가 필요합니다.' }) - } - - const ip = getClientIp(event) - - const result = await execute(` - UPDATE wr_employee_info - SET google_id = NULL, - google_email = NULL, - google_linked_at = NULL, - updated_at = NOW(), - updated_ip = $1 - WHERE employee_id = $2 - `, [ip, employeeId]) - - if (result === 0) { - throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' }) - } - - return { success: true, message: 'Google 계정 연결이 해제되었습니다.' } -}) diff --git a/backend/api/google-group/[id]/delete.delete.ts b/backend/api/google-group/[id]/delete.delete.ts deleted file mode 100644 index d08f573..0000000 --- a/backend/api/google-group/[id]/delete.delete.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { execute } from '../../../utils/db' -import { requireAuth } from '../../../utils/session' - -/** - * 구글 그룹 삭제 (비활성화) - * DELETE /api/google-group/[id]/delete - */ -export default defineEventHandler(async (event) => { - await requireAuth(event) - const groupId = parseInt(getRouterParam(event, 'id') || '0') - - if (!groupId) { - throw createError({ statusCode: 400, message: '그룹 ID가 필요합니다.' }) - } - - await execute(` - UPDATE wr_google_group SET is_active = false, updated_at = NOW() - WHERE group_id = $1 - `, [groupId]) - - return { success: true, message: '그룹이 삭제되었습니다.' } -}) diff --git a/backend/api/google-group/[id]/messages.get.ts b/backend/api/google-group/[id]/messages.get.ts deleted file mode 100644 index a7f919c..0000000 --- a/backend/api/google-group/[id]/messages.get.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { queryOne } from '../../../utils/db' -import { requireAuth, getCurrentUser } from '../../../utils/session' -import { getValidGoogleToken } from '../../../utils/google-token' - -/** - * 구글 그룹 메시지 목록 조회 - * GET /api/google-group/[id]/messages - */ -export default defineEventHandler(async (event) => { - const user = await requireAuth(event) - const groupId = parseInt(getRouterParam(event, 'id') || '0') - const queryParams = getQuery(event) - - if (!groupId) { - throw createError({ statusCode: 400, message: '그룹 ID가 필요합니다.' }) - } - - // 그룹 정보 조회 - const group = await queryOne(` - SELECT group_email, group_name FROM wr_google_group WHERE group_id = $1 - `, [groupId]) - - if (!group) { - throw createError({ statusCode: 404, message: '그룹을 찾을 수 없습니다.' }) - } - - // Google 토큰 확인 - const accessToken = await getValidGoogleToken(user.employeeId) - if (!accessToken) { - throw createError({ - statusCode: 401, - message: 'Google 계정 연결이 필요합니다.' - }) - } - - const maxResults = parseInt(queryParams.limit as string) || 20 - const pageToken = queryParams.pageToken as string || '' - - try { - // Gmail API로 그룹 메일 검색 - const searchQuery = encodeURIComponent(`from:${group.group_email} OR to:${group.group_email}`) - let url = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${searchQuery}&maxResults=${maxResults}` - if (pageToken) url += `&pageToken=${pageToken}` - - const listRes = await fetch(url, { - headers: { Authorization: `Bearer ${accessToken}` } - }) - - if (!listRes.ok) { - throw createError({ statusCode: 500, message: 'Gmail API 오류' }) - } - - const listData = await listRes.json() - const messages: any[] = [] - - // 각 메시지 상세 정보 조회 (최대 10개) - for (const msg of (listData.messages || []).slice(0, 10)) { - const detailRes = await fetch( - `https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date`, - { headers: { Authorization: `Bearer ${accessToken}` } } - ) - - if (detailRes.ok) { - const detail = await detailRes.json() - const headers = detail.payload?.headers || [] - - messages.push({ - messageId: msg.id, - threadId: msg.threadId, - subject: headers.find((h: any) => h.name === 'Subject')?.value || '(제목 없음)', - from: headers.find((h: any) => h.name === 'From')?.value || '', - date: headers.find((h: any) => h.name === 'Date')?.value || '', - snippet: detail.snippet || '' - }) - } - } - - return { - group: { groupId, groupEmail: group.group_email, groupName: group.group_name }, - messages, - nextPageToken: listData.nextPageToken || null - } - - } catch (e: any) { - throw createError({ statusCode: 500, message: e.message || '메시지 조회 실패' }) - } -}) diff --git a/backend/api/google-group/create.post.ts b/backend/api/google-group/create.post.ts deleted file mode 100644 index 17f8035..0000000 --- a/backend/api/google-group/create.post.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { execute, insertReturning } from '../../utils/db' -import { requireAuth } from '../../utils/session' - -/** - * 구글 그룹 등록 - * POST /api/google-group/create - */ -export default defineEventHandler(async (event) => { - await requireAuth(event) - const body = await readBody(event) - - if (!body.groupEmail || !body.groupName) { - throw createError({ statusCode: 400, message: '그룹 이메일과 이름은 필수입니다.' }) - } - - const result = await insertReturning(` - INSERT INTO wr_google_group (group_email, group_name, description) - VALUES ($1, $2, $3) - RETURNING group_id - `, [body.groupEmail.toLowerCase().trim(), body.groupName.trim(), body.description || null]) - - return { success: true, groupId: result.group_id, message: '그룹이 등록되었습니다.' } -}) diff --git a/backend/api/google-group/list.get.ts b/backend/api/google-group/list.get.ts deleted file mode 100644 index 7e5d4af..0000000 --- a/backend/api/google-group/list.get.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { query } from '../../utils/db' -import { requireAuth } from '../../utils/session' - -/** - * 구글 그룹 목록 조회 - * GET /api/google-group/list - */ -export default defineEventHandler(async (event) => { - await requireAuth(event) - - const groups = await query(` - SELECT group_id, group_email, group_name, description, is_active, created_at - FROM wr_google_group - WHERE is_active = true - ORDER BY group_name - `) - - return { - groups: groups.map(g => ({ - groupId: g.group_id, - groupEmail: g.group_email, - groupName: g.group_name, - description: g.description, - isActive: g.is_active, - createdAt: g.created_at - })) - } -}) diff --git a/backend/api/google-group/messages.get.ts b/backend/api/google-group/messages.get.ts deleted file mode 100644 index 136ff8d..0000000 --- a/backend/api/google-group/messages.get.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { queryOne, execute } from '../../utils/db' -import { requireAuth } from '../../utils/session' - -/** - * 구글 그룹 메시지 조회 - * GET /api/google-group/messages - * - * Gmail API로 그룹 이메일 조회 - * - * Query params: - * - groupEmail: 그룹 이메일 주소 (예: dev-team@company.com) - * - maxResults: 최대 결과 수 (기본 20) - * - after: 이 날짜 이후 메시지 (YYYY-MM-DD) - */ -export default defineEventHandler(async (event) => { - const session = await requireAuth(event) - const query = getQuery(event) - - const groupEmail = query.groupEmail as string - const maxResults = parseInt(query.maxResults as string) || 20 - const after = query.after as string - - if (!groupEmail) { - throw createError({ statusCode: 400, message: '그룹 이메일이 필요합니다.' }) - } - - // 사용자의 Google 토큰 조회 - const employee = await queryOne(` - SELECT google_access_token, google_refresh_token, google_token_expires_at - FROM wr_employee_info WHERE employee_id = $1 - `, [session.employeeId]) - - if (!employee?.google_access_token) { - throw createError({ - statusCode: 401, - message: 'Google 계정이 연결되지 않았습니다.' - }) - } - - let accessToken = employee.google_access_token - - // 토큰 만료 확인 및 갱신 - if (employee.google_token_expires_at && new Date(employee.google_token_expires_at) < new Date()) { - accessToken = await refreshGoogleToken(session.employeeId, employee.google_refresh_token) - } - - try { - // Gmail API로 그룹 메일 검색 - let searchQuery = `list:${groupEmail}` - if (after) { - searchQuery += ` after:${after}` - } - - const listResponse = await $fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages', { - headers: { 'Authorization': `Bearer ${accessToken}` }, - query: { - q: searchQuery, - maxResults: maxResults - } - }) - - if (!listResponse.messages || listResponse.messages.length === 0) { - return { messages: [], total: 0 } - } - - // 각 메시지의 상세 정보 조회 - const messages = await Promise.all( - listResponse.messages.slice(0, maxResults).map(async (msg: any) => { - const detail = await $fetch(`https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}`, { - headers: { 'Authorization': `Bearer ${accessToken}` }, - query: { format: 'metadata', metadataHeaders: ['Subject', 'From', 'Date', 'To'] } - }) - - const headers = detail.payload?.headers || [] - const getHeader = (name: string) => headers.find((h: any) => h.name === name)?.value || '' - - return { - id: msg.id, - threadId: msg.threadId, - subject: getHeader('Subject'), - from: getHeader('From'), - to: getHeader('To'), - date: getHeader('Date'), - snippet: detail.snippet - } - }) - ) - - return { - messages, - total: listResponse.resultSizeEstimate || messages.length - } - } catch (e: any) { - console.error('Gmail API error:', e) - - if (e.status === 403) { - throw createError({ - statusCode: 403, - message: 'Gmail 접근 권한이 없습니다. Google 로그인 시 권한을 허용해주세요.' - }) - } - - throw createError({ - statusCode: 500, - message: '그룹 메시지를 가져오는데 실패했습니다.' - }) - } -}) - -/** - * Google 토큰 갱신 - */ -async function refreshGoogleToken(employeeId: number, refreshToken: string): Promise { - const config = useRuntimeConfig() - - const response = await $fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - body: new URLSearchParams({ - client_id: config.googleClientId, - client_secret: config.googleClientSecret, - refresh_token: refreshToken, - grant_type: 'refresh_token' - }).toString(), - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } - }) - - if (response.access_token) { - await execute(` - UPDATE wr_employee_info - SET google_access_token = $1, - google_token_expires_at = NOW() + INTERVAL '${response.expires_in} seconds' - WHERE employee_id = $2 - `, [response.access_token, employeeId]) - - return response.access_token - } - - throw new Error('토큰 갱신 실패') -} diff --git a/backend/api/google-group/share-report.post.ts b/backend/api/google-group/share-report.post.ts deleted file mode 100644 index 889d993..0000000 --- a/backend/api/google-group/share-report.post.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { queryOne, execute, query } from '../../utils/db' -import { requireAuth } from '../../utils/session' - -/** - * 주간보고를 구글 그룹에 공유 (이메일 전송) - * POST /api/google-group/share-report - * - * Gmail API로 그룹에 이메일 전송 - * - * Body: - * - reportId: 주간보고 ID - * - groupEmail: 그룹 이메일 주소 - * - subject?: 이메일 제목 (기본값 자동 생성) - */ -export default defineEventHandler(async (event) => { - const session = await requireAuth(event) - const body = await readBody(event) - - const { reportId, groupEmail, subject } = body - - if (!reportId || !groupEmail) { - throw createError({ statusCode: 400, message: '보고서 ID와 그룹 이메일이 필요합니다.' }) - } - - // 주간보고 조회 - const report = await queryOne(` - SELECT r.*, e.employee_name, e.employee_email, p.project_name - FROM wr_weekly_report r - JOIN wr_employee_info e ON r.employee_id = e.employee_id - LEFT JOIN wr_project_info p ON r.project_id = p.project_id - WHERE r.report_id = $1 - `, [reportId]) - - if (!report) { - throw createError({ statusCode: 404, message: '주간보고를 찾을 수 없습니다.' }) - } - - // 권한 확인 (본인 보고서만) - if (report.employee_id !== session.employeeId) { - throw createError({ statusCode: 403, message: '본인의 주간보고만 공유할 수 있습니다.' }) - } - - // 사용자의 Google 토큰 조회 - const employee = await queryOne(` - SELECT google_access_token, google_refresh_token, google_token_expires_at, employee_email - FROM wr_employee_info WHERE employee_id = $1 - `, [session.employeeId]) - - if (!employee?.google_access_token) { - throw createError({ - statusCode: 401, - message: 'Google 계정이 연결되지 않았습니다.' - }) - } - - let accessToken = employee.google_access_token - - // 토큰 만료 확인 및 갱신 - if (employee.google_token_expires_at && new Date(employee.google_token_expires_at) < new Date()) { - accessToken = await refreshGoogleToken(session.employeeId, employee.google_refresh_token) - } - - // 이메일 내용 생성 - const emailSubject = subject || `[주간보고] ${report.project_name || '개인'} - ${report.report_week}주차 (${report.employee_name})` - const emailBody = generateReportEmailBody(report) - - // RFC 2822 형식의 이메일 메시지 생성 - const emailLines = [ - `From: ${employee.employee_email}`, - `To: ${groupEmail}`, - `Subject: =?UTF-8?B?${Buffer.from(emailSubject).toString('base64')}?=`, - 'MIME-Version: 1.0', - 'Content-Type: text/html; charset=UTF-8', - '', - emailBody - ] - - const rawEmail = Buffer.from(emailLines.join('\r\n')) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - - try { - // Gmail API로 이메일 전송 - const response = await $fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${accessToken}`, - 'Content-Type': 'application/json' - }, - body: { raw: rawEmail } - }) - - // 공유 이력 저장 - await execute(` - INSERT INTO wr_report_share_log (report_id, shared_to, shared_type, shared_by, message_id) - VALUES ($1, $2, 'GOOGLE_GROUP', $3, $4) - `, [reportId, groupEmail, session.employeeId, response.id]) - - return { - success: true, - message: `${groupEmail}로 주간보고가 공유되었습니다.`, - messageId: response.id - } - } catch (e: any) { - console.error('Gmail send error:', e) - - if (e.status === 403) { - throw createError({ - statusCode: 403, - message: 'Gmail 발송 권한이 없습니다. Google 로그인 시 권한을 허용해주세요.' - }) - } - - throw createError({ - statusCode: 500, - message: '이메일 발송에 실패했습니다.' - }) - } -}) - -/** - * 주간보고 이메일 본문 생성 - */ -function generateReportEmailBody(report: any): string { - const weekRange = `${report.week_start_date?.split('T')[0] || ''} ~ ${report.week_end_date?.split('T')[0] || ''}` - - return ` - - - - -

- 📋 주간업무보고 -

- - - - - - - - - - - - - - -
작성자${report.employee_name}프로젝트${report.project_name || '-'}
보고 주차${report.report_year}년 ${report.report_week}주차기간${weekRange}
- -

✅ 금주 실적

-
${report.this_week_work || '(내용 없음)'}
- -

📅 차주 계획

-
${report.next_week_plan || '(내용 없음)'}
- - ${report.issues ? ` -

⚠️ 이슈사항

-
${report.issues}
- ` : ''} - - ${report.remarks ? ` -

📝 비고

-
${report.remarks}
- ` : ''} - -
-

- 이 메일은 주간업무보고 시스템에서 자동 발송되었습니다. -

- - - `.trim() -} - -/** - * Google 토큰 갱신 - */ -async function refreshGoogleToken(employeeId: number, refreshToken: string): Promise { - const config = useRuntimeConfig() - - const response = await $fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - body: new URLSearchParams({ - client_id: config.googleClientId, - client_secret: config.googleClientSecret, - refresh_token: refreshToken, - grant_type: 'refresh_token' - }).toString(), - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } - }) - - if (response.access_token) { - await execute(` - UPDATE wr_employee_info - SET google_access_token = $1, - google_token_expires_at = NOW() + INTERVAL '${response.expires_in} seconds' - WHERE employee_id = $2 - `, [response.access_token, employeeId]) - - return response.access_token - } - - throw new Error('토큰 갱신 실패') -} diff --git a/backend/api/meeting/[id]/analyze.post.ts b/backend/api/meeting/[id]/analyze.post.ts deleted file mode 100644 index 7c732ef..0000000 --- a/backend/api/meeting/[id]/analyze.post.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { queryOne, execute } from '../../../utils/db' -import { requireAuth } from '../../../utils/session' -import { callOpenAI } from '../../../utils/openai' - -/** - * 회의록 AI 분석 - * POST /api/meeting/[id]/analyze - */ -export default defineEventHandler(async (event) => { - await requireAuth(event) - const meetingId = parseInt(event.context.params?.id || '0') - - if (!meetingId) { - throw createError({ statusCode: 400, message: '회의록 ID가 필요합니다.' }) - } - - // 회의록 조회 - const meeting = await queryOne(` - SELECT m.*, p.project_name - FROM wr_meeting m - LEFT JOIN wr_project_info p ON m.project_id = p.project_id - WHERE m.meeting_id = $1 - `, [meetingId]) - - if (!meeting) { - throw createError({ statusCode: 404, message: '회의록을 찾을 수 없습니다.' }) - } - - if (!meeting.raw_content) { - throw createError({ statusCode: 400, message: '분석할 회의 내용이 없습니다.' }) - } - - // AI 프롬프트 - const systemPrompt = `당신은 회의록 정리 전문가입니다. -아래 회의 내용을 분석하여 JSON 형식으로 정리해주세요. - -## 출력 형식 (JSON만 출력, 다른 텍스트 없이) -{ - "agendas": [ - { - "no": 1, - "title": "안건 제목", - "content": "상세 내용 요약", - "status": "DECIDED | PENDING | IN_PROGRESS", - "decision": "결정 내용 (결정된 경우만)", - "todos": [ - { - "title": "TODO 제목", - "assignee": "담당자명 또는 null", - "reason": "TODO로 추출한 이유" - } - ] - } - ], - "summary": "전체 회의 요약 (2-3문장)" -} - -## 규칙 -1. 안건은 주제별로 분리하여 넘버링 -2. 결정된 사항은 DECIDED, 추후 논의는 PENDING, 진행중은 IN_PROGRESS -3. 미결정/진행중 사항 중 액션이 필요한 것은 todos로 추출 -4. 담당자가 언급되면 assignee에 기록 (없으면 null) -5. JSON 외 다른 텍스트 출력 금지` - - const userPrompt = `## 회의 정보 -- 제목: ${meeting.meeting_title} -- 프로젝트: ${meeting.project_name || '없음 (내부업무)'} -- 일자: ${meeting.meeting_date} - -## 회의 내용 -${meeting.raw_content}` - - try { - const result = await callOpenAI([ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt } - ], true, 'gpt-4o-mini') - - // JSON 파싱 - let aiResult: any - try { - // JSON 블록 추출 (```json ... ``` 형태 처리) - let jsonStr = result.trim() - if (jsonStr.startsWith('```')) { - jsonStr = jsonStr.replace(/^```json?\n?/, '').replace(/\n?```$/, '') - } - aiResult = JSON.parse(jsonStr) - } catch (e) { - console.error('AI result parse error:', result) - throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' }) - } - - // DB 저장 - await execute(` - UPDATE wr_meeting - SET ai_summary = $1, ai_status = 'PENDING', ai_processed_at = NOW() - WHERE meeting_id = $2 - `, [JSON.stringify(aiResult), meetingId]) - - return { success: true, result: aiResult } - } catch (e: any) { - console.error('AI analyze error:', e) - throw createError({ statusCode: 500, message: e.message || 'AI 분석 실패' }) - } -}) diff --git a/backend/api/meeting/[id]/confirm.post.ts b/backend/api/meeting/[id]/confirm.post.ts deleted file mode 100644 index bd0f3ac..0000000 --- a/backend/api/meeting/[id]/confirm.post.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { queryOne, execute, insertReturning } from '../../../utils/db' -import { requireAuth } from '../../../utils/session' -import { getClientIp } from '../../../utils/ip' - -interface ConfirmBody { - selectedTodos?: Array<{ - agendaNo: number - todoIndex: number - title: string - assignee?: string - }> -} - -/** - * AI 분석 결과 확정 + TODO 생성 - * POST /api/meeting/[id]/confirm - */ -export default defineEventHandler(async (event) => { - const employeeId = await requireAuth(event) - const meetingId = parseInt(event.context.params?.id || '0') - const body = await readBody(event) - const ip = getClientIp(event) - - if (!meetingId) { - throw createError({ statusCode: 400, message: '회의록 ID가 필요합니다.' }) - } - - // 회의록 조회 - const meeting = await queryOne(` - SELECT meeting_id, ai_summary, ai_status, project_id - FROM wr_meeting WHERE meeting_id = $1 - `, [meetingId]) - - if (!meeting) { - throw createError({ statusCode: 404, message: '회의록을 찾을 수 없습니다.' }) - } - - if (!meeting.ai_summary) { - throw createError({ statusCode: 400, message: 'AI 분석 결과가 없습니다.' }) - } - - const aiResult = typeof meeting.ai_summary === 'string' - ? JSON.parse(meeting.ai_summary) - : meeting.ai_summary - - // 선택된 TODO 생성 - const createdTodos: any[] = [] - - if (body.selectedTodos && body.selectedTodos.length > 0) { - for (const todo of body.selectedTodos) { - const inserted = await insertReturning(` - INSERT INTO wr_todo ( - source_type, meeting_id, project_id, - todo_title, todo_description, todo_status, - author_id, created_at, created_ip - ) VALUES ('MEETING', $1, $2, $3, $4, 'PENDING', $5, NOW(), $6) - RETURNING todo_id - `, [ - meetingId, - meeting.project_id, - todo.title, - `안건 ${todo.agendaNo}에서 추출`, - employeeId, - ip - ]) - createdTodos.push({ todoId: inserted.todo_id, title: todo.title }) - } - } - - // 상태 업데이트 - await execute(` - UPDATE wr_meeting - SET ai_status = 'CONFIRMED', ai_confirmed_at = NOW() - WHERE meeting_id = $1 - `, [meetingId]) - - return { - success: true, - message: `확정 완료. ${createdTodos.length}개의 TODO가 생성되었습니다.`, - createdTodos - } -}) diff --git a/backend/api/project/[id]/commits.get.ts b/backend/api/project/[id]/commits.get.ts deleted file mode 100644 index 83d6935..0000000 --- a/backend/api/project/[id]/commits.get.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { query } from '../../../utils/db' -import { requireAuth } from '../../../utils/session' - -/** - * 프로젝트 커밋 목록 조회 - * GET /api/project/[id]/commits - * - * Query params: - * - startDate: 시작일 (YYYY-MM-DD) - * - endDate: 종료일 (YYYY-MM-DD) - * - repoId: 저장소 ID (옵션) - * - authorId: 작성자 ID (옵션) - * - page: 페이지 번호 (기본 1) - * - limit: 페이지당 개수 (기본 50) - */ -export default defineEventHandler(async (event) => { - await requireAuth(event) - const projectId = parseInt(getRouterParam(event, 'id') || '0') - const queryParams = getQuery(event) - - if (!projectId) { - throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' }) - } - - const startDate = queryParams.startDate as string - const endDate = queryParams.endDate as string - const repoId = queryParams.repoId ? parseInt(queryParams.repoId as string) : null - const authorId = queryParams.authorId ? parseInt(queryParams.authorId as string) : null - const page = parseInt(queryParams.page as string) || 1 - const limit = Math.min(parseInt(queryParams.limit as string) || 50, 100) - const offset = (page - 1) * limit - - // 조건 빌드 - const conditions = ['r.project_id = $1'] - const values: any[] = [projectId] - let paramIndex = 2 - - if (startDate) { - conditions.push(`c.commit_date >= $${paramIndex++}`) - values.push(startDate) - } - - if (endDate) { - conditions.push(`c.commit_date <= $${paramIndex++}::date + INTERVAL '1 day'`) - values.push(endDate) - } - - if (repoId) { - conditions.push(`c.repo_id = $${paramIndex++}`) - values.push(repoId) - } - - if (authorId) { - conditions.push(`c.employee_id = $${paramIndex++}`) - values.push(authorId) - } - - const whereClause = conditions.join(' AND ') - - // 커밋 목록 조회 - const commits = await query(` - SELECT - c.commit_id, c.commit_hash, c.commit_message, c.commit_author, c.commit_email, - c.commit_date, c.employee_id, c.files_changed, c.insertions, c.deletions, - r.repo_id, r.repo_name, r.repo_path, - s.server_type, s.server_name, - e.employee_name, e.display_name - FROM wr_commit_log c - JOIN wr_repository r ON c.repo_id = r.repo_id - JOIN wr_vcs_server s ON r.server_id = s.server_id - LEFT JOIN wr_employee_info e ON c.employee_id = e.employee_id - WHERE ${whereClause} - ORDER BY c.commit_date DESC - LIMIT $${paramIndex++} OFFSET $${paramIndex++} - `, [...values, limit, offset]) - - // 전체 개수 조회 - const countResult = await query(` - SELECT COUNT(*) as total - FROM wr_commit_log c - JOIN wr_repository r ON c.repo_id = r.repo_id - WHERE ${whereClause} - `, values) - - const total = parseInt(countResult[0]?.total || '0') - - // 통계 (해당 기간) - const statsResult = await query(` - SELECT - COUNT(*) as commit_count, - COALESCE(SUM(c.insertions), 0) as total_insertions, - COALESCE(SUM(c.deletions), 0) as total_deletions, - COUNT(DISTINCT c.employee_id) as author_count - FROM wr_commit_log c - JOIN wr_repository r ON c.repo_id = r.repo_id - WHERE ${whereClause} - `, values) - - return { - commits: commits.map(c => ({ - commitId: c.commit_id, - commitHash: c.commit_hash, - commitMessage: c.commit_message, - commitAuthor: c.commit_author, - commitEmail: c.commit_email, - commitDate: c.commit_date, - employeeId: c.employee_id, - employeeName: c.display_name || c.employee_name, - filesChanged: c.files_changed, - insertions: c.insertions, - deletions: c.deletions, - repoId: c.repo_id, - repoName: c.repo_name, - repoPath: c.repo_path, - serverType: c.server_type, - serverName: c.server_name - })), - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit) - }, - stats: { - commitCount: parseInt(statsResult[0]?.commit_count || '0'), - totalInsertions: parseInt(statsResult[0]?.total_insertions || '0'), - totalDeletions: parseInt(statsResult[0]?.total_deletions || '0'), - authorCount: parseInt(statsResult[0]?.author_count || '0') - } - } -}) diff --git a/backend/api/project/[id]/commits/refresh.post.ts b/backend/api/project/[id]/commits/refresh.post.ts deleted file mode 100644 index e64a995..0000000 --- a/backend/api/project/[id]/commits/refresh.post.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { requireAuth } from '../../../../utils/session' -import { syncProjectGitRepositories } from '../../../../utils/git-sync' -import { syncProjectSvnRepositories } from '../../../../utils/svn-sync' - -/** - * 프로젝트 커밋 새로고침 (모든 저장소 동기화) - * POST /api/project/[id]/commits/refresh - */ -export default defineEventHandler(async (event) => { - await requireAuth(event) - const projectId = parseInt(getRouterParam(event, 'id') || '0') - - if (!projectId) { - throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' }) - } - - // Git과 SVN 모두 동기화 - const gitResult = await syncProjectGitRepositories(projectId) - const svnResult = await syncProjectSvnRepositories(projectId) - - const allResults = [...gitResult.results, ...svnResult.results] - const allSuccess = allResults.every(r => r.success) - - return { - success: allSuccess, - message: allSuccess - ? `${allResults.length}개 저장소 동기화 완료` - : '일부 저장소 동기화 실패', - results: allResults - } -}) diff --git a/backend/api/report/weekly/[id]/share.post.ts b/backend/api/report/weekly/[id]/share.post.ts deleted file mode 100644 index 5d5c6a0..0000000 --- a/backend/api/report/weekly/[id]/share.post.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { query, queryOne, insertReturning } from '../../../../utils/db' -import { requireAuth } from '../../../../utils/session' -import { getValidGoogleToken } from '../../../../utils/google-token' - -/** - * 주간보고 그룹 공유 (Gmail 발송) - * POST /api/report/weekly/[id]/share - */ -export default defineEventHandler(async (event) => { - const user = await requireAuth(event) - const reportId = parseInt(getRouterParam(event, 'id') || '0') - const body = await readBody(event) - - if (!reportId) { - throw createError({ statusCode: 400, message: '보고서 ID가 필요합니다.' }) - } - - const groupIds = body.groupIds as number[] - if (!groupIds?.length) { - throw createError({ statusCode: 400, message: '공유할 그룹을 선택해주세요.' }) - } - - // 보고서 조회 - const report = await queryOne(` - SELECT r.*, e.employee_name, e.employee_email, - p.project_name, p.project_code - FROM wr_weekly_report r - JOIN wr_employee_info e ON r.employee_id = e.employee_id - LEFT JOIN wr_project_info p ON r.project_id = p.project_id - WHERE r.report_id = $1 - `, [reportId]) - - if (!report) { - throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' }) - } - - // Google 토큰 확인 - const accessToken = await getValidGoogleToken(user.employeeId) - if (!accessToken) { - throw createError({ statusCode: 401, message: 'Google 계정 연결이 필요합니다.' }) - } - - // 선택된 그룹 조회 - const groups = await query(` - SELECT group_id, group_email, group_name - FROM wr_google_group WHERE group_id = ANY($1) AND is_active = true - `, [groupIds]) - - if (!groups.length) { - throw createError({ statusCode: 400, message: '유효한 그룹이 없습니다.' }) - } - - // 이메일 제목 및 본문 생성 - const weekInfo = `${report.report_year}년 ${report.report_week}주차` - const subject = `[주간보고] ${report.project_name || '개인'} - ${weekInfo} (${report.employee_name})` - const emailBody = buildEmailBody(report) - - // 각 그룹에 발송 - const results: any[] = [] - - for (const group of groups) { - try { - const rawEmail = createRawEmail({ - to: group.group_email, subject, body: emailBody, from: user.employeeEmail - }) - - const sendRes = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', { - method: 'POST', - headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ raw: rawEmail }) - }) - - if (sendRes.ok) { - results.push({ groupId: group.group_id, groupName: group.group_name, success: true }) - } else { - const err = await sendRes.json() - results.push({ groupId: group.group_id, groupName: group.group_name, success: false, error: err.error?.message }) - } - } catch (e: any) { - results.push({ groupId: group.group_id, groupName: group.group_name, success: false, error: e.message }) - } - } - - return { success: results.some(r => r.success), message: `${results.filter(r => r.success).length}/${groups.length}개 그룹에 공유됨`, results } -}) - -function buildEmailBody(report: any): string { - return ` -

📋 주간보고 - ${report.report_year}년 ${report.report_week}주차

-

작성자: ${report.employee_name} | 프로젝트: ${report.project_name || '개인'}

-

📌 금주 실적

${(report.this_week_work || '').replace(/\n/g, '
')}
-

📅 차주 계획

${(report.next_week_plan || '').replace(/\n/g, '
')}
-${report.issues ? `

⚠️ 이슈

${report.issues.replace(/\n/g, '
')}
` : ''} -

주간업무보고 시스템에서 발송

` -} - -function createRawEmail(opts: { to: string; subject: string; body: string; from: string }): string { - const email = [`From: ${opts.from}`, `To: ${opts.to}`, `Subject: =?UTF-8?B?${Buffer.from(opts.subject).toString('base64')}?=`, 'MIME-Version: 1.0', 'Content-Type: text/html; charset=UTF-8', '', opts.body].join('\r\n') - return Buffer.from(email).toString('base64url') -} diff --git a/backend/api/repository/[id]/index.delete.ts b/backend/api/repository/[id]/index.delete.ts deleted file mode 100644 index 6c1e893..0000000 --- a/backend/api/repository/[id]/index.delete.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { execute } from '../../../utils/db' -import { requireAuth } from '../../../utils/session' - -/** - * 저장소 삭제 (비활성화) - * DELETE /api/repository/[id] - */ -export default defineEventHandler(async (event) => { - await requireAuth(event) - const repoId = parseInt(event.context.params?.id || '0') - - if (!repoId) { - throw createError({ statusCode: 400, message: '저장소 ID가 필요합니다.' }) - } - - // 실제 삭제 대신 비활성화 - await execute(` - UPDATE wr_repository SET is_active = false, updated_at = NOW() WHERE repo_id = $1 - `, [repoId]) - - return { success: true } -}) diff --git a/backend/api/repository/[id]/index.put.ts b/backend/api/repository/[id]/index.put.ts deleted file mode 100644 index fb14caf..0000000 --- a/backend/api/repository/[id]/index.put.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { execute } from '../../../utils/db' -import { requireAuth } from '../../../utils/session' -import { getClientIp } from '../../../utils/ip' - -interface UpdateRepoBody { - repoName?: string - repoPath?: string - repoUrl?: string - defaultBranch?: string - description?: string - isActive?: boolean -} - -/** - * 저장소 수정 - * PUT /api/repository/[id] - */ -export default defineEventHandler(async (event) => { - await requireAuth(event) - const repoId = parseInt(event.context.params?.id || '0') - const body = await readBody(event) - const ip = getClientIp(event) - - if (!repoId) { - throw createError({ statusCode: 400, message: '저장소 ID가 필요합니다.' }) - } - - const updates: string[] = ['updated_at = NOW()', 'updated_ip = $1'] - const values: any[] = [ip] - let idx = 2 - - if (body.repoName !== undefined) { - updates.push(`repo_name = $${idx++}`) - values.push(body.repoName) - } - if (body.repoPath !== undefined) { - updates.push(`repo_path = $${idx++}`) - values.push(body.repoPath) - } - if (body.repoUrl !== undefined) { - updates.push(`repo_url = $${idx++}`) - values.push(body.repoUrl) - } - if (body.defaultBranch !== undefined) { - updates.push(`default_branch = $${idx++}`) - values.push(body.defaultBranch) - } - if (body.description !== undefined) { - updates.push(`description = $${idx++}`) - values.push(body.description) - } - if (body.isActive !== undefined) { - updates.push(`is_active = $${idx++}`) - values.push(body.isActive) - } - - values.push(repoId) - - await execute(` - UPDATE wr_repository SET ${updates.join(', ')} WHERE repo_id = $${idx} - `, values) - - return { success: true } -}) diff --git a/backend/api/repository/[id]/sync.post.ts b/backend/api/repository/[id]/sync.post.ts deleted file mode 100644 index c798637..0000000 --- a/backend/api/repository/[id]/sync.post.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { queryOne } from '../../../utils/db' -import { requireAuth } from '../../../utils/session' -import { syncGitRepository } from '../../../utils/git-sync' -import { syncSvnRepository } from '../../../utils/svn-sync' - -/** - * 저장소 동기화 (수동) - * POST /api/repository/[id]/sync - */ -export default defineEventHandler(async (event) => { - await requireAuth(event) - const repoId = parseInt(getRouterParam(event, 'id') || '0') - - if (!repoId) { - throw createError({ statusCode: 400, message: '저장소 ID가 필요합니다.' }) - } - - // 저장소 정보 확인 - const repo = await queryOne(` - SELECT r.*, s.server_type - FROM wr_repository r - JOIN wr_vcs_server s ON r.server_id = s.server_id - WHERE r.repo_id = $1 - `, [repoId]) - - if (!repo) { - throw createError({ statusCode: 404, message: '저장소를 찾을 수 없습니다.' }) - } - - if (repo.server_type === 'GIT') { - const result = await syncGitRepository(repoId) - return result - } else if (repo.server_type === 'SVN') { - const result = await syncSvnRepository(repoId) - return result - } - - return { success: false, message: '지원하지 않는 서버 타입입니다.' } -}) diff --git a/backend/api/repository/create.post.ts b/backend/api/repository/create.post.ts deleted file mode 100644 index 99a1e78..0000000 --- a/backend/api/repository/create.post.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { insertReturning } from '../../utils/db' -import { requireAuth } from '../../utils/session' -import { getClientIp } from '../../utils/ip' - -interface CreateRepoBody { - projectId: number - serverId: number - repoName: string - repoPath: string - repoUrl?: string - defaultBranch?: string - description?: string -} - -/** - * 저장소 추가 - * POST /api/repository/create - */ -export default defineEventHandler(async (event) => { - const employeeId = await requireAuth(event) - const body = await readBody(event) - const ip = getClientIp(event) - - if (!body.projectId || !body.serverId || !body.repoPath) { - throw createError({ statusCode: 400, message: '필수 항목을 입력해주세요.' }) - } - - const repo = await insertReturning(` - INSERT INTO wr_repository ( - project_id, server_id, repo_name, repo_path, repo_url, - default_branch, description, created_by, created_at, created_ip - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9) - RETURNING repo_id - `, [ - body.projectId, - body.serverId, - body.repoName || body.repoPath, - body.repoPath, - body.repoUrl || null, - body.defaultBranch || 'main', - body.description || null, - employeeId, - ip - ]) - - return { success: true, repoId: repo.repo_id } -}) diff --git a/backend/api/todo/[id]/complete.put.ts b/backend/api/todo/[id]/complete.put.ts deleted file mode 100644 index a9c976b..0000000 --- a/backend/api/todo/[id]/complete.put.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { execute } from '../../../utils/db' -import { requireAuth } from '../../../utils/session' - -/** - * TODO 완료 처리 - * PUT /api/todo/[id]/complete - */ -export default defineEventHandler(async (event) => { - await requireAuth(event) - const todoId = parseInt(event.context.params?.id || '0') - - if (!todoId) { - throw createError({ statusCode: 400, message: 'TODO ID가 필요합니다.' }) - } - - await execute(` - UPDATE wr_todo - SET status = 'COMPLETED', completed_at = NOW(), updated_at = NOW() - WHERE todo_id = $1 - `, [todoId]) - - return { success: true, message: '완료 처리되었습니다.' } -}) diff --git a/backend/api/todo/[id]/discard.put.ts b/backend/api/todo/[id]/discard.put.ts deleted file mode 100644 index ed18ac9..0000000 --- a/backend/api/todo/[id]/discard.put.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { execute } from '../../../utils/db' -import { requireAuth } from '../../../utils/session' - -interface DiscardBody { - reason?: string -} - -/** - * TODO 폐기 처리 - * PUT /api/todo/[id]/discard - */ -export default defineEventHandler(async (event) => { - await requireAuth(event) - const todoId = parseInt(event.context.params?.id || '0') - const body = await readBody(event) - - if (!todoId) { - throw createError({ statusCode: 400, message: 'TODO ID가 필요합니다.' }) - } - - await execute(` - UPDATE wr_todo - SET status = 'DISCARDED', discard_reason = $1, updated_at = NOW() - WHERE todo_id = $2 - `, [body.reason || null, todoId]) - - return { success: true, message: '폐기 처리되었습니다.' } -}) diff --git a/backend/plugins/vcs-sync-cron.ts b/backend/plugins/vcs-sync-cron.ts deleted file mode 100644 index 22f2434..0000000 --- a/backend/plugins/vcs-sync-cron.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { query } from '../utils/db' -import { syncGitRepository } from '../utils/git-sync' -import { syncSvnRepository } from '../utils/svn-sync' - -/** - * VCS 저장소 자동 동기화 Cron Job - * 매일 새벽 3시에 실행 - */ - -// 마지막 실행 시간 체크 -let lastSyncDate = '' - -function getTodayDate() { - return new Date().toISOString().split('T')[0] -} - -function getCurrentHour() { - return new Date().getHours() -} - -/** - * 모든 활성 저장소 동기화 - */ -async function syncAllRepositories() { - console.log('[VCS-SYNC] 자동 동기화 시작:', new Date().toISOString()) - - try { - // 모든 활성 저장소 조회 - const repos = await query(` - SELECT r.repo_id, r.repo_name, s.server_type - FROM wr_repository r - JOIN wr_vcs_server s ON r.server_id = s.server_id - WHERE r.is_active = true AND s.is_active = true - `) - - console.log(`[VCS-SYNC] 동기화 대상 저장소: ${repos.length}개`) - - let successCount = 0 - let failCount = 0 - - for (const repo of repos) { - try { - let result - if (repo.server_type === 'GIT') { - result = await syncGitRepository(repo.repo_id) - } else if (repo.server_type === 'SVN') { - result = await syncSvnRepository(repo.repo_id) - } - - if (result?.success) { - successCount++ - console.log(`[VCS-SYNC] ✓ ${repo.repo_name}: ${result.message}`) - } else { - failCount++ - console.log(`[VCS-SYNC] ✗ ${repo.repo_name}: ${result?.message || '알 수 없는 오류'}`) - } - } catch (e: any) { - failCount++ - console.error(`[VCS-SYNC] ✗ ${repo.repo_name} 오류:`, e.message) - } - - // 저장소 간 1초 대기 (서버 부하 방지) - await new Promise(resolve => setTimeout(resolve, 1000)) - } - - console.log(`[VCS-SYNC] 동기화 완료: 성공 ${successCount}개, 실패 ${failCount}개`) - lastSyncDate = getTodayDate() - - } catch (e: any) { - console.error('[VCS-SYNC] 동기화 중 오류:', e.message) - } -} - -/** - * Cron 체크 (1시간마다 실행, 새벽 3시에 동기화) - */ -function checkAndSync() { - const today = getTodayDate() - const hour = getCurrentHour() - - // 새벽 3시이고, 오늘 아직 실행 안했으면 실행 - if (hour === 3 && lastSyncDate !== today) { - syncAllRepositories() - } -} - -export default defineNitroPlugin((nitroApp) => { - // 서버 시작 시 로그 - console.log('[VCS-SYNC] Cron Job 플러그인 로드됨 (매일 03:00 실행)') - - // 개발 환경에서는 비활성화 옵션 - if (process.env.DISABLE_VCS_SYNC === 'true') { - console.log('[VCS-SYNC] 환경변수로 비활성화됨') - return - } - - // 1시간마다 체크 - setInterval(checkAndSync, 60 * 60 * 1000) - - // 서버 시작 5분 후 첫 체크 - setTimeout(checkAndSync, 5 * 60 * 1000) -}) diff --git a/backend/sql/add_synology_columns.sql b/backend/sql/add_synology_columns.sql deleted file mode 100644 index f07b459..0000000 --- a/backend/sql/add_synology_columns.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Synology SSO 연동을 위한 컬럼 추가 --- 실행: psql -d weeklyreport -f add_synology_columns.sql - --- Synology 계정 연결 정보 컬럼 추가 -ALTER TABLE wr_employee_info ADD COLUMN IF NOT EXISTS synology_id VARCHAR(100); -ALTER TABLE wr_employee_info ADD COLUMN IF NOT EXISTS synology_email VARCHAR(255); -ALTER TABLE wr_employee_info ADD COLUMN IF NOT EXISTS synology_linked_at TIMESTAMP; - --- 인덱스 추가 -CREATE INDEX IF NOT EXISTS idx_employee_synology_id ON wr_employee_info(synology_id); - --- 로그인 이력 테이블에 login_type 컬럼 추가 (이미 있을 수 있음) -ALTER TABLE wr_login_history ADD COLUMN IF NOT EXISTS login_type VARCHAR(20) DEFAULT 'PASSWORD'; -ALTER TABLE wr_login_history ADD COLUMN IF NOT EXISTS login_email VARCHAR(255); - -COMMENT ON COLUMN wr_employee_info.synology_id IS 'Synology 사용자 ID'; -COMMENT ON COLUMN wr_employee_info.synology_email IS 'Synology 계정 이메일'; -COMMENT ON COLUMN wr_employee_info.synology_linked_at IS 'Synology 계정 연결 일시'; -COMMENT ON COLUMN wr_login_history.login_type IS '로그인 방식 (PASSWORD, GOOGLE, SYNOLOGY)'; diff --git a/backend/sql/create_report_share_log.sql b/backend/sql/create_report_share_log.sql deleted file mode 100644 index 44ea025..0000000 --- a/backend/sql/create_report_share_log.sql +++ /dev/null @@ -1,21 +0,0 @@ --- 주간보고 공유 이력 테이블 --- 실행: psql -d weeklyreport -f create_report_share_log.sql - -CREATE TABLE IF NOT EXISTS wr_report_share_log ( - share_id SERIAL PRIMARY KEY, - report_id INTEGER NOT NULL REFERENCES wr_weekly_report(report_id), - shared_to VARCHAR(255) NOT NULL, -- 공유 대상 (이메일 또는 그룹명) - shared_type VARCHAR(50) NOT NULL DEFAULT 'GOOGLE_GROUP', -- GOOGLE_GROUP, EMAIL, SLACK 등 - shared_by INTEGER NOT NULL REFERENCES wr_employee_info(employee_id), - shared_at TIMESTAMP DEFAULT NOW(), - message_id VARCHAR(255), -- Gmail 메시지 ID 등 - share_status VARCHAR(20) DEFAULT 'SUCCESS', -- SUCCESS, FAILED - error_message TEXT -); - -CREATE INDEX IF NOT EXISTS idx_report_share_report_id ON wr_report_share_log(report_id); -CREATE INDEX IF NOT EXISTS idx_report_share_shared_by ON wr_report_share_log(shared_by); - -COMMENT ON TABLE wr_report_share_log IS '주간보고 공유 이력'; -COMMENT ON COLUMN wr_report_share_log.shared_to IS '공유 대상 (이메일, 그룹 등)'; -COMMENT ON COLUMN wr_report_share_log.shared_type IS '공유 방식 (GOOGLE_GROUP, EMAIL, SLACK 등)'; diff --git a/backend/utils/git-sync.ts b/backend/utils/git-sync.ts deleted file mode 100644 index ba0e223..0000000 --- a/backend/utils/git-sync.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { query, execute, queryOne } from './db' -import { existsSync, mkdirSync, rmSync } from 'fs' -import { join } from 'path' -import { execSync, exec } from 'child_process' -import { promisify } from 'util' - -const execAsync = promisify(exec) - -// 임시 저장소 디렉토리 -const REPO_TEMP_DIR = process.env.REPO_TEMP_DIR || join(process.cwd(), '.tmp', 'repos') - -interface CommitInfo { - hash: string - author: string - email: string - date: string - message: string - filesChanged?: number - insertions?: number - deletions?: number -} - -/** - * 저장소 정보 조회 - */ -async function getRepositoryInfo(repoId: number) { - return await queryOne(` - SELECT - r.repo_id, r.project_id, r.repo_name, r.repo_path, r.repo_url, - r.default_branch, r.last_sync_at, - s.server_id, s.server_type, s.server_url, s.server_name, - s.auth_type, s.auth_username, s.auth_credential - FROM wr_repository r - JOIN wr_vcs_server s ON r.server_id = s.server_id - WHERE r.repo_id = $1 AND r.is_active = true - `, [repoId]) -} - -/** - * Git 저장소 URL 생성 - */ -function buildGitUrl(serverUrl: string, repoPath: string, authUsername?: string, authCredential?: string): string { - // 이미 전체 URL인 경우 - if (repoPath.startsWith('http://') || repoPath.startsWith('https://') || repoPath.startsWith('git@')) { - return repoPath - } - - // 서버 URL + 경로 조합 - let baseUrl = serverUrl.replace(/\/$/, '') - let path = repoPath.startsWith('/') ? repoPath : `/${repoPath}` - - // HTTPS 인증 추가 - if (authUsername && authCredential && baseUrl.startsWith('https://')) { - const urlObj = new URL(baseUrl) - urlObj.username = encodeURIComponent(authUsername) - urlObj.password = encodeURIComponent(authCredential) - baseUrl = urlObj.toString().replace(/\/$/, '') - } - - return `${baseUrl}${path}` -} - -/** - * Git 커밋 로그 파싱 - */ -function parseGitLog(output: string): CommitInfo[] { - const commits: CommitInfo[] = [] - const lines = output.trim().split('\n') - - for (const line of lines) { - if (!line.trim()) continue - - // 포맷: hash|author|email|date|message - const parts = line.split('|') - if (parts.length >= 5) { - commits.push({ - hash: parts[0], - author: parts[1], - email: parts[2], - date: parts[3], - message: parts.slice(4).join('|') // 메시지에 | 있을 수 있음 - }) - } - } - - return commits -} - -/** - * VCS 계정으로 사용자 매칭 - */ -async function matchAuthor(serverId: number, authorName: string, authorEmail: string): Promise { - // 이메일로 먼저 매칭 - let matched = await queryOne(` - SELECT employee_id FROM wr_employee_vcs_account - WHERE server_id = $1 AND (vcs_email = $2 OR vcs_username = $3) - `, [serverId, authorEmail, authorName]) - - if (matched) { - return matched.employee_id - } - - // VCS 계정에 없으면 직원 이메일로 매칭 시도 - matched = await queryOne(` - SELECT employee_id FROM wr_employee_info - WHERE email = $1 AND is_active = true - `, [authorEmail]) - - return matched?.employee_id || null -} - -/** - * Git 저장소 동기화 - */ -export async function syncGitRepository(repoId: number): Promise<{ success: boolean; message: string; commitCount?: number }> { - const repo = await getRepositoryInfo(repoId) - - if (!repo) { - return { success: false, message: '저장소를 찾을 수 없습니다.' } - } - - if (repo.server_type !== 'GIT') { - return { success: false, message: 'Git 저장소가 아닙니다.' } - } - - // 임시 디렉토리 생성 - if (!existsSync(REPO_TEMP_DIR)) { - mkdirSync(REPO_TEMP_DIR, { recursive: true }) - } - - const localPath = join(REPO_TEMP_DIR, `repo_${repoId}`) - const gitUrl = buildGitUrl(repo.server_url, repo.repo_path, repo.auth_username, repo.auth_credential) - const branch = repo.default_branch || 'main' - - try { - // Clone 또는 Pull - if (existsSync(localPath)) { - // 기존 저장소 업데이트 - await execAsync(`cd "${localPath}" && git fetch origin && git reset --hard origin/${branch}`) - } else { - // 새로 클론 (shallow clone으로 최근 커밋만) - await execAsync(`git clone --depth 100 --single-branch --branch ${branch} "${gitUrl}" "${localPath}"`) - } - - // 마지막 동기화 이후 커밋 조회 - let sinceOption = '' - if (repo.last_sync_at) { - const sinceDate = new Date(repo.last_sync_at) - sinceDate.setDate(sinceDate.getDate() - 1) // 1일 전부터 (중복 방지용 UPSERT 사용) - sinceOption = `--since="${sinceDate.toISOString()}"` - } else { - sinceOption = '--since="30 days ago"' // 최초 동기화: 최근 30일 - } - - // 커밋 로그 조회 - const logFormat = '%H|%an|%ae|%aI|%s' - const { stdout } = await execAsync( - `cd "${localPath}" && git log ${sinceOption} --format="${logFormat}" --no-merges`, - { maxBuffer: 10 * 1024 * 1024 } // 10MB - ) - - const commits = parseGitLog(stdout) - let insertedCount = 0 - - // 커밋 저장 - for (const commit of commits) { - const employeeId = await matchAuthor(repo.server_id, commit.author, commit.email) - - // UPSERT (중복 무시) - const result = await execute(` - INSERT INTO wr_commit_log ( - repo_id, commit_hash, commit_message, commit_author, commit_email, - commit_date, employee_id, synced_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) - ON CONFLICT (repo_id, commit_hash) DO NOTHING - `, [ - repoId, - commit.hash, - commit.message, - commit.author, - commit.email, - commit.date, - employeeId - ]) - - if (result.rowCount && result.rowCount > 0) { - insertedCount++ - } - } - - // 동기화 상태 업데이트 - await execute(` - UPDATE wr_repository - SET last_sync_at = NOW(), last_sync_status = 'SUCCESS', last_sync_message = $2 - WHERE repo_id = $1 - `, [repoId, `${commits.length}개 커밋 조회, ${insertedCount}개 신규 저장`]) - - return { - success: true, - message: `동기화 완료: ${commits.length}개 커밋 조회, ${insertedCount}개 신규 저장`, - commitCount: insertedCount - } - - } catch (error: any) { - console.error('Git sync error:', error) - - // 실패 상태 업데이트 - await execute(` - UPDATE wr_repository - SET last_sync_status = 'FAILED', last_sync_message = $2 - WHERE repo_id = $1 - `, [repoId, error.message?.substring(0, 500)]) - - return { success: false, message: error.message || '동기화 실패' } - } -} - -/** - * 프로젝트의 모든 Git 저장소 동기화 - */ -export async function syncProjectGitRepositories(projectId: number): Promise<{ success: boolean; results: any[] }> { - const repos = await query(` - SELECT r.repo_id - FROM wr_repository r - JOIN wr_vcs_server s ON r.server_id = s.server_id - WHERE r.project_id = $1 AND r.is_active = true AND s.server_type = 'GIT' - `, [projectId]) - - const results = [] - for (const repo of repos) { - const result = await syncGitRepository(repo.repo_id) - results.push({ repoId: repo.repo_id, ...result }) - } - - return { success: results.every(r => r.success), results } -} diff --git a/backend/utils/google-token.ts b/backend/utils/google-token.ts deleted file mode 100644 index e9039da..0000000 --- a/backend/utils/google-token.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { query, execute } from './db' - -const config = useRuntimeConfig() - -/** - * Google Access Token 갱신 - */ -export async function refreshGoogleToken(employeeId: number): Promise { - // 현재 토큰 정보 조회 - const rows = await query(` - SELECT google_access_token, google_refresh_token, google_token_expires_at - FROM wr_employee_info - WHERE employee_id = $1 - `, [employeeId]) - - const employee = rows[0] - if (!employee?.google_refresh_token) { - return null - } - - // 토큰이 아직 유효하면 그대로 반환 (5분 여유) - const expiresAt = new Date(employee.google_token_expires_at) - if (expiresAt.getTime() > Date.now() + 5 * 60 * 1000) { - return employee.google_access_token - } - - // 토큰 갱신 - try { - const res = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: config.googleClientId, - client_secret: config.googleClientSecret, - refresh_token: employee.google_refresh_token, - grant_type: 'refresh_token' - }) - }) - - const data = await res.json() - if (!data.access_token) { - console.error('Token refresh failed:', data) - return null - } - - // 새 토큰 저장 - await execute(` - UPDATE wr_employee_info SET - google_access_token = $1, - google_token_expires_at = NOW() + INTERVAL '${data.expires_in} seconds' - WHERE employee_id = $2 - `, [data.access_token, employeeId]) - - return data.access_token - } catch (e) { - console.error('Token refresh error:', e) - return null - } -} - -/** - * 유효한 Google Access Token 조회 (자동 갱신) - */ -export async function getValidGoogleToken(employeeId: number): Promise { - return refreshGoogleToken(employeeId) -} diff --git a/backend/utils/svn-sync.ts b/backend/utils/svn-sync.ts deleted file mode 100644 index b893126..0000000 --- a/backend/utils/svn-sync.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { query, execute, queryOne } from './db' -import { execSync, exec } from 'child_process' -import { promisify } from 'util' - -const execAsync = promisify(exec) - -interface SvnLogEntry { - revision: string - author: string - date: string - message: string -} - -/** - * 저장소 정보 조회 - */ -async function getRepositoryInfo(repoId: number) { - return await queryOne(` - SELECT - r.repo_id, r.project_id, r.repo_name, r.repo_path, r.repo_url, - r.default_branch, r.last_sync_at, - s.server_id, s.server_type, s.server_url, s.server_name, - s.auth_type, s.auth_username, s.auth_credential - FROM wr_repository r - JOIN wr_vcs_server s ON r.server_id = s.server_id - WHERE r.repo_id = $1 AND r.is_active = true - `, [repoId]) -} - -/** - * SVN URL 생성 - */ -function buildSvnUrl(serverUrl: string, repoPath: string): string { - // 이미 전체 URL인 경우 - if (repoPath.startsWith('svn://') || repoPath.startsWith('http://') || repoPath.startsWith('https://')) { - return repoPath - } - - // 서버 URL + 경로 조합 - let baseUrl = serverUrl.replace(/\/$/, '') - let path = repoPath.startsWith('/') ? repoPath : `/${repoPath}` - - return `${baseUrl}${path}` -} - -/** - * SVN 로그 XML 파싱 - */ -function parseSvnLogXml(xmlContent: string): SvnLogEntry[] { - const entries: SvnLogEntry[] = [] - - // 간단한 XML 파싱 (정규식 사용) - const logEntryRegex = /([\s\S]*?)<\/logentry>/g - const authorRegex = /(.*?)<\/author>/ - const dateRegex = /(.*?)<\/date>/ - const msgRegex = /([\s\S]*?)<\/msg>/ - - let match - while ((match = logEntryRegex.exec(xmlContent)) !== null) { - const revision = match[1] - const content = match[2] - - const authorMatch = content.match(authorRegex) - const dateMatch = content.match(dateRegex) - const msgMatch = content.match(msgRegex) - - entries.push({ - revision, - author: authorMatch ? authorMatch[1] : '', - date: dateMatch ? dateMatch[1] : '', - message: msgMatch ? msgMatch[1].trim() : '' - }) - } - - return entries -} - -/** - * VCS 계정으로 사용자 매칭 - */ -async function matchAuthor(serverId: number, authorName: string): Promise { - // SVN 사용자명으로 매칭 - let matched = await queryOne(` - SELECT employee_id FROM wr_employee_vcs_account - WHERE server_id = $1 AND vcs_username = $2 - `, [serverId, authorName]) - - if (matched) { - return matched.employee_id - } - - // VCS 계정에 없으면 직원 이름으로 매칭 시도 - matched = await queryOne(` - SELECT employee_id FROM wr_employee_info - WHERE (employee_name = $1 OR display_name = $1) AND is_active = true - `, [authorName]) - - return matched?.employee_id || null -} - -/** - * SVN 저장소 동기화 - */ -export async function syncSvnRepository(repoId: number): Promise<{ success: boolean; message: string; commitCount?: number }> { - const repo = await getRepositoryInfo(repoId) - - if (!repo) { - return { success: false, message: '저장소를 찾을 수 없습니다.' } - } - - if (repo.server_type !== 'SVN') { - return { success: false, message: 'SVN 저장소가 아닙니다.' } - } - - const svnUrl = buildSvnUrl(repo.server_url, repo.repo_path) - - // SVN 명령어 구성 - let command = `svn log "${svnUrl}" --xml` - - // 인증 정보 추가 - if (repo.auth_username && repo.auth_credential) { - command += ` --username "${repo.auth_username}" --password "${repo.auth_credential}"` - } - - // 기간 제한 - if (repo.last_sync_at) { - const sinceDate = new Date(repo.last_sync_at) - sinceDate.setDate(sinceDate.getDate() - 1) // 1일 전부터 - command += ` -r {${sinceDate.toISOString()}}:HEAD` - } else { - // 최초 동기화: 최근 100개 또는 30일 - command += ' -l 100' - } - - // 비대화형 모드 - command += ' --non-interactive --trust-server-cert-failures=unknown-ca,cn-mismatch,expired,not-yet-valid,other' - - try { - const { stdout, stderr } = await execAsync(command, { - maxBuffer: 10 * 1024 * 1024, // 10MB - timeout: 60000 // 60초 타임아웃 - }) - - const entries = parseSvnLogXml(stdout) - let insertedCount = 0 - - // 커밋 저장 - for (const entry of entries) { - const employeeId = await matchAuthor(repo.server_id, entry.author) - - // UPSERT (중복 무시) - const result = await execute(` - INSERT INTO wr_commit_log ( - repo_id, commit_hash, commit_message, commit_author, - commit_date, employee_id, synced_at - ) VALUES ($1, $2, $3, $4, $5, $6, NOW()) - ON CONFLICT (repo_id, commit_hash) DO NOTHING - `, [ - repoId, - `r${entry.revision}`, // SVN: r123 형식 - entry.message, - entry.author, - entry.date, - employeeId - ]) - - if (result.rowCount && result.rowCount > 0) { - insertedCount++ - } - } - - // 동기화 상태 업데이트 - await execute(` - UPDATE wr_repository - SET last_sync_at = NOW(), last_sync_status = 'SUCCESS', last_sync_message = $2 - WHERE repo_id = $1 - `, [repoId, `${entries.length}개 커밋 조회, ${insertedCount}개 신규 저장`]) - - return { - success: true, - message: `동기화 완료: ${entries.length}개 커밋 조회, ${insertedCount}개 신규 저장`, - commitCount: insertedCount - } - - } catch (error: any) { - console.error('SVN sync error:', error) - - // 실패 상태 업데이트 - await execute(` - UPDATE wr_repository - SET last_sync_status = 'FAILED', last_sync_message = $2 - WHERE repo_id = $1 - `, [repoId, error.message?.substring(0, 500)]) - - return { success: false, message: error.message || '동기화 실패' } - } -} - -/** - * 프로젝트의 모든 SVN 저장소 동기화 - */ -export async function syncProjectSvnRepositories(projectId: number): Promise<{ success: boolean; results: any[] }> { - const repos = await query(` - SELECT r.repo_id - FROM wr_repository r - JOIN wr_vcs_server s ON r.server_id = s.server_id - WHERE r.project_id = $1 AND r.is_active = true AND s.server_type = 'SVN' - `, [projectId]) - - const results = [] - for (const repo of repos) { - const result = await syncSvnRepository(repo.repo_id) - results.push({ repoId: repo.repo_id, ...result }) - } - - return { success: results.every(r => r.success), results } -}