기능구현중

This commit is contained in:
2026-01-11 17:04:16 +09:00
parent 954ba21211
commit 9decf172df
30 changed files with 0 additions and 2276 deletions

View File

@@ -1,58 +0,0 @@
import { query, execute } from '../../../utils/db'
import { hashPassword, generateTempPassword } from '../../../utils/password'
import { getClientIp } from '../../../utils/ip'
import { requireAuth } from '../../../utils/session'
interface AdminResetPasswordBody {
employeeId: number
}
/**
* 관리자 비밀번호 초기화
* POST /api/admin/user/reset-password
*/
export default defineEventHandler(async (event) => {
const currentUserId = await requireAuth(event)
// TODO: 관리자 권한 체크 (현재는 모든 로그인 사용자 허용)
const body = await readBody<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
}
})

View File

@@ -1,59 +0,0 @@
import { query } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
/**
* 전체 VCS 동기화 상태 조회 (관리자용)
* GET /api/admin/vcs/status
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
// 저장소별 동기화 상태
const repos = await query(`
SELECT
r.repo_id, r.repo_name, r.repo_path,
r.last_sync_at, r.last_sync_status, r.last_sync_message,
s.server_type, s.server_name,
p.project_name,
(SELECT COUNT(*) FROM wr_commit_log c WHERE c.repo_id = r.repo_id) as commit_count
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
LEFT JOIN wr_project_info p ON r.project_id = p.project_id
WHERE r.is_active = true
ORDER BY r.last_sync_at DESC NULLS LAST
`)
// 전체 통계
const stats = await query(`
SELECT
COUNT(DISTINCT r.repo_id) as total_repos,
COUNT(DISTINCT CASE WHEN r.last_sync_status = 'SUCCESS' THEN r.repo_id END) as success_repos,
COUNT(DISTINCT CASE WHEN r.last_sync_status = 'FAILED' THEN r.repo_id END) as failed_repos,
COUNT(DISTINCT CASE WHEN r.last_sync_at IS NULL THEN r.repo_id END) as never_synced,
(SELECT COUNT(*) FROM wr_commit_log) as total_commits
FROM wr_repository r
WHERE r.is_active = true
`)
return {
repositories: repos.map(r => ({
repoId: r.repo_id,
repoName: r.repo_name,
repoPath: r.repo_path,
serverType: r.server_type,
serverName: r.server_name,
projectName: r.project_name,
lastSyncAt: r.last_sync_at,
lastSyncStatus: r.last_sync_status,
lastSyncMessage: r.last_sync_message,
commitCount: parseInt(r.commit_count || '0')
})),
stats: {
totalRepos: parseInt(stats[0]?.total_repos || '0'),
successRepos: parseInt(stats[0]?.success_repos || '0'),
failedRepos: parseInt(stats[0]?.failed_repos || '0'),
neverSynced: parseInt(stats[0]?.never_synced || '0'),
totalCommits: parseInt(stats[0]?.total_commits || '0')
}
}
})

View File

@@ -1,63 +0,0 @@
import { query } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
import { syncGitRepository } from '../../../utils/git-sync'
import { syncSvnRepository } from '../../../utils/svn-sync'
/**
* 전체 VCS 저장소 동기화 (관리자용)
* POST /api/admin/vcs/sync-all
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
// 모든 활성 저장소 조회
const repos = await query(`
SELECT r.repo_id, r.repo_name, s.server_type
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE r.is_active = true AND s.is_active = true
ORDER BY r.repo_name
`)
const results: any[] = []
for (const repo of repos) {
try {
let result
if (repo.server_type === 'GIT') {
result = await syncGitRepository(repo.repo_id)
} else if (repo.server_type === 'SVN') {
result = await syncSvnRepository(repo.repo_id)
} else {
result = { success: false, message: '지원하지 않는 서버 타입' }
}
results.push({
repoId: repo.repo_id,
repoName: repo.repo_name,
serverType: repo.server_type,
...result
})
} catch (e: any) {
results.push({
repoId: repo.repo_id,
repoName: repo.repo_name,
serverType: repo.server_type,
success: false,
message: e.message
})
}
}
const successCount = results.filter(r => r.success).length
const failCount = results.filter(r => !r.success).length
return {
success: failCount === 0,
message: `동기화 완료: 성공 ${successCount}개, 실패 ${failCount}`,
totalRepos: repos.length,
successCount,
failCount,
results
}
})

View File

@@ -1,107 +0,0 @@
import { queryOne, execute } from '../../../utils/db'
import { createSession } from '../../../utils/session'
import { getClientIp } from '../../../utils/ip'
/**
* Synology SSO 콜백
* GET /api/auth/synology/callback
*
* Synology SSO Server에서 인증 후 리다이렉트되는 엔드포인트
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const query = getQuery(event)
const ip = getClientIp(event)
const code = query.code as string
const error = query.error as string
if (error) {
return sendRedirect(event, `/login?error=${encodeURIComponent('Synology 인증이 취소되었습니다.')}`)
}
if (!code) {
return sendRedirect(event, '/login?error=' + encodeURIComponent('인증 코드가 없습니다.'))
}
try {
// 1. 코드로 액세스 토큰 교환
const tokenResponse = await $fetch<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 인증 중 오류가 발생했습니다.'))
}
})

View File

@@ -1,26 +0,0 @@
/**
* Synology SSO 로그인 시작
* GET /api/auth/synology
*
* Synology SSO Server OAuth 2.0 인증 페이지로 리다이렉트
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
if (!config.synologyServerUrl || !config.synologyClientId) {
throw createError({
statusCode: 500,
message: 'Synology SSO가 설정되지 않았습니다.'
})
}
// Synology SSO Server OAuth 인증 URL
const authUrl = new URL(`${config.synologyServerUrl}/webman/sso/SSOOauth.cgi`)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('client_id', config.synologyClientId)
authUrl.searchParams.set('redirect_uri', config.synologyRedirectUri)
authUrl.searchParams.set('scope', 'user_id')
authUrl.searchParams.set('state', crypto.randomUUID())
return sendRedirect(event, authUrl.toString())
})

View File

@@ -1,90 +0,0 @@
import { query } from '../../utils/db'
import { requireAuth } from '../../utils/session'
/**
* 내 주간 커밋 조회 (주간보고 작성용)
* GET /api/commits/my-weekly
*
* Query params:
* - projectId: 프로젝트 ID (필수)
* - startDate: 주 시작일 (YYYY-MM-DD)
* - endDate: 주 종료일 (YYYY-MM-DD)
*/
export default defineEventHandler(async (event) => {
const employeeId = await requireAuth(event)
const queryParams = getQuery(event)
const projectId = queryParams.projectId ? parseInt(queryParams.projectId as string) : null
const startDate = queryParams.startDate as string
const endDate = queryParams.endDate as string
if (!projectId) {
throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' })
}
// 조건 빌드
const conditions = [
'r.project_id = $1',
'c.employee_id = $2'
]
const values: any[] = [projectId, employeeId]
let paramIndex = 3
if (startDate) {
conditions.push(`c.commit_date >= $${paramIndex++}`)
values.push(startDate)
}
if (endDate) {
conditions.push(`c.commit_date <= $${paramIndex++}::date + INTERVAL '1 day'`)
values.push(endDate)
}
const whereClause = conditions.join(' AND ')
// 내 커밋 목록
const commits = await query(`
SELECT
c.commit_id, c.commit_hash, c.commit_message, c.commit_date,
c.files_changed, c.insertions, c.deletions,
r.repo_id, r.repo_name,
s.server_type
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE ${whereClause}
ORDER BY c.commit_date DESC
LIMIT 100
`, values)
// 통계
const statsResult = await query(`
SELECT
COUNT(*) as commit_count,
COALESCE(SUM(c.insertions), 0) as total_insertions,
COALESCE(SUM(c.deletions), 0) as total_deletions
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
WHERE ${whereClause}
`, values)
return {
commits: commits.map(c => ({
commitId: c.commit_id,
commitHash: c.commit_hash?.substring(0, 8),
commitMessage: c.commit_message,
commitDate: c.commit_date,
filesChanged: c.files_changed,
insertions: c.insertions,
deletions: c.deletions,
repoId: c.repo_id,
repoName: c.repo_name,
serverType: c.server_type
})),
stats: {
commitCount: parseInt(statsResult[0]?.commit_count || '0'),
totalInsertions: parseInt(statsResult[0]?.total_insertions || '0'),
totalDeletions: parseInt(statsResult[0]?.total_deletions || '0')
}
}
})

