From 954ba21211370ab2c89f8737f0f4f95d14019267 Mon Sep 17 00:00:00 2001 From: Hyoseong Jo Date: Sun, 11 Jan 2026 17:01:01 +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 --- .../api/admin/bulk-register.post.ts | 0 .../api/admin/menu/[id]/toggle-role.post.ts | 0 .../api/admin/menu/list.get.ts | 0 .../api/admin/parse-image.post.ts | 0 .../api/admin/parse-report.post.ts | 0 .../api/admin/role/[id]/delete.delete.ts | 0 .../api/admin/role/[id]/update.put.ts | 0 .../api/admin/role/create.post.ts | 0 .../api/admin/role/list.get.ts | 0 .../api/admin/user/[id]/roles.put.ts | 0 .../api/admin/user/[id]/toggle-role.post.ts | 0 .../api/admin/user/list.get.ts | 0 server/api/admin/user/reset-password.post.ts | 58 +++++ server/api/admin/vcs/status.get.ts | 59 +++++ server/api/admin/vcs/sync-all.post.ts | 63 +++++ .../api/ai/parse-my-report-image.post.ts | 0 .../api/ai/parse-my-report.post.ts | 0 .../api/auth/change-password.post.ts | 0 .../api/auth/current-user.get.ts | 0 .../api/auth/google/callback.get.ts | 0 .../api/auth/google/index.get.ts | 0 .../api/auth/login-history.get.ts | 0 .../api/auth/login-password.post.ts | 0 {backend => server}/api/auth/login.post.ts | 0 {backend => server}/api/auth/logout.post.ts | 0 {backend => server}/api/auth/me.get.ts | 0 {backend => server}/api/auth/menu.get.ts | 0 .../api/auth/recent-users.get.ts | 0 .../api/auth/reset-password.post.ts | 0 .../api/auth/select-user.post.ts | 0 .../api/auth/set-password.post.ts | 0 server/api/auth/synology/callback.get.ts | 107 ++++++++ server/api/auth/synology/index.get.ts | 26 ++ .../api/business-report/[id]/confirm.put.ts | 0 .../api/business-report/[id]/detail.get.ts | 0 .../api/business-report/[id]/update.put.ts | 0 .../api/business-report/generate.post.ts | 0 .../api/business-report/list.get.ts | 0 .../api/business/[id]/delete.delete.ts | 0 .../api/business/[id]/detail.get.ts | 0 .../api/business/[id]/update.put.ts | 0 .../api/business/create.post.ts | 0 {backend => server}/api/business/list.get.ts | 0 server/api/commits/my-weekly.get.ts | 90 +++++++ .../api/dashboard/stats.get.ts | 0 .../api/employee/[id]/delete.delete.ts | 0 .../api/employee/[id]/detail.get.ts | 0 .../api/employee/[id]/unlink-google.post.ts | 34 +++ .../api/employee/[id]/update.put.ts | 0 .../api/employee/create.post.ts | 0 {backend => server}/api/employee/list.get.ts | 0 .../api/employee/search.get.ts | 0 .../api/feedback/[id]/delete.delete.ts | 0 .../api/feedback/[id]/like.post.ts | 0 .../api/feedback/[id]/update.put.ts | 0 .../api/feedback/create.post.ts | 0 {backend => server}/api/feedback/list.get.ts | 0 server/api/google-group/[id]/delete.delete.ts | 22 ++ server/api/google-group/[id]/messages.get.ts | 87 +++++++ server/api/google-group/create.post.ts | 23 ++ server/api/google-group/list.get.ts | 28 +++ server/api/google-group/messages.get.ts | 139 +++++++++++ server/api/google-group/share-report.post.ts | 207 +++++++++++++++ .../api/maintenance/[id]/delete.delete.ts | 0 .../api/maintenance/[id]/detail.get.ts | 0 .../api/maintenance/[id]/status.put.ts | 0 .../api/maintenance/[id]/update.put.ts | 0 .../api/maintenance/bulk-create.post.ts | 0 .../api/maintenance/create.post.ts | 0 .../api/maintenance/list.get.ts | 0 .../api/maintenance/report/available.get.ts | 0 .../maintenance/report/generate-text.post.ts | 0 .../api/maintenance/report/link.post.ts | 0 .../api/maintenance/stats.get.ts | 0 .../api/maintenance/upload.post.ts | 0 server/api/meeting/[id]/analyze.post.ts | 105 ++++++++ server/api/meeting/[id]/confirm.post.ts | 82 ++++++ .../api/meeting/[id]/delete.delete.ts | 0 .../api/meeting/[id]/detail.get.ts | 0 .../api/meeting/[id]/update.put.ts | 0 .../api/meeting/create.post.ts | 0 {backend => server}/api/meeting/list.get.ts | 0 server/api/project/[id]/commits.get.ts | 131 ++++++++++ .../api/project/[id]/commits/refresh.post.ts | 31 +++ .../api/project/[id]/detail.get.ts | 0 .../api/project/[id]/manager-assign.post.ts | 0 .../api/project/[id]/manager-history.get.ts | 0 .../api/project/[id]/update.put.ts | 0 .../api/project/create.post.ts | 0 {backend => server}/api/project/list.get.ts | 0 .../api/project/my-projects.get.ts | 0 {backend => server}/api/report/review.post.ts | 0 .../api/report/summary/[id]/detail.get.ts | 0 .../api/report/summary/[id]/review.put.ts | 0 .../api/report/summary/aggregate.post.ts | 0 .../report/summary/available-projects.get.ts | 0 .../api/report/summary/list.get.ts | 0 .../api/report/summary/regenerate-ai.post.ts | 0 .../api/report/summary/week/detail.get.ts | 0 .../api/report/summary/weekly-list.get.ts | 0 .../api/report/weekly/[id]/delete.delete.ts | 0 .../api/report/weekly/[id]/detail.get.ts | 0 server/api/report/weekly/[id]/share.post.ts | 100 ++++++++ .../api/report/weekly/[id]/submit.post.ts | 0 .../api/report/weekly/[id]/update.put.ts | 0 .../api/report/weekly/aggregate.get.ts | 0 .../api/report/weekly/create.post.ts | 0 .../api/report/weekly/current-week.get.ts | 0 .../api/report/weekly/list.get.ts | 0 server/api/repository/[id]/index.delete.ts | 22 ++ server/api/repository/[id]/index.put.ts | 64 +++++ server/api/repository/[id]/sync.post.ts | 39 +++ server/api/repository/create.post.ts | 47 ++++ .../api/repository/list.get.ts | 0 server/api/todo/[id]/complete.put.ts | 23 ++ .../api/todo/[id]/delete.delete.ts | 0 .../api/todo/[id]/detail.get.ts | 0 server/api/todo/[id]/discard.put.ts | 28 +++ .../api/todo/[id]/update.put.ts | 0 {backend => server}/api/todo/create.post.ts | 0 {backend => server}/api/todo/list.get.ts | 0 .../api/todo/report/link.post.ts | 0 .../api/todo/report/similar.post.ts | 0 .../api/vcs-account/[id]/delete.delete.ts | 0 .../api/vcs-account/[id]/update.put.ts | 0 .../api/vcs-account/create.post.ts | 0 {backend => server}/api/vcs-account/my.get.ts | 0 .../api/vcs-server/[id]/delete.delete.ts | 0 .../api/vcs-server/[id]/detail.get.ts | 0 .../api/vcs-server/[id]/update.put.ts | 0 .../api/vcs-server/create.post.ts | 0 .../api/vcs-server/list.get.ts | 0 server/plugins/vcs-sync-cron.ts | 102 ++++++++ server/sql/add_synology_columns.sql | 19 ++ server/sql/create_report_share_log.sql | 21 ++ .../sql/create_role_tables.sql | 0 .../sql/create_session_table.sql | 0 {backend => server}/utils/db.ts | 0 {backend => server}/utils/email.ts | 0 server/utils/git-sync.ts | 236 ++++++++++++++++++ server/utils/google-token.ts | 66 +++++ {backend => server}/utils/ip.ts | 0 {backend => server}/utils/openai.ts | 0 {backend => server}/utils/password.ts | 0 {backend => server}/utils/session.ts | 0 server/utils/svn-sync.ts | 217 ++++++++++++++++ {backend => server}/utils/user.ts | 0 {backend => server}/utils/week-calc.ts | 0 148 files changed, 2276 insertions(+) rename {backend => server}/api/admin/bulk-register.post.ts (100%) rename {backend => server}/api/admin/menu/[id]/toggle-role.post.ts (100%) rename {backend => server}/api/admin/menu/list.get.ts (100%) rename {backend => server}/api/admin/parse-image.post.ts (100%) rename {backend => server}/api/admin/parse-report.post.ts (100%) rename {backend => server}/api/admin/role/[id]/delete.delete.ts (100%) rename {backend => server}/api/admin/role/[id]/update.put.ts (100%) rename {backend => server}/api/admin/role/create.post.ts (100%) rename {backend => server}/api/admin/role/list.get.ts (100%) rename {backend => server}/api/admin/user/[id]/roles.put.ts (100%) rename {backend => server}/api/admin/user/[id]/toggle-role.post.ts (100%) rename {backend => server}/api/admin/user/list.get.ts (100%) create mode 100644 server/api/admin/user/reset-password.post.ts create mode 100644 server/api/admin/vcs/status.get.ts create mode 100644 server/api/admin/vcs/sync-all.post.ts rename {backend => server}/api/ai/parse-my-report-image.post.ts (100%) rename {backend => server}/api/ai/parse-my-report.post.ts (100%) rename {backend => server}/api/auth/change-password.post.ts (100%) rename {backend => server}/api/auth/current-user.get.ts (100%) rename {backend => server}/api/auth/google/callback.get.ts (100%) rename {backend => server}/api/auth/google/index.get.ts (100%) rename {backend => server}/api/auth/login-history.get.ts (100%) rename {backend => server}/api/auth/login-password.post.ts (100%) rename {backend => server}/api/auth/login.post.ts (100%) rename {backend => server}/api/auth/logout.post.ts (100%) rename {backend => server}/api/auth/me.get.ts (100%) rename {backend => server}/api/auth/menu.get.ts (100%) rename {backend => server}/api/auth/recent-users.get.ts (100%) rename {backend => server}/api/auth/reset-password.post.ts (100%) rename {backend => server}/api/auth/select-user.post.ts (100%) rename {backend => server}/api/auth/set-password.post.ts (100%) create mode 100644 server/api/auth/synology/callback.get.ts create mode 100644 server/api/auth/synology/index.get.ts rename {backend => server}/api/business-report/[id]/confirm.put.ts (100%) rename {backend => server}/api/business-report/[id]/detail.get.ts (100%) rename {backend => server}/api/business-report/[id]/update.put.ts (100%) rename {backend => server}/api/business-report/generate.post.ts (100%) rename {backend => server}/api/business-report/list.get.ts (100%) rename {backend => server}/api/business/[id]/delete.delete.ts (100%) rename {backend => server}/api/business/[id]/detail.get.ts (100%) rename {backend => server}/api/business/[id]/update.put.ts (100%) rename {backend => server}/api/business/create.post.ts (100%) rename {backend => server}/api/business/list.get.ts (100%) create mode 100644 server/api/commits/my-weekly.get.ts rename {backend => server}/api/dashboard/stats.get.ts (100%) rename {backend => server}/api/employee/[id]/delete.delete.ts (100%) rename {backend => server}/api/employee/[id]/detail.get.ts (100%) create mode 100644 server/api/employee/[id]/unlink-google.post.ts rename {backend => server}/api/employee/[id]/update.put.ts (100%) rename {backend => server}/api/employee/create.post.ts (100%) rename {backend => server}/api/employee/list.get.ts (100%) rename {backend => server}/api/employee/search.get.ts (100%) rename {backend => server}/api/feedback/[id]/delete.delete.ts (100%) rename {backend => server}/api/feedback/[id]/like.post.ts (100%) rename {backend => server}/api/feedback/[id]/update.put.ts (100%) rename {backend => server}/api/feedback/create.post.ts (100%) rename {backend => server}/api/feedback/list.get.ts (100%) create mode 100644 server/api/google-group/[id]/delete.delete.ts create mode 100644 server/api/google-group/[id]/messages.get.ts create mode 100644 server/api/google-group/create.post.ts create mode 100644 server/api/google-group/list.get.ts create mode 100644 server/api/google-group/messages.get.ts create mode 100644 server/api/google-group/share-report.post.ts rename {backend => server}/api/maintenance/[id]/delete.delete.ts (100%) rename {backend => server}/api/maintenance/[id]/detail.get.ts (100%) rename {backend => server}/api/maintenance/[id]/status.put.ts (100%) rename {backend => server}/api/maintenance/[id]/update.put.ts (100%) rename {backend => server}/api/maintenance/bulk-create.post.ts (100%) rename {backend => server}/api/maintenance/create.post.ts (100%) rename {backend => server}/api/maintenance/list.get.ts (100%) rename {backend => server}/api/maintenance/report/available.get.ts (100%) rename {backend => server}/api/maintenance/report/generate-text.post.ts (100%) rename {backend => server}/api/maintenance/report/link.post.ts (100%) rename {backend => server}/api/maintenance/stats.get.ts (100%) rename {backend => server}/api/maintenance/upload.post.ts (100%) create mode 100644 server/api/meeting/[id]/analyze.post.ts create mode 100644 server/api/meeting/[id]/confirm.post.ts rename {backend => server}/api/meeting/[id]/delete.delete.ts (100%) rename {backend => server}/api/meeting/[id]/detail.get.ts (100%) rename {backend => server}/api/meeting/[id]/update.put.ts (100%) rename {backend => server}/api/meeting/create.post.ts (100%) rename {backend => server}/api/meeting/list.get.ts (100%) create mode 100644 server/api/project/[id]/commits.get.ts create mode 100644 server/api/project/[id]/commits/refresh.post.ts rename {backend => server}/api/project/[id]/detail.get.ts (100%) rename {backend => server}/api/project/[id]/manager-assign.post.ts (100%) rename {backend => server}/api/project/[id]/manager-history.get.ts (100%) rename {backend => server}/api/project/[id]/update.put.ts (100%) rename {backend => server}/api/project/create.post.ts (100%) rename {backend => server}/api/project/list.get.ts (100%) rename {backend => server}/api/project/my-projects.get.ts (100%) rename {backend => server}/api/report/review.post.ts (100%) rename {backend => server}/api/report/summary/[id]/detail.get.ts (100%) rename {backend => server}/api/report/summary/[id]/review.put.ts (100%) rename {backend => server}/api/report/summary/aggregate.post.ts (100%) rename {backend => server}/api/report/summary/available-projects.get.ts (100%) rename {backend => server}/api/report/summary/list.get.ts (100%) rename {backend => server}/api/report/summary/regenerate-ai.post.ts (100%) rename {backend => server}/api/report/summary/week/detail.get.ts (100%) rename {backend => server}/api/report/summary/weekly-list.get.ts (100%) rename {backend => server}/api/report/weekly/[id]/delete.delete.ts (100%) rename {backend => server}/api/report/weekly/[id]/detail.get.ts (100%) create mode 100644 server/api/report/weekly/[id]/share.post.ts rename {backend => server}/api/report/weekly/[id]/submit.post.ts (100%) rename {backend => server}/api/report/weekly/[id]/update.put.ts (100%) rename {backend => server}/api/report/weekly/aggregate.get.ts (100%) rename {backend => server}/api/report/weekly/create.post.ts (100%) rename {backend => server}/api/report/weekly/current-week.get.ts (100%) rename {backend => server}/api/report/weekly/list.get.ts (100%) create mode 100644 server/api/repository/[id]/index.delete.ts create mode 100644 server/api/repository/[id]/index.put.ts create mode 100644 server/api/repository/[id]/sync.post.ts create mode 100644 server/api/repository/create.post.ts rename {backend => server}/api/repository/list.get.ts (100%) create mode 100644 server/api/todo/[id]/complete.put.ts rename {backend => server}/api/todo/[id]/delete.delete.ts (100%) rename {backend => server}/api/todo/[id]/detail.get.ts (100%) create mode 100644 server/api/todo/[id]/discard.put.ts rename {backend => server}/api/todo/[id]/update.put.ts (100%) rename {backend => server}/api/todo/create.post.ts (100%) rename {backend => server}/api/todo/list.get.ts (100%) rename {backend => server}/api/todo/report/link.post.ts (100%) rename {backend => server}/api/todo/report/similar.post.ts (100%) rename {backend => server}/api/vcs-account/[id]/delete.delete.ts (100%) rename {backend => server}/api/vcs-account/[id]/update.put.ts (100%) rename {backend => server}/api/vcs-account/create.post.ts (100%) rename {backend => server}/api/vcs-account/my.get.ts (100%) rename {backend => server}/api/vcs-server/[id]/delete.delete.ts (100%) rename {backend => server}/api/vcs-server/[id]/detail.get.ts (100%) rename {backend => server}/api/vcs-server/[id]/update.put.ts (100%) rename {backend => server}/api/vcs-server/create.post.ts (100%) rename {backend => server}/api/vcs-server/list.get.ts (100%) create mode 100644 server/plugins/vcs-sync-cron.ts create mode 100644 server/sql/add_synology_columns.sql create mode 100644 server/sql/create_report_share_log.sql rename {backend => server}/sql/create_role_tables.sql (100%) rename {backend => server}/sql/create_session_table.sql (100%) rename {backend => server}/utils/db.ts (100%) rename {backend => server}/utils/email.ts (100%) create mode 100644 server/utils/git-sync.ts create mode 100644 server/utils/google-token.ts rename {backend => server}/utils/ip.ts (100%) rename {backend => server}/utils/openai.ts (100%) rename {backend => server}/utils/password.ts (100%) rename {backend => server}/utils/session.ts (100%) create mode 100644 server/utils/svn-sync.ts rename {backend => server}/utils/user.ts (100%) rename {backend => server}/utils/week-calc.ts (100%) diff --git a/backend/api/admin/bulk-register.post.ts b/server/api/admin/bulk-register.post.ts similarity index 100% rename from backend/api/admin/bulk-register.post.ts rename to server/api/admin/bulk-register.post.ts diff --git a/backend/api/admin/menu/[id]/toggle-role.post.ts b/server/api/admin/menu/[id]/toggle-role.post.ts similarity index 100% rename from backend/api/admin/menu/[id]/toggle-role.post.ts rename to server/api/admin/menu/[id]/toggle-role.post.ts diff --git a/backend/api/admin/menu/list.get.ts b/server/api/admin/menu/list.get.ts similarity index 100% rename from backend/api/admin/menu/list.get.ts rename to server/api/admin/menu/list.get.ts diff --git a/backend/api/admin/parse-image.post.ts b/server/api/admin/parse-image.post.ts similarity index 100% rename from backend/api/admin/parse-image.post.ts rename to server/api/admin/parse-image.post.ts diff --git a/backend/api/admin/parse-report.post.ts b/server/api/admin/parse-report.post.ts similarity index 100% rename from backend/api/admin/parse-report.post.ts rename to server/api/admin/parse-report.post.ts diff --git a/backend/api/admin/role/[id]/delete.delete.ts b/server/api/admin/role/[id]/delete.delete.ts similarity index 100% rename from backend/api/admin/role/[id]/delete.delete.ts rename to server/api/admin/role/[id]/delete.delete.ts diff --git a/backend/api/admin/role/[id]/update.put.ts b/server/api/admin/role/[id]/update.put.ts similarity index 100% rename from backend/api/admin/role/[id]/update.put.ts rename to server/api/admin/role/[id]/update.put.ts diff --git a/backend/api/admin/role/create.post.ts b/server/api/admin/role/create.post.ts similarity index 100% rename from backend/api/admin/role/create.post.ts rename to server/api/admin/role/create.post.ts diff --git a/backend/api/admin/role/list.get.ts b/server/api/admin/role/list.get.ts similarity index 100% rename from backend/api/admin/role/list.get.ts rename to server/api/admin/role/list.get.ts diff --git a/backend/api/admin/user/[id]/roles.put.ts b/server/api/admin/user/[id]/roles.put.ts similarity index 100% rename from backend/api/admin/user/[id]/roles.put.ts rename to server/api/admin/user/[id]/roles.put.ts diff --git a/backend/api/admin/user/[id]/toggle-role.post.ts b/server/api/admin/user/[id]/toggle-role.post.ts similarity index 100% rename from backend/api/admin/user/[id]/toggle-role.post.ts rename to server/api/admin/user/[id]/toggle-role.post.ts diff --git a/backend/api/admin/user/list.get.ts b/server/api/admin/user/list.get.ts similarity index 100% rename from backend/api/admin/user/list.get.ts rename to server/api/admin/user/list.get.ts diff --git a/server/api/admin/user/reset-password.post.ts b/server/api/admin/user/reset-password.post.ts new file mode 100644 index 0000000..495fcf0 --- /dev/null +++ b/server/api/admin/user/reset-password.post.ts @@ -0,0 +1,58 @@ +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/server/api/admin/vcs/status.get.ts b/server/api/admin/vcs/status.get.ts new file mode 100644 index 0000000..69c6878 --- /dev/null +++ b/server/api/admin/vcs/status.get.ts @@ -0,0 +1,59 @@ +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/server/api/admin/vcs/sync-all.post.ts b/server/api/admin/vcs/sync-all.post.ts new file mode 100644 index 0000000..9f003db --- /dev/null +++ b/server/api/admin/vcs/sync-all.post.ts @@ -0,0 +1,63 @@ +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/ai/parse-my-report-image.post.ts b/server/api/ai/parse-my-report-image.post.ts similarity index 100% rename from backend/api/ai/parse-my-report-image.post.ts rename to server/api/ai/parse-my-report-image.post.ts diff --git a/backend/api/ai/parse-my-report.post.ts b/server/api/ai/parse-my-report.post.ts similarity index 100% rename from backend/api/ai/parse-my-report.post.ts rename to server/api/ai/parse-my-report.post.ts diff --git a/backend/api/auth/change-password.post.ts b/server/api/auth/change-password.post.ts similarity index 100% rename from backend/api/auth/change-password.post.ts rename to server/api/auth/change-password.post.ts diff --git a/backend/api/auth/current-user.get.ts b/server/api/auth/current-user.get.ts similarity index 100% rename from backend/api/auth/current-user.get.ts rename to server/api/auth/current-user.get.ts diff --git a/backend/api/auth/google/callback.get.ts b/server/api/auth/google/callback.get.ts similarity index 100% rename from backend/api/auth/google/callback.get.ts rename to server/api/auth/google/callback.get.ts diff --git a/backend/api/auth/google/index.get.ts b/server/api/auth/google/index.get.ts similarity index 100% rename from backend/api/auth/google/index.get.ts rename to server/api/auth/google/index.get.ts diff --git a/backend/api/auth/login-history.get.ts b/server/api/auth/login-history.get.ts similarity index 100% rename from backend/api/auth/login-history.get.ts rename to server/api/auth/login-history.get.ts diff --git a/backend/api/auth/login-password.post.ts b/server/api/auth/login-password.post.ts similarity index 100% rename from backend/api/auth/login-password.post.ts rename to server/api/auth/login-password.post.ts diff --git a/backend/api/auth/login.post.ts b/server/api/auth/login.post.ts similarity index 100% rename from backend/api/auth/login.post.ts rename to server/api/auth/login.post.ts diff --git a/backend/api/auth/logout.post.ts b/server/api/auth/logout.post.ts similarity index 100% rename from backend/api/auth/logout.post.ts rename to server/api/auth/logout.post.ts diff --git a/backend/api/auth/me.get.ts b/server/api/auth/me.get.ts similarity index 100% rename from backend/api/auth/me.get.ts rename to server/api/auth/me.get.ts diff --git a/backend/api/auth/menu.get.ts b/server/api/auth/menu.get.ts similarity index 100% rename from backend/api/auth/menu.get.ts rename to server/api/auth/menu.get.ts diff --git a/backend/api/auth/recent-users.get.ts b/server/api/auth/recent-users.get.ts similarity index 100% rename from backend/api/auth/recent-users.get.ts rename to server/api/auth/recent-users.get.ts diff --git a/backend/api/auth/reset-password.post.ts b/server/api/auth/reset-password.post.ts similarity index 100% rename from backend/api/auth/reset-password.post.ts rename to server/api/auth/reset-password.post.ts diff --git a/backend/api/auth/select-user.post.ts b/server/api/auth/select-user.post.ts similarity index 100% rename from backend/api/auth/select-user.post.ts rename to server/api/auth/select-user.post.ts diff --git a/backend/api/auth/set-password.post.ts b/server/api/auth/set-password.post.ts similarity index 100% rename from backend/api/auth/set-password.post.ts rename to server/api/auth/set-password.post.ts diff --git a/server/api/auth/synology/callback.get.ts b/server/api/auth/synology/callback.get.ts new file mode 100644 index 0000000..7e73df2 --- /dev/null +++ b/server/api/auth/synology/callback.get.ts @@ -0,0 +1,107 @@ +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/server/api/auth/synology/index.get.ts b/server/api/auth/synology/index.get.ts new file mode 100644 index 0000000..783fa67 --- /dev/null +++ b/server/api/auth/synology/index.get.ts @@ -0,0 +1,26 @@ +/** + * 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/business-report/[id]/confirm.put.ts b/server/api/business-report/[id]/confirm.put.ts similarity index 100% rename from backend/api/business-report/[id]/confirm.put.ts rename to server/api/business-report/[id]/confirm.put.ts diff --git a/backend/api/business-report/[id]/detail.get.ts b/server/api/business-report/[id]/detail.get.ts similarity index 100% rename from backend/api/business-report/[id]/detail.get.ts rename to server/api/business-report/[id]/detail.get.ts diff --git a/backend/api/business-report/[id]/update.put.ts b/server/api/business-report/[id]/update.put.ts similarity index 100% rename from backend/api/business-report/[id]/update.put.ts rename to server/api/business-report/[id]/update.put.ts diff --git a/backend/api/business-report/generate.post.ts b/server/api/business-report/generate.post.ts similarity index 100% rename from backend/api/business-report/generate.post.ts rename to server/api/business-report/generate.post.ts diff --git a/backend/api/business-report/list.get.ts b/server/api/business-report/list.get.ts similarity index 100% rename from backend/api/business-report/list.get.ts rename to server/api/business-report/list.get.ts diff --git a/backend/api/business/[id]/delete.delete.ts b/server/api/business/[id]/delete.delete.ts similarity index 100% rename from backend/api/business/[id]/delete.delete.ts rename to server/api/business/[id]/delete.delete.ts diff --git a/backend/api/business/[id]/detail.get.ts b/server/api/business/[id]/detail.get.ts similarity index 100% rename from backend/api/business/[id]/detail.get.ts rename to server/api/business/[id]/detail.get.ts diff --git a/backend/api/business/[id]/update.put.ts b/server/api/business/[id]/update.put.ts similarity index 100% rename from backend/api/business/[id]/update.put.ts rename to server/api/business/[id]/update.put.ts diff --git a/backend/api/business/create.post.ts b/server/api/business/create.post.ts similarity index 100% rename from backend/api/business/create.post.ts rename to server/api/business/create.post.ts diff --git a/backend/api/business/list.get.ts b/server/api/business/list.get.ts similarity index 100% rename from backend/api/business/list.get.ts rename to server/api/business/list.get.ts diff --git a/server/api/commits/my-weekly.get.ts b/server/api/commits/my-weekly.get.ts new file mode 100644 index 0000000..c662e21 --- /dev/null +++ b/server/api/commits/my-weekly.get.ts @@ -0,0 +1,90 @@ +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/dashboard/stats.get.ts b/server/api/dashboard/stats.get.ts similarity index 100% rename from backend/api/dashboard/stats.get.ts rename to server/api/dashboard/stats.get.ts diff --git a/backend/api/employee/[id]/delete.delete.ts b/server/api/employee/[id]/delete.delete.ts similarity index 100% rename from backend/api/employee/[id]/delete.delete.ts rename to server/api/employee/[id]/delete.delete.ts diff --git a/backend/api/employee/[id]/detail.get.ts b/server/api/employee/[id]/detail.get.ts similarity index 100% rename from backend/api/employee/[id]/detail.get.ts rename to server/api/employee/[id]/detail.get.ts diff --git a/server/api/employee/[id]/unlink-google.post.ts b/server/api/employee/[id]/unlink-google.post.ts new file mode 100644 index 0000000..afe34ab --- /dev/null +++ b/server/api/employee/[id]/unlink-google.post.ts @@ -0,0 +1,34 @@ +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/employee/[id]/update.put.ts b/server/api/employee/[id]/update.put.ts similarity index 100% rename from backend/api/employee/[id]/update.put.ts rename to server/api/employee/[id]/update.put.ts diff --git a/backend/api/employee/create.post.ts b/server/api/employee/create.post.ts similarity index 100% rename from backend/api/employee/create.post.ts rename to server/api/employee/create.post.ts diff --git a/backend/api/employee/list.get.ts b/server/api/employee/list.get.ts similarity index 100% rename from backend/api/employee/list.get.ts rename to server/api/employee/list.get.ts diff --git a/backend/api/employee/search.get.ts b/server/api/employee/search.get.ts similarity index 100% rename from backend/api/employee/search.get.ts rename to server/api/employee/search.get.ts diff --git a/backend/api/feedback/[id]/delete.delete.ts b/server/api/feedback/[id]/delete.delete.ts similarity index 100% rename from backend/api/feedback/[id]/delete.delete.ts rename to server/api/feedback/[id]/delete.delete.ts diff --git a/backend/api/feedback/[id]/like.post.ts b/server/api/feedback/[id]/like.post.ts similarity index 100% rename from backend/api/feedback/[id]/like.post.ts rename to server/api/feedback/[id]/like.post.ts diff --git a/backend/api/feedback/[id]/update.put.ts b/server/api/feedback/[id]/update.put.ts similarity index 100% rename from backend/api/feedback/[id]/update.put.ts rename to server/api/feedback/[id]/update.put.ts diff --git a/backend/api/feedback/create.post.ts b/server/api/feedback/create.post.ts similarity index 100% rename from backend/api/feedback/create.post.ts rename to server/api/feedback/create.post.ts diff --git a/backend/api/feedback/list.get.ts b/server/api/feedback/list.get.ts similarity index 100% rename from backend/api/feedback/list.get.ts rename to server/api/feedback/list.get.ts diff --git a/server/api/google-group/[id]/delete.delete.ts b/server/api/google-group/[id]/delete.delete.ts new file mode 100644 index 0000000..d08f573 --- /dev/null +++ b/server/api/google-group/[id]/delete.delete.ts @@ -0,0 +1,22 @@ +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/server/api/google-group/[id]/messages.get.ts b/server/api/google-group/[id]/messages.get.ts new file mode 100644 index 0000000..a7f919c --- /dev/null +++ b/server/api/google-group/[id]/messages.get.ts @@ -0,0 +1,87 @@ +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/server/api/google-group/create.post.ts b/server/api/google-group/create.post.ts new file mode 100644 index 0000000..17f8035 --- /dev/null +++ b/server/api/google-group/create.post.ts @@ -0,0 +1,23 @@ +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/server/api/google-group/list.get.ts b/server/api/google-group/list.get.ts new file mode 100644 index 0000000..7e5d4af --- /dev/null +++ b/server/api/google-group/list.get.ts @@ -0,0 +1,28 @@ +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/server/api/google-group/messages.get.ts b/server/api/google-group/messages.get.ts new file mode 100644 index 0000000..136ff8d --- /dev/null +++ b/server/api/google-group/messages.get.ts @@ -0,0 +1,139 @@ +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/server/api/google-group/share-report.post.ts b/server/api/google-group/share-report.post.ts new file mode 100644 index 0000000..889d993 --- /dev/null +++ b/server/api/google-group/share-report.post.ts @@ -0,0 +1,207 @@ +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/maintenance/[id]/delete.delete.ts b/server/api/maintenance/[id]/delete.delete.ts similarity index 100% rename from backend/api/maintenance/[id]/delete.delete.ts rename to server/api/maintenance/[id]/delete.delete.ts diff --git a/backend/api/maintenance/[id]/detail.get.ts b/server/api/maintenance/[id]/detail.get.ts similarity index 100% rename from backend/api/maintenance/[id]/detail.get.ts rename to server/api/maintenance/[id]/detail.get.ts diff --git a/backend/api/maintenance/[id]/status.put.ts b/server/api/maintenance/[id]/status.put.ts similarity index 100% rename from backend/api/maintenance/[id]/status.put.ts rename to server/api/maintenance/[id]/status.put.ts diff --git a/backend/api/maintenance/[id]/update.put.ts b/server/api/maintenance/[id]/update.put.ts similarity index 100% rename from backend/api/maintenance/[id]/update.put.ts rename to server/api/maintenance/[id]/update.put.ts diff --git a/backend/api/maintenance/bulk-create.post.ts b/server/api/maintenance/bulk-create.post.ts similarity index 100% rename from backend/api/maintenance/bulk-create.post.ts rename to server/api/maintenance/bulk-create.post.ts diff --git a/backend/api/maintenance/create.post.ts b/server/api/maintenance/create.post.ts similarity index 100% rename from backend/api/maintenance/create.post.ts rename to server/api/maintenance/create.post.ts diff --git a/backend/api/maintenance/list.get.ts b/server/api/maintenance/list.get.ts similarity index 100% rename from backend/api/maintenance/list.get.ts rename to server/api/maintenance/list.get.ts diff --git a/backend/api/maintenance/report/available.get.ts b/server/api/maintenance/report/available.get.ts similarity index 100% rename from backend/api/maintenance/report/available.get.ts rename to server/api/maintenance/report/available.get.ts diff --git a/backend/api/maintenance/report/generate-text.post.ts b/server/api/maintenance/report/generate-text.post.ts similarity index 100% rename from backend/api/maintenance/report/generate-text.post.ts rename to server/api/maintenance/report/generate-text.post.ts diff --git a/backend/api/maintenance/report/link.post.ts b/server/api/maintenance/report/link.post.ts similarity index 100% rename from backend/api/maintenance/report/link.post.ts rename to server/api/maintenance/report/link.post.ts diff --git a/backend/api/maintenance/stats.get.ts b/server/api/maintenance/stats.get.ts similarity index 100% rename from backend/api/maintenance/stats.get.ts rename to server/api/maintenance/stats.get.ts diff --git a/backend/api/maintenance/upload.post.ts b/server/api/maintenance/upload.post.ts similarity index 100% rename from backend/api/maintenance/upload.post.ts rename to server/api/maintenance/upload.post.ts diff --git a/server/api/meeting/[id]/analyze.post.ts b/server/api/meeting/[id]/analyze.post.ts new file mode 100644 index 0000000..7c732ef --- /dev/null +++ b/server/api/meeting/[id]/analyze.post.ts @@ -0,0 +1,105 @@ +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/server/api/meeting/[id]/confirm.post.ts b/server/api/meeting/[id]/confirm.post.ts new file mode 100644 index 0000000..bd0f3ac --- /dev/null +++ b/server/api/meeting/[id]/confirm.post.ts @@ -0,0 +1,82 @@ +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/meeting/[id]/delete.delete.ts b/server/api/meeting/[id]/delete.delete.ts similarity index 100% rename from backend/api/meeting/[id]/delete.delete.ts rename to server/api/meeting/[id]/delete.delete.ts diff --git a/backend/api/meeting/[id]/detail.get.ts b/server/api/meeting/[id]/detail.get.ts similarity index 100% rename from backend/api/meeting/[id]/detail.get.ts rename to server/api/meeting/[id]/detail.get.ts diff --git a/backend/api/meeting/[id]/update.put.ts b/server/api/meeting/[id]/update.put.ts similarity index 100% rename from backend/api/meeting/[id]/update.put.ts rename to server/api/meeting/[id]/update.put.ts diff --git a/backend/api/meeting/create.post.ts b/server/api/meeting/create.post.ts similarity index 100% rename from backend/api/meeting/create.post.ts rename to server/api/meeting/create.post.ts diff --git a/backend/api/meeting/list.get.ts b/server/api/meeting/list.get.ts similarity index 100% rename from backend/api/meeting/list.get.ts rename to server/api/meeting/list.get.ts diff --git a/server/api/project/[id]/commits.get.ts b/server/api/project/[id]/commits.get.ts new file mode 100644 index 0000000..83d6935 --- /dev/null +++ b/server/api/project/[id]/commits.get.ts @@ -0,0 +1,131 @@ +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/server/api/project/[id]/commits/refresh.post.ts b/server/api/project/[id]/commits/refresh.post.ts new file mode 100644 index 0000000..e64a995 --- /dev/null +++ b/server/api/project/[id]/commits/refresh.post.ts @@ -0,0 +1,31 @@ +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/project/[id]/detail.get.ts b/server/api/project/[id]/detail.get.ts similarity index 100% rename from backend/api/project/[id]/detail.get.ts rename to server/api/project/[id]/detail.get.ts diff --git a/backend/api/project/[id]/manager-assign.post.ts b/server/api/project/[id]/manager-assign.post.ts similarity index 100% rename from backend/api/project/[id]/manager-assign.post.ts rename to server/api/project/[id]/manager-assign.post.ts diff --git a/backend/api/project/[id]/manager-history.get.ts b/server/api/project/[id]/manager-history.get.ts similarity index 100% rename from backend/api/project/[id]/manager-history.get.ts rename to server/api/project/[id]/manager-history.get.ts diff --git a/backend/api/project/[id]/update.put.ts b/server/api/project/[id]/update.put.ts similarity index 100% rename from backend/api/project/[id]/update.put.ts rename to server/api/project/[id]/update.put.ts diff --git a/backend/api/project/create.post.ts b/server/api/project/create.post.ts similarity index 100% rename from backend/api/project/create.post.ts rename to server/api/project/create.post.ts diff --git a/backend/api/project/list.get.ts b/server/api/project/list.get.ts similarity index 100% rename from backend/api/project/list.get.ts rename to server/api/project/list.get.ts diff --git a/backend/api/project/my-projects.get.ts b/server/api/project/my-projects.get.ts similarity index 100% rename from backend/api/project/my-projects.get.ts rename to server/api/project/my-projects.get.ts diff --git a/backend/api/report/review.post.ts b/server/api/report/review.post.ts similarity index 100% rename from backend/api/report/review.post.ts rename to server/api/report/review.post.ts diff --git a/backend/api/report/summary/[id]/detail.get.ts b/server/api/report/summary/[id]/detail.get.ts similarity index 100% rename from backend/api/report/summary/[id]/detail.get.ts rename to server/api/report/summary/[id]/detail.get.ts diff --git a/backend/api/report/summary/[id]/review.put.ts b/server/api/report/summary/[id]/review.put.ts similarity index 100% rename from backend/api/report/summary/[id]/review.put.ts rename to server/api/report/summary/[id]/review.put.ts diff --git a/backend/api/report/summary/aggregate.post.ts b/server/api/report/summary/aggregate.post.ts similarity index 100% rename from backend/api/report/summary/aggregate.post.ts rename to server/api/report/summary/aggregate.post.ts diff --git a/backend/api/report/summary/available-projects.get.ts b/server/api/report/summary/available-projects.get.ts similarity index 100% rename from backend/api/report/summary/available-projects.get.ts rename to server/api/report/summary/available-projects.get.ts diff --git a/backend/api/report/summary/list.get.ts b/server/api/report/summary/list.get.ts similarity index 100% rename from backend/api/report/summary/list.get.ts rename to server/api/report/summary/list.get.ts diff --git a/backend/api/report/summary/regenerate-ai.post.ts b/server/api/report/summary/regenerate-ai.post.ts similarity index 100% rename from backend/api/report/summary/regenerate-ai.post.ts rename to server/api/report/summary/regenerate-ai.post.ts diff --git a/backend/api/report/summary/week/detail.get.ts b/server/api/report/summary/week/detail.get.ts similarity index 100% rename from backend/api/report/summary/week/detail.get.ts rename to server/api/report/summary/week/detail.get.ts diff --git a/backend/api/report/summary/weekly-list.get.ts b/server/api/report/summary/weekly-list.get.ts similarity index 100% rename from backend/api/report/summary/weekly-list.get.ts rename to server/api/report/summary/weekly-list.get.ts diff --git a/backend/api/report/weekly/[id]/delete.delete.ts b/server/api/report/weekly/[id]/delete.delete.ts similarity index 100% rename from backend/api/report/weekly/[id]/delete.delete.ts rename to server/api/report/weekly/[id]/delete.delete.ts diff --git a/backend/api/report/weekly/[id]/detail.get.ts b/server/api/report/weekly/[id]/detail.get.ts similarity index 100% rename from backend/api/report/weekly/[id]/detail.get.ts rename to server/api/report/weekly/[id]/detail.get.ts diff --git a/server/api/report/weekly/[id]/share.post.ts b/server/api/report/weekly/[id]/share.post.ts new file mode 100644 index 0000000..5d5c6a0 --- /dev/null +++ b/server/api/report/weekly/[id]/share.post.ts @@ -0,0 +1,100 @@ +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/report/weekly/[id]/submit.post.ts b/server/api/report/weekly/[id]/submit.post.ts similarity index 100% rename from backend/api/report/weekly/[id]/submit.post.ts rename to server/api/report/weekly/[id]/submit.post.ts diff --git a/backend/api/report/weekly/[id]/update.put.ts b/server/api/report/weekly/[id]/update.put.ts similarity index 100% rename from backend/api/report/weekly/[id]/update.put.ts rename to server/api/report/weekly/[id]/update.put.ts diff --git a/backend/api/report/weekly/aggregate.get.ts b/server/api/report/weekly/aggregate.get.ts similarity index 100% rename from backend/api/report/weekly/aggregate.get.ts rename to server/api/report/weekly/aggregate.get.ts diff --git a/backend/api/report/weekly/create.post.ts b/server/api/report/weekly/create.post.ts similarity index 100% rename from backend/api/report/weekly/create.post.ts rename to server/api/report/weekly/create.post.ts diff --git a/backend/api/report/weekly/current-week.get.ts b/server/api/report/weekly/current-week.get.ts similarity index 100% rename from backend/api/report/weekly/current-week.get.ts rename to server/api/report/weekly/current-week.get.ts diff --git a/backend/api/report/weekly/list.get.ts b/server/api/report/weekly/list.get.ts similarity index 100% rename from backend/api/report/weekly/list.get.ts rename to server/api/report/weekly/list.get.ts diff --git a/server/api/repository/[id]/index.delete.ts b/server/api/repository/[id]/index.delete.ts new file mode 100644 index 0000000..6c1e893 --- /dev/null +++ b/server/api/repository/[id]/index.delete.ts @@ -0,0 +1,22 @@ +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/server/api/repository/[id]/index.put.ts b/server/api/repository/[id]/index.put.ts new file mode 100644 index 0000000..fb14caf --- /dev/null +++ b/server/api/repository/[id]/index.put.ts @@ -0,0 +1,64 @@ +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/server/api/repository/[id]/sync.post.ts b/server/api/repository/[id]/sync.post.ts new file mode 100644 index 0000000..c798637 --- /dev/null +++ b/server/api/repository/[id]/sync.post.ts @@ -0,0 +1,39 @@ +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/server/api/repository/create.post.ts b/server/api/repository/create.post.ts new file mode 100644 index 0000000..99a1e78 --- /dev/null +++ b/server/api/repository/create.post.ts @@ -0,0 +1,47 @@ +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/repository/list.get.ts b/server/api/repository/list.get.ts similarity index 100% rename from backend/api/repository/list.get.ts rename to server/api/repository/list.get.ts diff --git a/server/api/todo/[id]/complete.put.ts b/server/api/todo/[id]/complete.put.ts new file mode 100644 index 0000000..a9c976b --- /dev/null +++ b/server/api/todo/[id]/complete.put.ts @@ -0,0 +1,23 @@ +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]/delete.delete.ts b/server/api/todo/[id]/delete.delete.ts similarity index 100% rename from backend/api/todo/[id]/delete.delete.ts rename to server/api/todo/[id]/delete.delete.ts diff --git a/backend/api/todo/[id]/detail.get.ts b/server/api/todo/[id]/detail.get.ts similarity index 100% rename from backend/api/todo/[id]/detail.get.ts rename to server/api/todo/[id]/detail.get.ts diff --git a/server/api/todo/[id]/discard.put.ts b/server/api/todo/[id]/discard.put.ts new file mode 100644 index 0000000..ed18ac9 --- /dev/null +++ b/server/api/todo/[id]/discard.put.ts @@ -0,0 +1,28 @@ +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/api/todo/[id]/update.put.ts b/server/api/todo/[id]/update.put.ts similarity index 100% rename from backend/api/todo/[id]/update.put.ts rename to server/api/todo/[id]/update.put.ts diff --git a/backend/api/todo/create.post.ts b/server/api/todo/create.post.ts similarity index 100% rename from backend/api/todo/create.post.ts rename to server/api/todo/create.post.ts diff --git a/backend/api/todo/list.get.ts b/server/api/todo/list.get.ts similarity index 100% rename from backend/api/todo/list.get.ts rename to server/api/todo/list.get.ts diff --git a/backend/api/todo/report/link.post.ts b/server/api/todo/report/link.post.ts similarity index 100% rename from backend/api/todo/report/link.post.ts rename to server/api/todo/report/link.post.ts diff --git a/backend/api/todo/report/similar.post.ts b/server/api/todo/report/similar.post.ts similarity index 100% rename from backend/api/todo/report/similar.post.ts rename to server/api/todo/report/similar.post.ts diff --git a/backend/api/vcs-account/[id]/delete.delete.ts b/server/api/vcs-account/[id]/delete.delete.ts similarity index 100% rename from backend/api/vcs-account/[id]/delete.delete.ts rename to server/api/vcs-account/[id]/delete.delete.ts diff --git a/backend/api/vcs-account/[id]/update.put.ts b/server/api/vcs-account/[id]/update.put.ts similarity index 100% rename from backend/api/vcs-account/[id]/update.put.ts rename to server/api/vcs-account/[id]/update.put.ts diff --git a/backend/api/vcs-account/create.post.ts b/server/api/vcs-account/create.post.ts similarity index 100% rename from backend/api/vcs-account/create.post.ts rename to server/api/vcs-account/create.post.ts diff --git a/backend/api/vcs-account/my.get.ts b/server/api/vcs-account/my.get.ts similarity index 100% rename from backend/api/vcs-account/my.get.ts rename to server/api/vcs-account/my.get.ts diff --git a/backend/api/vcs-server/[id]/delete.delete.ts b/server/api/vcs-server/[id]/delete.delete.ts similarity index 100% rename from backend/api/vcs-server/[id]/delete.delete.ts rename to server/api/vcs-server/[id]/delete.delete.ts diff --git a/backend/api/vcs-server/[id]/detail.get.ts b/server/api/vcs-server/[id]/detail.get.ts similarity index 100% rename from backend/api/vcs-server/[id]/detail.get.ts rename to server/api/vcs-server/[id]/detail.get.ts diff --git a/backend/api/vcs-server/[id]/update.put.ts b/server/api/vcs-server/[id]/update.put.ts similarity index 100% rename from backend/api/vcs-server/[id]/update.put.ts rename to server/api/vcs-server/[id]/update.put.ts diff --git a/backend/api/vcs-server/create.post.ts b/server/api/vcs-server/create.post.ts similarity index 100% rename from backend/api/vcs-server/create.post.ts rename to server/api/vcs-server/create.post.ts diff --git a/backend/api/vcs-server/list.get.ts b/server/api/vcs-server/list.get.ts similarity index 100% rename from backend/api/vcs-server/list.get.ts rename to server/api/vcs-server/list.get.ts diff --git a/server/plugins/vcs-sync-cron.ts b/server/plugins/vcs-sync-cron.ts new file mode 100644 index 0000000..22f2434 --- /dev/null +++ b/server/plugins/vcs-sync-cron.ts @@ -0,0 +1,102 @@ +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/server/sql/add_synology_columns.sql b/server/sql/add_synology_columns.sql new file mode 100644 index 0000000..f07b459 --- /dev/null +++ b/server/sql/add_synology_columns.sql @@ -0,0 +1,19 @@ +-- 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/server/sql/create_report_share_log.sql b/server/sql/create_report_share_log.sql new file mode 100644 index 0000000..44ea025 --- /dev/null +++ b/server/sql/create_report_share_log.sql @@ -0,0 +1,21 @@ +-- 주간보고 공유 이력 테이블 +-- 실행: 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/sql/create_role_tables.sql b/server/sql/create_role_tables.sql similarity index 100% rename from backend/sql/create_role_tables.sql rename to server/sql/create_role_tables.sql diff --git a/backend/sql/create_session_table.sql b/server/sql/create_session_table.sql similarity index 100% rename from backend/sql/create_session_table.sql rename to server/sql/create_session_table.sql diff --git a/backend/utils/db.ts b/server/utils/db.ts similarity index 100% rename from backend/utils/db.ts rename to server/utils/db.ts diff --git a/backend/utils/email.ts b/server/utils/email.ts similarity index 100% rename from backend/utils/email.ts rename to server/utils/email.ts diff --git a/server/utils/git-sync.ts b/server/utils/git-sync.ts new file mode 100644 index 0000000..ba0e223 --- /dev/null +++ b/server/utils/git-sync.ts @@ -0,0 +1,236 @@ +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/server/utils/google-token.ts b/server/utils/google-token.ts new file mode 100644 index 0000000..e9039da --- /dev/null +++ b/server/utils/google-token.ts @@ -0,0 +1,66 @@ +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/ip.ts b/server/utils/ip.ts similarity index 100% rename from backend/utils/ip.ts rename to server/utils/ip.ts diff --git a/backend/utils/openai.ts b/server/utils/openai.ts similarity index 100% rename from backend/utils/openai.ts rename to server/utils/openai.ts diff --git a/backend/utils/password.ts b/server/utils/password.ts similarity index 100% rename from backend/utils/password.ts rename to server/utils/password.ts diff --git a/backend/utils/session.ts b/server/utils/session.ts similarity index 100% rename from backend/utils/session.ts rename to server/utils/session.ts diff --git a/server/utils/svn-sync.ts b/server/utils/svn-sync.ts new file mode 100644 index 0000000..b893126 --- /dev/null +++ b/server/utils/svn-sync.ts @@ -0,0 +1,217 @@ +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 } +} diff --git a/backend/utils/user.ts b/server/utils/user.ts similarity index 100% rename from backend/utils/user.ts rename to server/utils/user.ts diff --git a/backend/utils/week-calc.ts b/server/utils/week-calc.ts similarity index 100% rename from backend/utils/week-calc.ts rename to server/utils/week-calc.ts