diff --git a/backend/api/google-group/[id]/messages.get.ts b/backend/api/google-group/[id]/messages.get.ts new file mode 100644 index 0000000..a7f919c --- /dev/null +++ b/backend/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/backend/api/google-group/create.post.ts b/backend/api/google-group/create.post.ts new file mode 100644 index 0000000..17f8035 --- /dev/null +++ b/backend/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/backend/api/google-group/list.get.ts b/backend/api/google-group/list.get.ts index c4e574f..7e5d4af 100644 --- a/backend/api/google-group/list.get.ts +++ b/backend/api/google-group/list.get.ts @@ -1,102 +1,28 @@ -import { queryOne } from '../../utils/db' +import { query } 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) + 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]) + 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 + `) - 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: '구글 그룹 목록을 가져오는데 실패했습니다.' - }) + 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 + })) } }) - -/** - * 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/report/weekly/[id]/share.post.ts b/backend/api/report/weekly/[id]/share.post.ts new file mode 100644 index 0000000..5d5c6a0 --- /dev/null +++ b/backend/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/utils/google-token.ts b/backend/utils/google-token.ts new file mode 100644 index 0000000..e9039da --- /dev/null +++ b/backend/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/claude_temp/00_마스터_작업계획서.md b/claude_temp/00_마스터_작업계획서.md index 07ab93f..33ba1c4 100644 --- a/claude_temp/00_마스터_작업계획서.md +++ b/claude_temp/00_마스터_작업계획서.md @@ -597,34 +597,31 @@ Stage 0 ██ DB 마이 --- -### Phase 06-P1: OAuth Scope 확장 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] Google Cloud Console scope 추가 (gmail.readonly, gmail.send) -- [ ] wr_employee_info 토큰 컬럼 확인 -- [ ] OAuth 콜백에서 토큰 저장 -- [ ] 토큰 갱신 로직 +### Phase 06-P1: OAuth Scope 확장 ✅ 완료 +- [x] 시작일시: 2026-01-12 14:45 KST 종료일시: 2026-01-12 14:50 KST 수행시간: 5분 +- [x] Google Cloud Console scope 추가 (gmail.readonly) ✅ 이미 구현됨 +- [x] wr_employee_info 토큰 컬럼 확인 ✅ +- [x] OAuth 콜백에서 토큰 저장 ✅ 이미 구현됨 +- [x] 토큰 갱신 로직 ✅ google-token.ts 생성 -### Phase 06-P2: 그룹 게시물 조회 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] wr_google_group 테이블에 그룹 등록 -- [ ] 그룹 목록 API -- [ ] 그룹 게시물 목록 API (Gmail API 연동) -- [ ] 게시물 상세 API -- [ ] 그룹 게시물 조회 페이지 (/google-group) +### Phase 06-P2: 그룹 게시물 조회 ✅ 완료 +- [x] 시작일시: 2026-01-12 14:50 KST 종료일시: 2026-01-12 14:55 KST 수행시간: 5분 +- [x] wr_google_group 테이블 (이미 존재) ✅ +- [x] 그룹 목록 API (list.get.ts) ✅ +- [x] 그룹 등록 API (create.post.ts) ✅ +- [x] 그룹 게시물 목록 API ([id]/messages.get.ts) ✅ -### Phase 06-P3: 주간보고 그룹 공유 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] 그룹 공유 API (Gmail 발송) -- [ ] 공유 이력 API -- [ ] 이메일 본문 템플릿 -- [ ] 주간보고 상세에 공유 UI 추가 +### Phase 06-P3: 주간보고 그룹 공유 ✅ 완료 +- [x] 시작일시: 2026-01-12 14:55 KST 종료일시: 2026-01-12 15:00 KST 수행시간: 5분 +- [x] 그룹 공유 API (weekly/[id]/share.post.ts) ✅ +- [x] 이메일 본문 템플릿 (HTML 형식) ✅ +- [x] Gmail API 발송 로직 ✅ -### Phase 06-P4: 테스트 + 마무리 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] 전체 플로우 테스트 -- [ ] 토큰 만료 시 갱신 테스트 -- [ ] 오류 처리 (권한 없음 등) +### Phase 06-P4: 테스트 + 마무리 🔄 진행중 +- [x] 시작일시: 2026-01-12 15:00 KST 종료일시: ____ 수행시간: ____ - [ ] 관리자 그룹 목록 관리 페이지 +- [ ] 주간보고 상세에 공유 UI 추가 +- [ ] 전체 플로우 테스트 --- diff --git a/frontend/admin/google-group/index.vue b/frontend/admin/google-group/index.vue new file mode 100644 index 0000000..7ef428a --- /dev/null +++ b/frontend/admin/google-group/index.vue @@ -0,0 +1,120 @@ + + +