View File

@@ -1,34 +0,0 @@
import { execute } from '../../../utils/db'
import { getClientIp } from '../../../utils/ip'
import { requireAuth } from '../../../utils/session'
/**
* Google 계정 연결 해제
* POST /api/employee/[id]/unlink-google
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const employeeId = parseInt(event.context.params?.id || '0')
if (!employeeId) {
throw createError({ statusCode: 400, message: '사용자 ID가 필요합니다.' })
}
const ip = getClientIp(event)
const result = await execute(`
UPDATE wr_employee_info
SET google_id = NULL,
google_email = NULL,
google_linked_at = NULL,
updated_at = NOW(),
updated_ip = $1
WHERE employee_id = $2
`, [ip, employeeId])
if (result === 0) {
throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' })
}
return { success: true, message: 'Google 계정 연결이 해제되었습니다.' }
})

View File

@@ -1,22 +0,0 @@
import { execute } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
/**
* 구글 그룹 삭제 (비활성화)
* DELETE /api/google-group/[id]/delete
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const groupId = parseInt(getRouterParam(event, 'id') || '0')
if (!groupId) {
throw createError({ statusCode: 400, message: '그룹 ID가 필요합니다.' })
}
await execute(`
UPDATE wr_google_group SET is_active = false, updated_at = NOW()
WHERE group_id = $1
`, [groupId])
return { success: true, message: '그룹이 삭제되었습니다.' }
})

View File

@@ -1,87 +0,0 @@
import { queryOne } from '../../../utils/db'
import { requireAuth, getCurrentUser } from '../../../utils/session'
import { getValidGoogleToken } from '../../../utils/google-token'
/**
* 구글 그룹 메시지 목록 조회
* GET /api/google-group/[id]/messages
*/
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
const groupId = parseInt(getRouterParam(event, 'id') || '0')
const queryParams = getQuery(event)
if (!groupId) {
throw createError({ statusCode: 400, message: '그룹 ID가 필요합니다.' })
}
// 그룹 정보 조회
const group = await queryOne<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 || '메시지 조회 실패' })
}
})

View File

