기능구현중
This commit is contained in:
58
server/api/admin/user/reset-password.post.ts
Normal file
58
server/api/admin/user/reset-password.post.ts
Normal file
@@ -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<AdminResetPasswordBody>(event)
|
||||||
|
const clientIp = getClientIp(event)
|
||||||
|
|
||||||
|
if (!body.employeeId) {
|
||||||
|
throw createError({ statusCode: 400, message: '사용자 ID가 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대상 사용자 조회
|
||||||
|
const employees = await query<any>(`
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
59
server/api/admin/vcs/status.get.ts
Normal file
59
server/api/admin/vcs/status.get.ts
Normal file
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
63
server/api/admin/vcs/sync-all.post.ts
Normal file
63
server/api/admin/vcs/sync-all.post.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
107
server/api/auth/synology/callback.get.ts
Normal file
107
server/api/auth/synology/callback.get.ts
Normal file
@@ -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<any>(`${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<any>(`${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<any>(`
|
||||||
|
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 인증 중 오류가 발생했습니다.'))
|
||||||
|
}
|
||||||
|
})
|
||||||
26
server/api/auth/synology/index.get.ts
Normal file
26
server/api/auth/synology/index.get.ts
Normal file
@@ -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())
|
||||||
|
})
|
||||||
90
server/api/commits/my-weekly.get.ts
Normal file
90
server/api/commits/my-weekly.get.ts
Normal file
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
34
server/api/employee/[id]/unlink-google.post.ts
Normal file
34
server/api/employee/[id]/unlink-google.post.ts
Normal file
@@ -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 계정 연결이 해제되었습니다.' }
|
||||||
|
})
|
||||||
22
server/api/google-group/[id]/delete.delete.ts
Normal file
22
server/api/google-group/[id]/delete.delete.ts
Normal file
@@ -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: '그룹이 삭제되었습니다.' }
|
||||||
|
})
|
||||||
87
server/api/google-group/[id]/messages.get.ts
Normal file
87
server/api/google-group/[id]/messages.get.ts
Normal file
@@ -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<any>(`
|
||||||
|
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 || '메시지 조회 실패' })
|
||||||
|
}
|
||||||
|
})
|
||||||
23
server/api/google-group/create.post.ts
Normal file
23
server/api/google-group/create.post.ts
Normal file
@@ -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: '그룹이 등록되었습니다.' }
|
||||||
|
})
|
||||||
28
server/api/google-group/list.get.ts
Normal file
28
server/api/google-group/list.get.ts
Normal file
@@ -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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
139
server/api/google-group/messages.get.ts
Normal file
139
server/api/google-group/messages.get.ts
Normal file
@@ -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<any>(`
|
||||||
|
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<any>('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<any>(`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<string> {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
const response = await $fetch<any>('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('토큰 갱신 실패')
|
||||||
|
}
|
||||||
207
server/api/google-group/share-report.post.ts
Normal file
207
server/api/google-group/share-report.post.ts
Normal file
@@ -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<any>(`
|
||||||
|
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<any>(`
|
||||||
|
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<any>('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 `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="UTF-8"></head>
|
||||||
|
<body style="font-family: 'Malgun Gothic', sans-serif; padding: 20px; max-width: 800px;">
|
||||||
|
<h2 style="color: #333; border-bottom: 2px solid #007bff; padding-bottom: 10px;">
|
||||||
|
📋 주간업무보고
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; background: #f8f9fa; width: 120px;"><strong>작성자</strong></td>
|
||||||
|
<td style="padding: 8px;">${report.employee_name}</td>
|
||||||
|
<td style="padding: 8px; background: #f8f9fa; width: 120px;"><strong>프로젝트</strong></td>
|
||||||
|
<td style="padding: 8px;">${report.project_name || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; background: #f8f9fa;"><strong>보고 주차</strong></td>
|
||||||
|
<td style="padding: 8px;">${report.report_year}년 ${report.report_week}주차</td>
|
||||||
|
<td style="padding: 8px; background: #f8f9fa;"><strong>기간</strong></td>
|
||||||
|
<td style="padding: 8px;">${weekRange}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 style="color: #28a745;">✅ 금주 실적</h3>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 20px; white-space: pre-wrap;">${report.this_week_work || '(내용 없음)'}</div>
|
||||||
|
|
||||||
|
<h3 style="color: #007bff;">📅 차주 계획</h3>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 20px; white-space: pre-wrap;">${report.next_week_plan || '(내용 없음)'}</div>
|
||||||
|
|
||||||
|
${report.issues ? `
|
||||||
|
<h3 style="color: #dc3545;">⚠️ 이슈사항</h3>
|
||||||
|
<div style="background: #fff3cd; padding: 15px; border-radius: 5px; margin-bottom: 20px; white-space: pre-wrap;">${report.issues}</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${report.remarks ? `
|
||||||
|
<h3 style="color: #6c757d;">📝 비고</h3>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; white-space: pre-wrap;">${report.remarks}</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<hr style="margin-top: 30px; border: none; border-top: 1px solid #ddd;">
|
||||||
|
<p style="color: #6c757d; font-size: 12px;">
|
||||||
|
이 메일은 주간업무보고 시스템에서 자동 발송되었습니다.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google 토큰 갱신
|
||||||
|
*/
|
||||||
|
async function refreshGoogleToken(employeeId: number, refreshToken: string): Promise<string> {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
const response = await $fetch<any>('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('토큰 갱신 실패')
|
||||||
|
}
|
||||||
105
server/api/meeting/[id]/analyze.post.ts
Normal file
105
server/api/meeting/[id]/analyze.post.ts
Normal file
@@ -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<any>(`
|
||||||
|
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 분석 실패' })
|
||||||
|
}
|
||||||
|
})
|
||||||
82
server/api/meeting/[id]/confirm.post.ts
Normal file
82
server/api/meeting/[id]/confirm.post.ts
Normal file
@@ -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<ConfirmBody>(event)
|
||||||
|
const ip = getClientIp(event)
|
||||||
|
|
||||||
|
if (!meetingId) {
|
||||||
|
throw createError({ statusCode: 400, message: '회의록 ID가 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회의록 조회
|
||||||
|
const meeting = await queryOne<any>(`
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
131
server/api/project/[id]/commits.get.ts
Normal file
131
server/api/project/[id]/commits.get.ts
Normal file
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
31
server/api/project/[id]/commits/refresh.post.ts
Normal file
31
server/api/project/[id]/commits/refresh.post.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user