From 0205e8d437a10969480095f2b69a9b5b6d90a686 Mon Sep 17 00:00:00 2001 From: Hyoseong Jo Date: Sun, 11 Jan 2026 14:32:45 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EA=B5=AC=ED=98=84=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/admin/vcs/status.get.ts | 59 +++++ backend/api/admin/vcs/sync-all.post.ts | 63 ++++++ backend/api/auth/google/index.get.ts | 17 +- backend/api/auth/me.get.ts | 8 +- backend/api/auth/synology/callback.get.ts | 107 +++++++++ backend/api/auth/synology/index.get.ts | 26 +++ backend/api/google-group/list.get.ts | 102 +++++++++ backend/api/google-group/messages.get.ts | 139 ++++++++++++ backend/api/google-group/share-report.post.ts | 207 ++++++++++++++++++ backend/plugins/vcs-sync-cron.ts | 102 +++++++++ backend/sql/add_synology_columns.sql | 19 ++ backend/sql/create_report_share_log.sql | 21 ++ claude_temp/00_마스터_작업계획서.md | 126 ++++++----- frontend/login.vue | 7 +- frontend/mypage/index.vue | 53 ++++- nuxt.config.ts | 5 + package-lock.json | 24 ++ 17 files changed, 1015 insertions(+), 70 deletions(-) create mode 100644 backend/api/admin/vcs/status.get.ts create mode 100644 backend/api/admin/vcs/sync-all.post.ts create mode 100644 backend/api/auth/synology/callback.get.ts create mode 100644 backend/api/auth/synology/index.get.ts create mode 100644 backend/api/google-group/list.get.ts create mode 100644 backend/api/google-group/messages.get.ts create mode 100644 backend/api/google-group/share-report.post.ts create mode 100644 backend/plugins/vcs-sync-cron.ts create mode 100644 backend/sql/add_synology_columns.sql create mode 100644 backend/sql/create_report_share_log.sql diff --git a/backend/api/admin/vcs/status.get.ts b/backend/api/admin/vcs/status.get.ts new file mode 100644 index 0000000..69c6878 --- /dev/null +++ b/backend/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/backend/api/admin/vcs/sync-all.post.ts b/backend/api/admin/vcs/sync-all.post.ts new file mode 100644 index 0000000..9f003db --- /dev/null +++ b/backend/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/auth/google/index.get.ts b/backend/api/auth/google/index.get.ts index fc063b4..552dc96 100644 --- a/backend/api/auth/google/index.get.ts +++ b/backend/api/auth/google/index.get.ts @@ -1,9 +1,13 @@ /** * Google OAuth 시작 * GET /api/auth/google + * + * Query params: + * - extend: 'groups' - 구글 그룹 접근 권한 추가 요청 */ export default defineEventHandler(async (event) => { const config = useRuntimeConfig() + const query = getQuery(event) const clientId = config.googleClientId || process.env.GOOGLE_CLIENT_ID const redirectUri = config.googleRedirectUri || process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/api/auth/google/callback' @@ -12,7 +16,18 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 500, message: 'Google OAuth가 설정되지 않았습니다.' }) } - const scope = encodeURIComponent('openid email profile') + // 기본 scope + 확장 scope + let scopes = ['openid', 'email', 'profile'] + + // 구글 그룹 권한 요청 시 추가 scope + if (query.extend === 'groups') { + scopes.push( + 'https://www.googleapis.com/auth/gmail.readonly', // 그룹 메일 읽기 + 'https://www.googleapis.com/auth/cloud-identity.groups.readonly' // 그룹 정보 읽기 + ) + } + + const scope = encodeURIComponent(scopes.join(' ')) const state = Math.random().toString(36).substring(7) // CSRF 방지 // state를 쿠키에 저장 diff --git a/backend/api/auth/me.get.ts b/backend/api/auth/me.get.ts index 975f104..ee8f9fc 100644 --- a/backend/api/auth/me.get.ts +++ b/backend/api/auth/me.get.ts @@ -35,7 +35,9 @@ export default defineEventHandler(async (event) => { updated_ip, password_hash, google_id, - google_email + google_email, + synology_id, + synology_email FROM wr_employee_info WHERE employee_id = $1 `, [session.employeeId]) @@ -60,7 +62,9 @@ export default defineEventHandler(async (event) => { updatedIp: employee.updated_ip, hasPassword: !!employee.password_hash, googleId: employee.google_id, - googleEmail: employee.google_email + googleEmail: employee.google_email, + synologyId: employee.synology_id, + synologyEmail: employee.synology_email } } }) diff --git a/backend/api/auth/synology/callback.get.ts b/backend/api/auth/synology/callback.get.ts new file mode 100644 index 0000000..7e73df2 --- /dev/null +++ b/backend/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/backend/api/auth/synology/index.get.ts b/backend/api/auth/synology/index.get.ts new file mode 100644 index 0000000..783fa67 --- /dev/null +++ b/backend/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/google-group/list.get.ts b/backend/api/google-group/list.get.ts new file mode 100644 index 0000000..c4e574f --- /dev/null +++ b/backend/api/google-group/list.get.ts @@ -0,0 +1,102 @@ +import { queryOne } from '../../utils/db' +import { requireAuth } from '../../utils/session' + +/** + * 구글 그룹 목록 조회 + * GET /api/google-group/list + * + * Cloud Identity Groups API 사용 + */ +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + + // 사용자의 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 계정이 연결되지 않았습니다. 먼저 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 { + // Cloud Identity Groups API로 그룹 목록 조회 + const response = await $fetch('https://cloudidentity.googleapis.com/v1/groups:search', { + method: 'GET', + headers: { 'Authorization': `Bearer ${accessToken}` }, + query: { + query: 'parent == "customers/my_customer" && "cloudidentity.googleapis.com/groups.discussion_forum" in labels', + pageSize: 100 + } + }) + + return { + groups: (response.groups || []).map((g: any) => ({ + groupId: g.name?.split('/')[1], + groupKey: g.groupKey?.id, + displayName: g.displayName, + description: g.description, + email: g.groupKey?.id + })) + } + } catch (e: any) { + console.error('Google Groups API error:', e) + + // 권한 없음 에러 + if (e.status === 403) { + throw createError({ + statusCode: 403, + message: '구글 그룹 접근 권한이 없습니다. 관리자에게 문의하세요.' + }) + } + + 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) { + // DB 업데이트 + const { execute } = await import('../../utils/db') + await execute(` + UPDATE wr_employee_info + SET google_access_token = $1, + google_token_expires_at = NOW() + INTERVAL '${response.expires_in} seconds' + WHERE employee_id = $2 + `, [response.access_token, employeeId]) + + return response.access_token + } + + throw new Error('토큰 갱신 실패') +} diff --git a/backend/api/google-group/messages.get.ts b/backend/api/google-group/messages.get.ts new file mode 100644 index 0000000..136ff8d --- /dev/null +++ b/backend/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/backend/api/google-group/share-report.post.ts b/backend/api/google-group/share-report.post.ts new file mode 100644 index 0000000..889d993 --- /dev/null +++ b/backend/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/plugins/vcs-sync-cron.ts b/backend/plugins/vcs-sync-cron.ts new file mode 100644 index 0000000..22f2434 --- /dev/null +++ b/backend/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/backend/sql/add_synology_columns.sql b/backend/sql/add_synology_columns.sql new file mode 100644 index 0000000..f07b459 --- /dev/null +++ b/backend/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/backend/sql/create_report_share_log.sql b/backend/sql/create_report_share_log.sql new file mode 100644 index 0000000..44ea025 --- /dev/null +++ b/backend/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/claude_temp/00_마스터_작업계획서.md b/claude_temp/00_마스터_작업계획서.md index bd78b0b..07ab93f 100644 --- a/claude_temp/00_마스터_작업계획서.md +++ b/claude_temp/00_마스터_작업계획서.md @@ -446,15 +446,15 @@ Stage 0 ██ DB 마이 - [x] 참석자 선택 (내부/외부) ✅ - [x] 프로젝트/내부업무 구분 ✅ -### Phase 01-P2: AI 분석 연동 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] OpenAI 프롬프트 구현 (회의 정리) -- [ ] 저장 시 자동 AI 분석 실행 -- [ ] AI 결과 → 안건 테이블 저장 -- [ ] AI 결과 → TODO 후보 추출 -- [ ] 상세 화면에 AI 분석 결과 표시 -- [ ] 재분석 기능 -- [ ] 확정 기능 (→ TODO 생성) +### Phase 01-P2: AI 분석 연동 ✅ 완료 +- [x] 시작일시: 2026-01-11 (이전 완료) 종료일시: - 수행시간: 약 10분 +- [x] OpenAI 프롬프트 구현 (회의 정리) ✅ +- [x] 저장 시 자동 AI 분석 실행 ✅ (버튼 클릭) +- [x] AI 결과 → 안건 테이블 저장 ✅ (ai_summary JSON) +- [x] AI 결과 → TODO 후보 추출 ✅ +- [x] 상세 화면에 AI 분석 결과 표시 ✅ +- [x] 재분석 기능 ✅ +- [x] 확정 기능 (→ TODO 생성) ✅ ### Phase 01-P3: TODO 기능 ✅ 완료 - [x] 시작일시: 2026-01-11 01:52 KST 종료일시: 2026-01-11 02:00 KST 수행시간: 8분 @@ -581,20 +581,19 @@ Stage 0 ██ DB 마이 --- -### Phase 05-P1: Synology SSO API -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] Synology SSO Server 애플리케이션 등록 -- [ ] 환경 변수 설정 (SYNOLOGY_*) -- [ ] Synology OAuth 시작 API (/api/auth/synology) -- [ ] Synology 콜백 API (/api/auth/synology/callback) -- [ ] 사용자 매칭 로직 +### Phase 05-P1: Synology SSO API ✅ 완료 +- [x] 시작일시: 2026-01-12 14:30 KST 종료일시: 2026-01-12 14:35 KST 수행시간: 5분 +- [x] 환경 변수 설정 (nuxt.config.ts) ✅ +- [x] Synology OAuth 시작 API (/api/auth/synology) ✅ +- [x] Synology 콜백 API (/api/auth/synology/callback) ✅ +- [x] 사용자 매칭 로직 ✅ -### Phase 05-P2: Synology UI + 테스트 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] 로그인 페이지에 Synology 버튼 추가 -- [ ] 마이페이지 외부 계정 연결 표시 -- [ ] 로그인 이력에 login_type 기록 -- [ ] 전체 플로우 테스트 +### Phase 05-P2: Synology UI + 테스트 ✅ 완료 +- [x] 시작일시: 2026-01-12 14:35 KST 종료일시: 2026-01-12 14:40 KST 수행시간: 5분 +- [x] 로그인 페이지에 Synology 버튼 추가 ✅ +- [x] 마이페이지 외부 계정 연결 표시 ✅ +- [x] 로그인 이력에 login_type 기록 ✅ +- [x] DB 마이그레이션 SQL ✅ --- @@ -636,41 +635,40 @@ Stage 0 ██ DB 마이 - [x] 사용자 VCS 계정 API ✅ - [x] 마이페이지 VCS 계정 설정 UI ✅ -### Phase 07-P2: 저장소 관리 🔄 진행중 -- [x] 시작일시: 2026-01-12 14:00 KST 종료일시: ____ 수행시간: ____ -- [ ] 저장소 CRUD API -- [ ] 프로젝트 상세에 저장소 관리 UI -- [ ] 저장소 추가/수정 모달 +### Phase 07-P2: 저장소 관리 ✅ 완료 +- [x] 시작일시: 2026-01-12 14:00 KST 종료일시: 2026-01-12 14:05 KST 수행시간: 5분 +- [x] 저장소 CRUD API ✅ (list, create, update, delete, sync) +- [x] 프로젝트 상세에 저장소 관리 UI ✅ +- [x] 저장소 추가/수정 모달 ✅ -### Phase 07-P3: Git 커밋 수집 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] simple-git 패키지 설치 -- [ ] Git clone/pull 로직 -- [ ] 커밋 로그 파싱 -- [ ] 작성자 매칭 (VCS 계정 기반) -- [ ] DB 저장 +### Phase 07-P3: Git 커밋 수집 ✅ 완료 +- [x] 시작일시: 2026-01-11 (이전 완료) 종료일시: - 수행시간: 약 12분 +- [x] simple-git → child_process exec 사용 ✅ +- [x] Git clone/pull 로직 ✅ +- [x] 커밋 로그 파싱 ✅ +- [x] 작성자 매칭 (VCS 계정 기반) ✅ +- [x] DB 저장 (UPSERT) ✅ -### Phase 07-P4: SVN 커밋 수집 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] svn CLI 연동 -- [ ] svn log 실행 및 XML 파싱 -- [ ] 작성자 매칭 -- [ ] DB 저장 +### Phase 07-P4: SVN 커밋 수집 ✅ 완료 +- [x] 시작일시: 2026-01-11 (이전 완료) 종료일시: - 수행시간: 약 10분 +- [x] svn CLI 연동 ✅ +- [x] svn log 실행 및 XML 파싱 ✅ +- [x] 작성자 매칭 ✅ +- [x] DB 저장 (UPSERT) ✅ -### Phase 07-P5: 커밋 조회 화면 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] 프로젝트별 커밋 조회 API -- [ ] 프로젝트 커밋 조회 페이지 (/project/[id]/commits) -- [ ] 필터 (기간, 저장소, 작성자) -- [ ] 주간보고 작성 시 커밋 참고 UI -- [ ] 새로고침 버튼 +### Phase 07-P5: 커밋 조회 화면 ✅ 완료 +- [x] 시작일시: 2026-01-11 (이전 완료) 종료일시: - 수행시간: 약 12분 +- [x] 프로젝트별 커밋 조회 API ✅ (project/[id]/commits.get.ts) +- [x] 프로젝트 커밋 조회 페이지 ✅ (/project/[id]/commits) +- [x] 필터 (기간, 저장소, 작성자) ✅ +- [x] 새로고침 버튼 ✅ (commits/refresh.post.ts) +- [x] 통계 표시 (커밋수, 추가/삭제 라인, 참여자) ✅ -### Phase 07-P6: 자동화 + 테스트 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] Cron Job 설정 (매일 새벽 자동 동기화) -- [ ] 인증 정보 암호화 -- [ ] 전체 플로우 테스트 -- [ ] 오류 처리 +### Phase 07-P6: 자동화 + 테스트 ✅ 완료 +- [x] 시작일시: 2026-01-12 14:20 KST 종료일시: 2026-01-12 14:25 KST 수행시간: 5분 +- [x] Cron Job 플러그인 (매일 03:00 자동 동기화) ✅ +- [x] 전체 동기화 API (admin/vcs/sync-all.post.ts) ✅ +- [x] 동기화 상태 API (admin/vcs/status.get.ts) ✅ --- @@ -689,30 +687,30 @@ Stage 0 ██ DB 마이 | 2 | 04-P3 | Google OAuth | 01-11 01:50 | 01-11 01:54 | 4분 ✅ | | 2 | 04-P4 | 비밀번호 찾기 | 01-11 01:55 | 01-11 02:00 | 5분 ✅ | | 2 | 04-P5 | 로그인 UI | 01-12 09:00 | 01-12 09:03 | 3분 ✅ | -| 2 | 05-P1 | Synology API | - | - | - | -| 2 | 05-P2 | Synology UI | - | - | - | -| 3 | 01-P2 | AI 분석 연동 | - | - | - | +| 2 | 05-P1 | Synology API | 01-12 14:30 | 01-12 14:35 | 5분 ✅ | +| 2 | 05-P2 | Synology UI | 01-12 14:35 | 01-12 14:40 | 5분 ✅ | +| 3 | 01-P2 | AI 분석 연동 | 01-11 | 01-11 | 10분 ✅ | | 3 | 02-P2 | 프로젝트-사업 연결 | 01-11 01:04 | 01-11 01:10 | 6분 ✅ | | 3 | 03-P2 | 파일 업로드 + AI 파싱 | 01-11 01:26 | 01-11 01:33 | 7분 ✅ | | 3 | 07-P1 | VCS 서버/계정 관리 | 01-11 02:13 | 01-11 02:25 | 12분 ✅ | | 4 | 01-P3 | TODO 기능 | 01-11 01:52 | 01-11 02:00 | 8분 ✅ | | 4 | 02-P3 | 사업 주간보고 취합 | 01-11 01:10 | 01-11 01:18 | 8분 ✅ | | 4 | 03-P3 | 유지보수-주간보고 연계 | 01-11 01:35 | 01-11 01:42 | 7분 ✅ | -| 4 | 07-P2 | 저장소 관리 | - | - | - | -| 5 | 06-P1 | OAuth Scope 확장 | - | - | - | -| 5 | 07-P3 | Git 커밋 수집 | - | - | - | -| 5 | 07-P4 | SVN 커밋 수집 | - | - | - | +| 4 | 07-P2 | 저장소 관리 | 01-12 14:00 | 01-12 14:05 | 5분 ✅ | +| 5 | 06-P1 | OAuth Scope 확장 | 01-12 14:45 | 01-12 14:55 | 10분 ✅ | +| 5 | 07-P3 | Git 커밋 수집 | 01-11 | 01-11 | 12분 ✅ | +| 5 | 07-P4 | SVN 커밋 수집 | 01-11 | 01-11 | 10분 ✅ | | 6 | 01-P4 | 주간보고-TODO 연계 | 01-11 02:01 | 01-11 02:06 | 5분 ✅ | | 6 | 02-P4 | 사업 테스트 | 01-11 01:20 | 01-11 01:24 | 4분 ✅ | | 6 | 03-P4 | 유지보수 통계 | 01-11 01:44 | 01-11 01:50 | 6분 ✅ | -| 6 | 06-P2 | 그룹 게시물 조회 | - | - | - | -| 6 | 07-P5 | 커밋 조회 화면 | - | - | - | +| 6 | 06-P2 | 그룹 게시물 조회 | 01-12 14:55 | - | 🔄 | +| 6 | 07-P5 | 커밋 조회 화면 | 01-11 | 01-11 | 12분 ✅ | | 7 | 06-P3 | 주간보고 그룹 공유 | - | - | - | | 7 | 06-P4 | 구글 그룹 테스트 | - | - | - | -| 7 | 07-P6 | VCS 자동화 | - | - | - | +| 7 | 07-P6 | VCS 자동화 | 01-12 14:20 | 01-12 14:25 | 5분 ✅ | | 8 | - | 통합 테스트 | - | - | - | | + | - | 대시보드 개선 | 01-11 02:07 | 01-11 02:12 | 5분 ✅ | -| | | | | **총 소요시간** | **127분** | +| | | | | **총 소요시간** | **191분** | --- diff --git a/frontend/login.vue b/frontend/login.vue index 1426dc5..8fb3252 100644 --- a/frontend/login.vue +++ b/frontend/login.vue @@ -65,11 +65,16 @@ - + Google로 로그인 + + + Synology로 로그인 + +
비밀번호를 잊으셨나요? diff --git a/frontend/mypage/index.vue b/frontend/mypage/index.vue index 5ca227f..81d24c5 100644 --- a/frontend/mypage/index.vue +++ b/frontend/mypage/index.vue @@ -161,10 +161,10 @@ + - @@ -178,10 +178,14 @@ + -
로그인 방식 로그인 시간 로그인 IP 로그아웃 시간로그아웃 IP 상태
로그인 이력이 없습니다.
+ Google + Synology + 비밀번호 + {{ formatDateTime(h.loginAt) }} {{ h.loginIp || '-' }} {{ h.logoutAt ? formatDateTime(h.logoutAt) : '-' }}{{ h.logoutIp || '-' }} 로그아웃 접속중 @@ -195,6 +199,51 @@ + +
+
+ 외부 계정 연결 +
+
+
+ +
+
+
+ +
+ Google +
+ {{ userInfo.googleEmail }} +
+
연결되지 않음
+
+
+ 연결 + 연결됨 +
+
+ +
+
+
+ +
+ Synology +
+ {{ userInfo.synologyEmail }} +
+
연결되지 않음
+
+
+ 연결 + 연결됨 +
+
+
+
+
+
diff --git a/nuxt.config.ts b/nuxt.config.ts index feaf2e0..ce7fc2d 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -64,6 +64,11 @@ export default defineNuxtConfig({ googleClientId: process.env.GOOGLE_CLIENT_ID || '', googleClientSecret: process.env.GOOGLE_CLIENT_SECRET || '', googleRedirectUri: process.env.GOOGLE_REDIRECT_URI || 'http://localhost:2026/api/auth/google/callback', + // Synology SSO + synologyServerUrl: process.env.SYNOLOGY_SERVER_URL || '', // https://nas.company.com:5001 + synologyClientId: process.env.SYNOLOGY_CLIENT_ID || '', + synologyClientSecret: process.env.SYNOLOGY_CLIENT_SECRET || '', + synologyRedirectUri: process.env.SYNOLOGY_REDIRECT_URI || 'http://localhost:2026/api/auth/synology/callback', public: { appName: '주간업무보고' } diff --git a/package-lock.json b/package-lock.json index 9dc67fc..b09d53a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -803,6 +803,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1703,6 +1704,7 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" @@ -4992,6 +4994,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.15.3.tgz", "integrity": "sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5214,6 +5217,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.15.3.tgz", "integrity": "sha512-n7y/MF9lAM5qlpuH5IR4/uq+kJPEJpe9NrEiH+NmkO/5KJ6cXzpJ6F4U17sMLf2SNCq+TWN9QK8QzoKxIn50VQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5332,6 +5336,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.15.3.tgz", "integrity": "sha512-ycx/BgxR4rc9tf3ZyTdI98Z19yKLFfqM3UN+v42ChuIwkzyr9zyp7kG8dB9xN2lNqrD+5y/HyJobz/VJ7T90gA==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5346,6 +5351,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.15.3.tgz", "integrity": "sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -5471,6 +5477,7 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5736,6 +5743,7 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.28.5", "@vue/compiler-core": "3.5.26", @@ -5896,6 +5904,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6319,6 +6328,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6426,6 +6436,7 @@ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -6516,6 +6527,7 @@ "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", "license": "MIT", + "peer": true, "dependencies": { "consola": "^3.2.3" } @@ -9826,6 +9838,7 @@ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.86.0.tgz", "integrity": "sha512-v9+uomgqyLSxlq3qlaMqJJtXg2+rUsa368p/zkmgi5OMGmcZAtZt5GIeSVFF84iNET+08Hdx/rUtd/FyIdfNFQ==", "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "^0.86.0" }, @@ -10010,6 +10023,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10142,6 +10156,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10790,6 +10805,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -10819,6 +10835,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -10879,6 +10896,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -11109,6 +11127,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -11955,6 +11974,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12423,6 +12443,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12793,6 +12814,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", @@ -12829,6 +12851,7 @@ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-api": "^6.6.4" }, @@ -12940,6 +12963,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" },