@@ -1,23 +0,0 @@
import { execute, insertReturning } from '../../utils/db'
import { requireAuth } from '../../utils/session'
/**
* 구글 그룹 등록
* POST /api/google-group/create
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const body = await readBody(event)
if (!body.groupEmail || !body.groupName) {
throw createError({ statusCode: 400, message: '그룹 이메일과 이름은 필수입니다.' })
}
const result = await insertReturning(`
INSERT INTO wr_google_group (group_email, group_name, description)
VALUES ($1, $2, $3)
RETURNING group_id
`, [body.groupEmail.toLowerCase().trim(), body.groupName.trim(), body.description || null])
return { success: true, groupId: result.group_id, message: '그룹이 등록되었습니다.' }
})

View File

@@ -1,28 +0,0 @@
import { query } from '../../utils/db'
import { requireAuth } from '../../utils/session'
/**
* 구글 그룹 목록 조회
* GET /api/google-group/list
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const groups = await query(`
SELECT group_id, group_email, group_name, description, is_active, created_at
FROM wr_google_group
WHERE is_active = true
ORDER BY group_name
`)
return {
groups: groups.map(g => ({
groupId: g.group_id,
groupEmail: g.group_email,
groupName: g.group_name,
description: g.description,
isActive: g.is_active,
createdAt: g.created_at
}))
}
})

View File

@@ -1,139 +0,0 @@
import { queryOne, execute } from '../../utils/db'
import { requireAuth } from '../../utils/session'
/**
* 구글 그룹 메시지 조회
* GET /api/google-group/messages
*
* Gmail API로 그룹 이메일 조회
*
* Query params:
* - groupEmail: 그룹 이메일 주소 (예: dev-team@company.com)
* - maxResults: 최대 결과 수 (기본 20)
* - after: 이 날짜 이후 메시지 (YYYY-MM-DD)
*/
export default defineEventHandler(async (event) => {
const session = await requireAuth(event)
const query = getQuery(event)
const groupEmail = query.groupEmail as string
const maxResults = parseInt(query.maxResults as string) || 20
const after = query.after as string
if (!groupEmail) {
throw createError({ statusCode: 400, message: '그룹 이메일이 필요합니다.' })
}
// 사용자의 Google 토큰 조회
const employee = await queryOne<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('토큰 갱신 실패')
}

View File

@@ -1,207 +0,0 @@
import { queryOne, execute, query } from '../../utils/db'
import { requireAuth } from '../../utils/session'
/**
* 주간보고를 구글 그룹에 공유 (이메일 전송)
* POST /api/google-group/share-report
*
* Gmail API로 그룹에 이메일 전송
*
* Body:
* - reportId: 주간보고 ID
* - groupEmail: 그룹 이메일 주소
* - subject?: 이메일 제목 (기본값 자동 생성)
*/
export default defineEventHandler(async (event) => {
const session = await requireAuth(event)
const body = await readBody(event)
const { reportId, groupEmail, subject } = body
if (!reportId || !groupEmail) {
throw createError({ statusCode: 400, message: '보고서 ID와 그룹 이메일이 필요합니다.' })
}
// 주간보고 조회
const report = await queryOne<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('토큰 갱신 실패')
}

View File

@@ -1,105 +0,0 @@
import { queryOne, execute } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
import { callOpenAI } from '../../../utils/openai'
/**
* 회의록 AI 분석
* POST /api/meeting/[id]/analyze
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const meetingId = parseInt(event.context.params?.id || '0')
if (!meetingId) {
throw createError({ statusCode: 400, message: '회의록 ID가 필요합니다.' })
}
// 회의록 조회
const meeting = await queryOne<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 분석 실패' })
}
})

View File

@@ -1,82 +0,0 @@
import { queryOne, execute, insertReturning } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
import { getClientIp } from '../../../utils/ip'
interface ConfirmBody {
selectedTodos?: Array<{
agendaNo: number
todoIndex: number
title: string
assignee?: string
}>
}
/**
* AI 분석 결과 확정 + TODO 생성
* POST /api/meeting/[id]/confirm
*/
export default defineEventHandler(async (event) => {
const employeeId = await requireAuth(event)
const meetingId = parseInt(event.context.params?.id || '0')
const body = await readBody<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
}
})

View File

@@ -1,131 +0,0 @@
import { query } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
/**
* 프로젝트 커밋 목록 조회
* GET /api/project/[id]/commits
*
* Query params:
* - startDate: 시작일 (YYYY-MM-DD)
* - endDate: 종료일 (YYYY-MM-DD)
* - repoId: 저장소 ID (옵션)
* - authorId: 작성자 ID (옵션)
* - page: 페이지 번호 (기본 1)
* - limit: 페이지당 개수 (기본 50)
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const projectId = parseInt(getRouterParam(event, 'id') || '0')
const queryParams = getQuery(event)
if (!projectId) {
throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' })
}
const startDate = queryParams.startDate as string
const endDate = queryParams.endDate as string
const repoId = queryParams.repoId ? parseInt(queryParams.repoId as string) : null
const authorId = queryParams.authorId ? parseInt(queryParams.authorId as string) : null
const page = parseInt(queryParams.page as string) || 1
const limit = Math.min(parseInt(queryParams.limit as string) || 50, 100)
const offset = (page - 1) * limit
// 조건 빌드
const conditions = ['r.project_id = $1']
const values: any[] = [projectId]
let paramIndex = 2
if (startDate) {
conditions.push(`c.commit_date >= $${paramIndex++}`)
values.push(startDate)
}
if (endDate) {
conditions.push(`c.commit_date <= $${paramIndex++}::date + INTERVAL '1 day'`)
values.push(endDate)
}
if (repoId) {
conditions.push(`c.repo_id = $${paramIndex++}`)
values.push(repoId)
}
if (authorId) {
conditions.push(`c.employee_id = $${paramIndex++}`)
values.push(authorId)
}
const whereClause = conditions.join(' AND ')
// 커밋 목록 조회
const commits = await query(`
SELECT
c.commit_id, c.commit_hash, c.commit_message, c.commit_author, c.commit_email,
c.commit_date, c.employee_id, c.files_changed, c.insertions, c.deletions,
r.repo_id, r.repo_name, r.repo_path,
s.server_type, s.server_name,
e.employee_name, e.display_name
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
JOIN wr_vcs_server s ON r.server_id = s.server_id
LEFT JOIN wr_employee_info e ON c.employee_id = e.employee_id
WHERE ${whereClause}
ORDER BY c.commit_date DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
`, [...values, limit, offset])
// 전체 개수 조회
const countResult = await query(`
SELECT COUNT(*) as total
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
WHERE ${whereClause}
`, values)
const total = parseInt(countResult[0]?.total || '0')
// 통계 (해당 기간)
const statsResult = await query(`
SELECT
COUNT(*) as commit_count,
COALESCE(SUM(c.insertions), 0) as total_insertions,
COALESCE(SUM(c.deletions), 0) as total_deletions,
COUNT(DISTINCT c.employee_id) as author_count
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
WHERE ${whereClause}
`, values)
return {
commits: commits.map(c => ({
commitId: c.commit_id,
commitHash: c.commit_hash,
commitMessage: c.commit_message,
commitAuthor: c.commit_author,
commitEmail: c.commit_email,
commitDate: c.commit_date,
employeeId: c.employee_id,
employeeName: c.display_name || c.employee_name,
filesChanged: c.files_changed,
insertions: c.insertions,
deletions: c.deletions,
repoId: c.repo_id,
repoName: c.repo_name,
repoPath: c.repo_path,
serverType: c.server_type,
serverName: c.server_name
})),
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
},
stats: {
commitCount: parseInt(statsResult[0]?.commit_count || '0'),
totalInsertions: parseInt(statsResult[0]?.total_insertions || '0'),
totalDeletions: parseInt(statsResult[0]?.total_deletions || '0'),
authorCount: parseInt(statsResult[0]?.author_count || '0')
}
}
})

View File

@@ -1,31 +0,0 @@
import { requireAuth } from '../../../../utils/session'
import { syncProjectGitRepositories } from '../../../../utils/git-sync'
import { syncProjectSvnRepositories } from '../../../../utils/svn-sync'
/**
* 프로젝트 커밋 새로고침 (모든 저장소 동기화)
* POST /api/project/[id]/commits/refresh
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const projectId = parseInt(getRouterParam(event, 'id') || '0')
if (!projectId) {
throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' })
}
// Git과 SVN 모두 동기화
const gitResult = await syncProjectGitRepositories(projectId)
const svnResult = await syncProjectSvnRepositories(projectId)
const allResults = [...gitResult.results, ...svnResult.results]
const allSuccess = allResults.every(r => r.success)
return {
success: allSuccess,
message: allSuccess
? `${allResults.length}개 저장소 동기화 완료`
: '일부 저장소 동기화 실패',
results: allResults
}
})

View File

@@ -1,100 +0,0 @@
import { query, queryOne, insertReturning } from '../../../../utils/db'
import { requireAuth } from '../../../../utils/session'
import { getValidGoogleToken } from '../../../../utils/google-token'
/**
* 주간보고 그룹 공유 (Gmail 발송)
* POST /api/report/weekly/[id]/share
*/
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
const reportId = parseInt(getRouterParam(event, 'id') || '0')
const body = await readBody(event)
if (!reportId) {
throw createError({ statusCode: 400, message: '보고서 ID가 필요합니다.' })
}
const groupIds = body.groupIds as number[]
if (!groupIds?.length) {
throw createError({ statusCode: 400, message: '공유할 그룹을 선택해주세요.' })
}
// 보고서 조회
const report = await queryOne<any>(`
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<any>(`
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 `<html><body style="font-family:sans-serif;line-height:1.6">
<h2>📋 주간보고 - ${report.report_year}${report.report_week}주차</h2>
<p><b>작성자:</b> ${report.employee_name} | <b>프로젝트:</b> ${report.project_name || '개인'}</p>
<hr><h3>📌 금주 실적</h3><div style="background:#f5f5f5;padding:15px;border-radius:5px">${(report.this_week_work || '').replace(/\n/g, '<br>')}</div>
<h3>📅 차주 계획</h3><div style="background:#f5f5f5;padding:15px;border-radius:5px">${(report.next_week_plan || '').replace(/\n/g, '<br>')}</div>
${report.issues ? `<h3>⚠️ 이슈</h3><div style="background:#fff3cd;padding:15px;border-radius:5px">${report.issues.replace(/\n/g, '<br>')}</div>` : ''}
<hr><p style="color:#666;font-size:12px">주간업무보고 시스템에서 발송</p></body></html>`
}
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')
}

View File

@@ -1,22 +0,0 @@
import { execute } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
/**
* 저장소 삭제 (비활성화)
* DELETE /api/repository/[id]
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const repoId = parseInt(event.context.params?.id || '0')
if (!repoId) {
throw createError({ statusCode: 400, message: '저장소 ID가 필요합니다.' })
}
// 실제 삭제 대신 비활성화
await execute(`
UPDATE wr_repository SET is_active = false, updated_at = NOW() WHERE repo_id = $1
`, [repoId])
return { success: true }
})

View File

@@ -1,64 +0,0 @@
import { execute } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
import { getClientIp } from '../../../utils/ip'
interface UpdateRepoBody {
repoName?: string
repoPath?: string
repoUrl?: string
defaultBranch?: string
description?: string
isActive?: boolean
}
/**
* 저장소 수정
* PUT /api/repository/[id]
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const repoId = parseInt(event.context.params?.id || '0')
const body = await readBody<UpdateRepoBody>(event)
const ip = getClientIp(event)
if (!repoId) {
throw createError({ statusCode: 400, message: '저장소 ID가 필요합니다.' })
}
const updates: string[] = ['updated_at = NOW()', 'updated_ip = $1']
const values: any[] = [ip]
let idx = 2
if (body.repoName !== undefined) {
updates.push(`repo_name = $${idx++}`)
values.push(body.repoName)
}
if (body.repoPath !== undefined) {
updates.push(`repo_path = $${idx++}`)
values.push(body.repoPath)
}
if (body.repoUrl !== undefined) {
updates.push(`repo_url = $${idx++}`)
values.push(body.repoUrl)
}
if (body.defaultBranch !== undefined) {
updates.push(`default_branch = $${idx++}`)
values.push(body.defaultBranch)
}
if (body.description !== undefined) {
updates.push(`description = $${idx++}`)
values.push(body.description)
}
if (body.isActive !== undefined) {
updates.push(`is_active = $${idx++}`)
values.push(body.isActive)
}
values.push(repoId)
await execute(`
UPDATE wr_repository SET ${updates.join(', ')} WHERE repo_id = $${idx}
`, values)
return { success: true }
})

View File

@@ -1,39 +0,0 @@
import { queryOne } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
import { syncGitRepository } from '../../../utils/git-sync'
import { syncSvnRepository } from '../../../utils/svn-sync'
/**
* 저장소 동기화 (수동)
* POST /api/repository/[id]/sync
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const repoId = parseInt(getRouterParam(event, 'id') || '0')
if (!repoId) {
throw createError({ statusCode: 400, message: '저장소 ID가 필요합니다.' })
}
// 저장소 정보 확인
const repo = await queryOne(`
SELECT r.*, s.server_type
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE r.repo_id = $1
`, [repoId])
if (!repo) {
throw createError({ statusCode: 404, message: '저장소를 찾을 수 없습니다.' })
}
if (repo.server_type === 'GIT') {
const result = await syncGitRepository(repoId)
return result
} else if (repo.server_type === 'SVN') {
const result = await syncSvnRepository(repoId)
return result
}
return { success: false, message: '지원하지 않는 서버 타입입니다.' }
})

View File

@@ -1,47 +0,0 @@
import { insertReturning } from '../../utils/db'
import { requireAuth } from '../../utils/session'
import { getClientIp } from '../../utils/ip'
interface CreateRepoBody {
projectId: number
serverId: number
repoName: string
repoPath: string
repoUrl?: string
defaultBranch?: string
description?: string
}
/**
* 저장소 추가
* POST /api/repository/create
*/
export default defineEventHandler(async (event) => {
const employeeId = await requireAuth(event)
const body = await readBody<CreateRepoBody>(event)
const ip = getClientIp(event)
if (!body.projectId || !body.serverId || !body.repoPath) {
throw createError({ statusCode: 400, message: '필수 항목을 입력해주세요.' })
}
const repo = await insertReturning(`
INSERT INTO wr_repository (
project_id, server_id, repo_name, repo_path, repo_url,
default_branch, description, created_by, created_at, created_ip
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
RETURNING repo_id
`, [
body.projectId,
body.serverId,
body.repoName || body.repoPath,
body.repoPath,
body.repoUrl || null,
body.defaultBranch || 'main',
body.description || null,
employeeId,
ip
])
return { success: true, repoId: repo.repo_id }
})

View File

@@ -1,23 +0,0 @@
import { execute } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
/**
* TODO 완료 처리
* PUT /api/todo/[id]/complete
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const todoId = parseInt(event.context.params?.id || '0')
if (!todoId) {
throw createError({ statusCode: 400, message: 'TODO ID가 필요합니다.' })
}
await execute(`
UPDATE wr_todo
SET status = 'COMPLETED', completed_at = NOW(), updated_at = NOW()
WHERE todo_id = $1
`, [todoId])
return { success: true, message: '완료 처리되었습니다.' }
})

View File

@@ -1,28 +0,0 @@
import { execute } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
interface DiscardBody {
reason?: string
}
/**
* TODO 폐기 처리
* PUT /api/todo/[id]/discard
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const todoId = parseInt(event.context.params?.id || '0')
const body = await readBody<DiscardBody>(event)
if (!todoId) {
throw createError({ statusCode: 400, message: 'TODO ID가 필요합니다.' })
}
await execute(`
UPDATE wr_todo
SET status = 'DISCARDED', discard_reason = $1, updated_at = NOW()
WHERE todo_id = $2
`, [body.reason || null, todoId])
return { success: true, message: '폐기 처리되었습니다.' }
})

View File

@@ -1,102 +0,0 @@
import { query } from '../utils/db'
import { syncGitRepository } from '../utils/git-sync'
import { syncSvnRepository } from '../utils/svn-sync'
/**
* VCS 저장소 자동 동기화 Cron Job
* 매일 새벽 3시에 실행
*/
// 마지막 실행 시간 체크
let lastSyncDate = ''
function getTodayDate() {
return new Date().toISOString().split('T')[0]
}
function getCurrentHour() {
return new Date().getHours()
}
/**
* 모든 활성 저장소 동기화
*/
async function syncAllRepositories() {
console.log('[VCS-SYNC] 자동 동기화 시작:', new Date().toISOString())
try {
// 모든 활성 저장소 조회
const repos = await query(`
SELECT r.repo_id, r.repo_name, s.server_type
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE r.is_active = true AND s.is_active = true
`)
console.log(`[VCS-SYNC] 동기화 대상 저장소: ${repos.length}`)
let successCount = 0
let failCount = 0
for (const repo of repos) {
try {
let result
if (repo.server_type === 'GIT') {
result = await syncGitRepository(repo.repo_id)
} else if (repo.server_type === 'SVN') {
result = await syncSvnRepository(repo.repo_id)
}
if (result?.success) {
successCount++
console.log(`[VCS-SYNC] ✓ ${repo.repo_name}: ${result.message}`)
} else {
failCount++
console.log(`[VCS-SYNC] ✗ ${repo.repo_name}: ${result?.message || '알 수 없는 오류'}`)
}
} catch (e: any) {
failCount++
console.error(`[VCS-SYNC] ✗ ${repo.repo_name} 오류:`, e.message)
}
// 저장소 간 1초 대기 (서버 부하 방지)
await new Promise(resolve => setTimeout(resolve, 1000))
}
console.log(`[VCS-SYNC] 동기화 완료: 성공 ${successCount}개, 실패 ${failCount}`)
lastSyncDate = getTodayDate()
} catch (e: any) {
console.error('[VCS-SYNC] 동기화 중 오류:', e.message)
}
}
/**
* Cron 체크 (1시간마다 실행, 새벽 3시에 동기화)
*/
function checkAndSync() {
const today = getTodayDate()
const hour = getCurrentHour()
// 새벽 3시이고, 오늘 아직 실행 안했으면 실행
if (hour === 3 && lastSyncDate !== today) {
syncAllRepositories()
}
}
export default defineNitroPlugin((nitroApp) => {
// 서버 시작 시 로그
console.log('[VCS-SYNC] Cron Job 플러그인 로드됨 (매일 03:00 실행)')
// 개발 환경에서는 비활성화 옵션
if (process.env.DISABLE_VCS_SYNC === 'true') {
console.log('[VCS-SYNC] 환경변수로 비활성화됨')
return
}
// 1시간마다 체크
setInterval(checkAndSync, 60 * 60 * 1000)
// 서버 시작 5분 후 첫 체크
setTimeout(checkAndSync, 5 * 60 * 1000)
})

View File

@@ -1,19 +0,0 @@
-- Synology SSO 연동을 위한 컬럼 추가
-- 실행: psql -d weeklyreport -f add_synology_columns.sql
-- Synology 계정 연결 정보 컬럼 추가
ALTER TABLE wr_employee_info ADD COLUMN IF NOT EXISTS synology_id VARCHAR(100);
ALTER TABLE wr_employee_info ADD COLUMN IF NOT EXISTS synology_email VARCHAR(255);
ALTER TABLE wr_employee_info ADD COLUMN IF NOT EXISTS synology_linked_at TIMESTAMP;
-- 인덱스 추가
CREATE INDEX IF NOT EXISTS idx_employee_synology_id ON wr_employee_info(synology_id);
-- 로그인 이력 테이블에 login_type 컬럼 추가 (이미 있을 수 있음)
ALTER TABLE wr_login_history ADD COLUMN IF NOT EXISTS login_type VARCHAR(20) DEFAULT 'PASSWORD';
ALTER TABLE wr_login_history ADD COLUMN IF NOT EXISTS login_email VARCHAR(255);
COMMENT ON COLUMN wr_employee_info.synology_id IS 'Synology 사용자 ID';
COMMENT ON COLUMN wr_employee_info.synology_email IS 'Synology 계정 이메일';
COMMENT ON COLUMN wr_employee_info.synology_linked_at IS 'Synology 계정 연결 일시';
COMMENT ON COLUMN wr_login_history.login_type IS '로그인 방식 (PASSWORD, GOOGLE, SYNOLOGY)';

View File

@@ -1,21 +0,0 @@
-- 주간보고 공유 이력 테이블
-- 실행: psql -d weeklyreport -f create_report_share_log.sql
CREATE TABLE IF NOT EXISTS wr_report_share_log (
share_id SERIAL PRIMARY KEY,
report_id INTEGER NOT NULL REFERENCES wr_weekly_report(report_id),
shared_to VARCHAR(255) NOT NULL, -- 공유 대상 (이메일 또는 그룹명)
shared_type VARCHAR(50) NOT NULL DEFAULT 'GOOGLE_GROUP', -- GOOGLE_GROUP, EMAIL, SLACK 등
shared_by INTEGER NOT NULL REFERENCES wr_employee_info(employee_id),
shared_at TIMESTAMP DEFAULT NOW(),
message_id VARCHAR(255), -- Gmail 메시지 ID 등
share_status VARCHAR(20) DEFAULT 'SUCCESS', -- SUCCESS, FAILED
error_message TEXT
);
CREATE INDEX IF NOT EXISTS idx_report_share_report_id ON wr_report_share_log(report_id);
CREATE INDEX IF NOT EXISTS idx_report_share_shared_by ON wr_report_share_log(shared_by);
COMMENT ON TABLE wr_report_share_log IS '주간보고 공유 이력';
COMMENT ON COLUMN wr_report_share_log.shared_to IS '공유 대상 (이메일, 그룹 등)';
COMMENT ON COLUMN wr_report_share_log.shared_type IS '공유 방식 (GOOGLE_GROUP, EMAIL, SLACK 등)';

View File

@@ -1,236 +0,0 @@
import { query, execute, queryOne } from './db'
import { existsSync, mkdirSync, rmSync } from 'fs'
import { join } from 'path'
import { execSync, exec } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
// 임시 저장소 디렉토리
const REPO_TEMP_DIR = process.env.REPO_TEMP_DIR || join(process.cwd(), '.tmp', 'repos')
interface CommitInfo {
hash: string
author: string
email: string
date: string
message: string
filesChanged?: number
insertions?: number
deletions?: number
}
/**
* 저장소 정보 조회
*/
async function getRepositoryInfo(repoId: number) {
return await queryOne(`
SELECT
r.repo_id, r.project_id, r.repo_name, r.repo_path, r.repo_url,
r.default_branch, r.last_sync_at,
s.server_id, s.server_type, s.server_url, s.server_name,
s.auth_type, s.auth_username, s.auth_credential
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE r.repo_id = $1 AND r.is_active = true
`, [repoId])
}
/**
* Git 저장소 URL 생성
*/
function buildGitUrl(serverUrl: string, repoPath: string, authUsername?: string, authCredential?: string): string {
// 이미 전체 URL인 경우
if (repoPath.startsWith('http://') || repoPath.startsWith('https://') || repoPath.startsWith('git@')) {
return repoPath
}
// 서버 URL + 경로 조합
let baseUrl = serverUrl.replace(/\/$/, '')
let path = repoPath.startsWith('/') ? repoPath : `/${repoPath}`
// HTTPS 인증 추가
if (authUsername && authCredential && baseUrl.startsWith('https://')) {
const urlObj = new URL(baseUrl)
urlObj.username = encodeURIComponent(authUsername)
urlObj.password = encodeURIComponent(authCredential)
baseUrl = urlObj.toString().replace(/\/$/, '')
}
return `${baseUrl}${path}`
}
/**
* Git 커밋 로그 파싱
*/
function parseGitLog(output: string): CommitInfo[] {
const commits: CommitInfo[] = []
const lines = output.trim().split('\n')
for (const line of lines) {
if (!line.trim()) continue
// 포맷: hash|author|email|date|message
const parts = line.split('|')
if (parts.length >= 5) {
commits.push({
hash: parts[0],
author: parts[1],
email: parts[2],
date: parts[3],
message: parts.slice(4).join('|') // 메시지에 | 있을 수 있음
})
}
}
return commits
}
/**
* VCS 계정으로 사용자 매칭
*/
async function matchAuthor(serverId: number, authorName: string, authorEmail: string): Promise<number | null> {
// 이메일로 먼저 매칭
let matched = await queryOne(`
SELECT employee_id FROM wr_employee_vcs_account
WHERE server_id = $1 AND (vcs_email = $2 OR vcs_username = $3)
`, [serverId, authorEmail, authorName])
if (matched) {
return matched.employee_id
}
// VCS 계정에 없으면 직원 이메일로 매칭 시도
matched = await queryOne(`
SELECT employee_id FROM wr_employee_info
WHERE email = $1 AND is_active = true
`, [authorEmail])
return matched?.employee_id || null
}
/**
* Git 저장소 동기화
*/
export async function syncGitRepository(repoId: number): Promise<{ success: boolean; message: string; commitCount?: number }> {
const repo = await getRepositoryInfo(repoId)
if (!repo) {
return { success: false, message: '저장소를 찾을 수 없습니다.' }
}
if (repo.server_type !== 'GIT') {
return { success: false, message: 'Git 저장소가 아닙니다.' }
}
// 임시 디렉토리 생성
if (!existsSync(REPO_TEMP_DIR)) {
mkdirSync(REPO_TEMP_DIR, { recursive: true })
}
const localPath = join(REPO_TEMP_DIR, `repo_${repoId}`)
const gitUrl = buildGitUrl(repo.server_url, repo.repo_path, repo.auth_username, repo.auth_credential)
const branch = repo.default_branch || 'main'
try {
// Clone 또는 Pull
if (existsSync(localPath)) {
// 기존 저장소 업데이트
await execAsync(`cd "${localPath}" && git fetch origin && git reset --hard origin/${branch}`)
} else {
// 새로 클론 (shallow clone으로 최근 커밋만)
await execAsync(`git clone --depth 100 --single-branch --branch ${branch} "${gitUrl}" "${localPath}"`)
}
// 마지막 동기화 이후 커밋 조회
let sinceOption = ''
if (repo.last_sync_at) {
const sinceDate = new Date(repo.last_sync_at)
sinceDate.setDate(sinceDate.getDate() - 1) // 1일 전부터 (중복 방지용 UPSERT 사용)
sinceOption = `--since="${sinceDate.toISOString()}"`
} else {
sinceOption = '--since="30 days ago"' // 최초 동기화: 최근 30일
}
// 커밋 로그 조회
const logFormat = '%H|%an|%ae|%aI|%s'
const { stdout } = await execAsync(
`cd "${localPath}" && git log ${sinceOption} --format="${logFormat}" --no-merges`,
{ maxBuffer: 10 * 1024 * 1024 } // 10MB
)
const commits = parseGitLog(stdout)
let insertedCount = 0
// 커밋 저장
for (const commit of commits) {
const employeeId = await matchAuthor(repo.server_id, commit.author, commit.email)
// UPSERT (중복 무시)
const result = await execute(`
INSERT INTO wr_commit_log (
repo_id, commit_hash, commit_message, commit_author, commit_email,
commit_date, employee_id, synced_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (repo_id, commit_hash) DO NOTHING
`, [
repoId,
commit.hash,
commit.message,
commit.author,
commit.email,
commit.date,
employeeId
])
if (result.rowCount && result.rowCount > 0) {
insertedCount++
}
}
// 동기화 상태 업데이트
await execute(`
UPDATE wr_repository
SET last_sync_at = NOW(), last_sync_status = 'SUCCESS', last_sync_message = $2
WHERE repo_id = $1
`, [repoId, `${commits.length}개 커밋 조회, ${insertedCount}개 신규 저장`])
return {
success: true,
message: `동기화 완료: ${commits.length}개 커밋 조회, ${insertedCount}개 신규 저장`,
commitCount: insertedCount
}
} catch (error: any) {
console.error('Git sync error:', error)
// 실패 상태 업데이트
await execute(`
UPDATE wr_repository
SET last_sync_status = 'FAILED', last_sync_message = $2
WHERE repo_id = $1
`, [repoId, error.message?.substring(0, 500)])
return { success: false, message: error.message || '동기화 실패' }
}
}
/**
* 프로젝트의 모든 Git 저장소 동기화
*/
export async function syncProjectGitRepositories(projectId: number): Promise<{ success: boolean; results: any[] }> {
const repos = await query(`
SELECT r.repo_id
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE r.project_id = $1 AND r.is_active = true AND s.server_type = 'GIT'
`, [projectId])
const results = []
for (const repo of repos) {
const result = await syncGitRepository(repo.repo_id)
results.push({ repoId: repo.repo_id, ...result })
}
return { success: results.every(r => r.success), results }
}

View File

@@ -1,66 +0,0 @@
import { query, execute } from './db'
const config = useRuntimeConfig()
/**
* Google Access Token 갱신
*/
export async function refreshGoogleToken(employeeId: number): Promise<string | null> {
// 현재 토큰 정보 조회
const rows = await query<any>(`
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<string | null> {
return refreshGoogleToken(employeeId)
}

View File

@@ -1,217 +0,0 @@
import { query, execute, queryOne } from './db'
import { execSync, exec } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
interface SvnLogEntry {
revision: string
author: string
date: string
message: string
}
/**
* 저장소 정보 조회
*/
async function getRepositoryInfo(repoId: number) {
return await queryOne(`
SELECT
r.repo_id, r.project_id, r.repo_name, r.repo_path, r.repo_url,
r.default_branch, r.last_sync_at,
s.server_id, s.server_type, s.server_url, s.server_name,
s.auth_type, s.auth_username, s.auth_credential
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE r.repo_id = $1 AND r.is_active = true
`, [repoId])
}
/**
* SVN URL 생성
*/
function buildSvnUrl(serverUrl: string, repoPath: string): string {
// 이미 전체 URL인 경우
if (repoPath.startsWith('svn://') || repoPath.startsWith('http://') || repoPath.startsWith('https://')) {
return repoPath
}
// 서버 URL + 경로 조합
let baseUrl = serverUrl.replace(/\/$/, '')
let path = repoPath.startsWith('/') ? repoPath : `/${repoPath}`
return `${baseUrl}${path}`
}
/**
* SVN 로그 XML 파싱
*/
function parseSvnLogXml(xmlContent: string): SvnLogEntry[] {
const entries: SvnLogEntry[] = []
// 간단한 XML 파싱 (정규식 사용)
const logEntryRegex = /<logentry\s+revision="(\d+)">([\s\S]*?)<\/logentry>/g
const authorRegex = /<author>(.*?)<\/author>/
const dateRegex = /<date>(.*?)<\/date>/
const msgRegex = /<msg>([\s\S]*?)<\/msg>/
let match
while ((match = logEntryRegex.exec(xmlContent)) !== null) {
const revision = match[1]
const content = match[2]
const authorMatch = content.match(authorRegex)
const dateMatch = content.match(dateRegex)
const msgMatch = content.match(msgRegex)
entries.push({
revision,
author: authorMatch ? authorMatch[1] : '',
date: dateMatch ? dateMatch[1] : '',
message: msgMatch ? msgMatch[1].trim() : ''
})
}
return entries
}
/**
* VCS 계정으로 사용자 매칭
*/
async function matchAuthor(serverId: number, authorName: string): Promise<number | null> {
// SVN 사용자명으로 매칭
let matched = await queryOne(`
SELECT employee_id FROM wr_employee_vcs_account
WHERE server_id = $1 AND vcs_username = $2
`, [serverId, authorName])
if (matched) {
return matched.employee_id
}
// VCS 계정에 없으면 직원 이름으로 매칭 시도
matched = await queryOne(`
SELECT employee_id FROM wr_employee_info
WHERE (employee_name = $1 OR display_name = $1) AND is_active = true
`, [authorName])
return matched?.employee_id || null
}
/**
* SVN 저장소 동기화
*/
export async function syncSvnRepository(repoId: number): Promise<{ success: boolean; message: string; commitCount?: number }> {
const repo = await getRepositoryInfo(repoId)
if (!repo) {
return { success: false, message: '저장소를 찾을 수 없습니다.' }
}
if (repo.server_type !== 'SVN') {
return { success: false, message: 'SVN 저장소가 아닙니다.' }
}
const svnUrl = buildSvnUrl(repo.server_url, repo.repo_path)
// SVN 명령어 구성
let command = `svn log "${svnUrl}" --xml`
// 인증 정보 추가
if (repo.auth_username && repo.auth_credential) {
command += ` --username "${repo.auth_username}" --password "${repo.auth_credential}"`
}
// 기간 제한
if (repo.last_sync_at) {
const sinceDate = new Date(repo.last_sync_at)
sinceDate.setDate(sinceDate.getDate() - 1) // 1일 전부터
command += ` -r {${sinceDate.toISOString()}}:HEAD`
} else {
// 최초 동기화: 최근 100개 또는 30일
command += ' -l 100'
}
// 비대화형 모드
command += ' --non-interactive --trust-server-cert-failures=unknown-ca,cn-mismatch,expired,not-yet-valid,other'
try {
const { stdout, stderr } = await execAsync(command, {
maxBuffer: 10 * 1024 * 1024, // 10MB
timeout: 60000 // 60초 타임아웃
})
const entries = parseSvnLogXml(stdout)
let insertedCount = 0
// 커밋 저장
for (const entry of entries) {
const employeeId = await matchAuthor(repo.server_id, entry.author)
// UPSERT (중복 무시)
const result = await execute(`
INSERT INTO wr_commit_log (
repo_id, commit_hash, commit_message, commit_author,
commit_date, employee_id, synced_at
) VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (repo_id, commit_hash) DO NOTHING
`, [
repoId,
`r${entry.revision}`, // SVN: r123 형식
entry.message,
entry.author,
entry.date,
employeeId
])
if (result.rowCount && result.rowCount > 0) {
insertedCount++
}
}
// 동기화 상태 업데이트
await execute(`
UPDATE wr_repository
SET last_sync_at = NOW(), last_sync_status = 'SUCCESS', last_sync_message = $2
WHERE repo_id = $1
`, [repoId, `${entries.length}개 커밋 조회, ${insertedCount}개 신규 저장`])
return {
success: true,
message: `동기화 완료: ${entries.length}개 커밋 조회, ${insertedCount}개 신규 저장`,
commitCount: insertedCount
}
} catch (error: any) {
console.error('SVN sync error:', error)
// 실패 상태 업데이트
await execute(`
UPDATE wr_repository
SET last_sync_status = 'FAILED', last_sync_message = $2
WHERE repo_id = $1
`, [repoId, error.message?.substring(0, 500)])
return { success: false, message: error.message || '동기화 실패' }
}
}
/**
* 프로젝트의 모든 SVN 저장소 동기화
*/
export async function syncProjectSvnRepositories(projectId: number): Promise<{ success: boolean; results: any[] }> {
const repos = await query(`
SELECT r.repo_id
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE r.project_id = $1 AND r.is_active = true AND s.server_type = 'SVN'
`, [projectId])
const results = []
for (const repo of repos) {
const result = await syncSvnRepository(repo.repo_id)
results.push({ repoId: repo.repo_id, ...result })
}
return { success: results.every(r => r.success), results }
}