작업계획서대로 진행
This commit is contained in:
66
backend/api/auth/change-password.post.ts
Normal file
66
backend/api/auth/change-password.post.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { query, execute } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { getCurrentUser } from '../../utils/session'
|
||||
import { hashPassword, verifyPassword } from '../../utils/password'
|
||||
|
||||
interface ChangePasswordBody {
|
||||
currentPassword?: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경
|
||||
* POST /api/auth/change-password
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await getCurrentUser(event)
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<ChangePasswordBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
|
||||
if (!body.newPassword || !body.confirmPassword) {
|
||||
throw createError({ statusCode: 400, message: '새 비밀번호를 입력해주세요.' })
|
||||
}
|
||||
|
||||
if (body.newPassword !== body.confirmPassword) {
|
||||
throw createError({ statusCode: 400, message: '비밀번호가 일치하지 않습니다.' })
|
||||
}
|
||||
|
||||
if (body.newPassword.length < 8) {
|
||||
throw createError({ statusCode: 400, message: '비밀번호는 8자 이상이어야 합니다.' })
|
||||
}
|
||||
|
||||
// 현재 직원 정보 조회
|
||||
const employees = await query<any>(`
|
||||
SELECT password_hash FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [user.employeeId])
|
||||
|
||||
const employee = employees[0]
|
||||
|
||||
// 기존 비밀번호가 있으면 현재 비밀번호 검증
|
||||
if (employee?.password_hash) {
|
||||
if (!body.currentPassword) {
|
||||
throw createError({ statusCode: 400, message: '현재 비밀번호를 입력해주세요.' })
|
||||
}
|
||||
const isValid = await verifyPassword(body.currentPassword, employee.password_hash)
|
||||
if (!isValid) {
|
||||
throw createError({ statusCode: 401, message: '현재 비밀번호가 올바르지 않습니다.' })
|
||||
}
|
||||
}
|
||||
|
||||
// 새 비밀번호 해시
|
||||
const newHash = await hashPassword(body.newPassword)
|
||||
|
||||
// 비밀번호 업데이트
|
||||
await execute(`
|
||||
UPDATE wr_employee_info
|
||||
SET password_hash = $1, updated_at = NOW(), updated_ip = $2, updated_email = $3
|
||||
WHERE employee_id = $4
|
||||
`, [newHash, clientIp, user.employeeEmail, user.employeeId])
|
||||
|
||||
return { success: true, message: '비밀번호가 변경되었습니다.' }
|
||||
})
|
||||
127
backend/api/auth/google/callback.get.ts
Normal file
127
backend/api/auth/google/callback.get.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { query, execute, insertReturning } from '../../../utils/db'
|
||||
import { getClientIp } from '../../../utils/ip'
|
||||
import { createSession, setSessionCookie } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* Google OAuth 콜백
|
||||
* GET /api/auth/google/callback
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const params = getQuery(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userAgent = getHeader(event, 'user-agent') || null
|
||||
|
||||
const code = params.code as string
|
||||
const state = params.state as string
|
||||
const error = params.error as string
|
||||
|
||||
if (error) {
|
||||
return sendRedirect(event, '/login?error=oauth_denied')
|
||||
}
|
||||
|
||||
// State 검증
|
||||
const savedState = getCookie(event, 'oauth_state')
|
||||
if (!savedState || savedState !== state) {
|
||||
return sendRedirect(event, '/login?error=invalid_state')
|
||||
}
|
||||
deleteCookie(event, 'oauth_state')
|
||||
|
||||
const clientId = config.googleClientId || process.env.GOOGLE_CLIENT_ID
|
||||
const clientSecret = config.googleClientSecret || process.env.GOOGLE_CLIENT_SECRET
|
||||
const redirectUri = config.googleRedirectUri || process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/api/auth/google/callback'
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return sendRedirect(event, '/login?error=oauth_not_configured')
|
||||
}
|
||||
|
||||
try {
|
||||
// 토큰 교환
|
||||
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code'
|
||||
})
|
||||
})
|
||||
|
||||
const tokenData = await tokenRes.json()
|
||||
if (!tokenData.access_token) {
|
||||
console.error('Token error:', tokenData)
|
||||
return sendRedirect(event, '/login?error=token_failed')
|
||||
}
|
||||
|
||||
// 사용자 정보 조회
|
||||
const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||
headers: { Authorization: `Bearer ${tokenData.access_token}` }
|
||||
})
|
||||
const googleUser = await userRes.json()
|
||||
|
||||
if (!googleUser.email) {
|
||||
return sendRedirect(event, '/login?error=no_email')
|
||||
}
|
||||
|
||||
const googleId = googleUser.id
|
||||
const googleEmail = googleUser.email.toLowerCase()
|
||||
const googleName = googleUser.name || googleEmail.split('@')[0]
|
||||
|
||||
// 기존 사용자 조회 (이메일 기준)
|
||||
let employees = await query<any>(`
|
||||
SELECT * FROM wr_employee_info WHERE employee_email = $1
|
||||
`, [googleEmail])
|
||||
|
||||
let employee = employees[0]
|
||||
|
||||
if (employee) {
|
||||
// 기존 사용자 - Google 정보 연결
|
||||
await execute(`
|
||||
UPDATE wr_employee_info SET
|
||||
google_id = $1,
|
||||
google_email = $2,
|
||||
google_linked_at = NOW(),
|
||||
google_access_token = $3,
|
||||
google_refresh_token = $4,
|
||||
google_token_expires_at = NOW() + INTERVAL '${tokenData.expires_in} seconds',
|
||||
last_login_at = NOW(),
|
||||
last_login_ip = $5,
|
||||
updated_at = NOW()
|
||||
WHERE employee_id = $6
|
||||
`, [googleId, googleEmail, tokenData.access_token, tokenData.refresh_token || null, clientIp, employee.employee_id])
|
||||
} else {
|
||||
// 신규 사용자 자동 등록
|
||||
employee = await insertReturning(`
|
||||
INSERT INTO wr_employee_info (
|
||||
employee_name, employee_email, google_id, google_email, google_linked_at,
|
||||
google_access_token, google_refresh_token, google_token_expires_at,
|
||||
last_login_at, last_login_ip, created_ip, created_email
|
||||
) VALUES ($1, $2, $3, $2, NOW(), $4, $5, NOW() + INTERVAL '${tokenData.expires_in} seconds', NOW(), $6, $6, $2)
|
||||
RETURNING *
|
||||
`, [googleName, googleEmail, googleId, tokenData.access_token, tokenData.refresh_token || null, clientIp])
|
||||
}
|
||||
|
||||
// 로그인 이력 추가
|
||||
const loginHistory = await insertReturning(`
|
||||
INSERT INTO wr_login_history (employee_id, login_ip, login_email, login_type)
|
||||
VALUES ($1, $2, $3, 'GOOGLE')
|
||||
RETURNING history_id
|
||||
`, [employee.employee_id, clientIp, googleEmail])
|
||||
|
||||
// 세션 생성
|
||||
const sessionId = await createSession(
|
||||
employee.employee_id,
|
||||
loginHistory.history_id,
|
||||
clientIp,
|
||||
userAgent
|
||||
)
|
||||
setSessionCookie(event, sessionId)
|
||||
|
||||
return sendRedirect(event, '/')
|
||||
} catch (e) {
|
||||
console.error('Google OAuth error:', e)
|
||||
return sendRedirect(event, '/login?error=oauth_failed')
|
||||
}
|
||||
})
|
||||
35
backend/api/auth/google/index.get.ts
Normal file
35
backend/api/auth/google/index.get.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Google OAuth 시작
|
||||
* GET /api/auth/google
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const clientId = config.googleClientId || process.env.GOOGLE_CLIENT_ID
|
||||
const redirectUri = config.googleRedirectUri || process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/api/auth/google/callback'
|
||||
|
||||
if (!clientId) {
|
||||
throw createError({ statusCode: 500, message: 'Google OAuth가 설정되지 않았습니다.' })
|
||||
}
|
||||
|
||||
const scope = encodeURIComponent('openid email profile')
|
||||
const state = Math.random().toString(36).substring(7) // CSRF 방지
|
||||
|
||||
// state를 쿠키에 저장
|
||||
setCookie(event, 'oauth_state', state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 300 // 5분
|
||||
})
|
||||
|
||||
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
|
||||
`client_id=${clientId}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&response_type=code` +
|
||||
`&scope=${scope}` +
|
||||
`&state=${state}` +
|
||||
`&access_type=offline` +
|
||||
`&prompt=consent`
|
||||
|
||||
return sendRedirect(event, authUrl)
|
||||
})
|
||||
82
backend/api/auth/login-password.post.ts
Normal file
82
backend/api/auth/login-password.post.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { query, execute, insertReturning } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { createSession, setSessionCookie } from '../../utils/session'
|
||||
import { verifyPassword } from '../../utils/password'
|
||||
|
||||
interface LoginBody {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 로그인
|
||||
* POST /api/auth/login-password
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<LoginBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userAgent = getHeader(event, 'user-agent') || null
|
||||
|
||||
if (!body.email || !body.password) {
|
||||
throw createError({ statusCode: 400, message: '이메일과 비밀번호를 입력해주세요.' })
|
||||
}
|
||||
|
||||
const emailLower = body.email.toLowerCase()
|
||||
|
||||
// 직원 조회
|
||||
const employees = await query<any>(`
|
||||
SELECT * FROM wr_employee_info WHERE employee_email = $1 AND is_active = true
|
||||
`, [emailLower])
|
||||
|
||||
if (employees.length === 0) {
|
||||
throw createError({ statusCode: 401, message: '이메일 또는 비밀번호가 올바르지 않습니다.' })
|
||||
}
|
||||
|
||||
const employee = employees[0]
|
||||
|
||||
// 비밀번호 미설정
|
||||
if (!employee.password_hash) {
|
||||
throw createError({ statusCode: 401, message: '비밀번호가 설정되지 않았습니다. 관리자에게 문의하세요.' })
|
||||
}
|
||||
|
||||
// 비밀번호 검증
|
||||
const isValid = await verifyPassword(body.password, employee.password_hash)
|
||||
if (!isValid) {
|
||||
throw createError({ statusCode: 401, message: '이메일 또는 비밀번호가 올바르지 않습니다.' })
|
||||
}
|
||||
|
||||
// 마지막 로그인 시간 업데이트
|
||||
await execute(`
|
||||
UPDATE wr_employee_info
|
||||
SET last_login_at = NOW(), last_login_ip = $1, updated_at = NOW()
|
||||
WHERE employee_id = $2
|
||||
`, [clientIp, employee.employee_id])
|
||||
|
||||
// 로그인 이력 추가
|
||||
const loginHistory = await insertReturning(`
|
||||
INSERT INTO wr_login_history (employee_id, login_ip, login_email, login_type)
|
||||
VALUES ($1, $2, $3, 'PASSWORD')
|
||||
RETURNING history_id
|
||||
`, [employee.employee_id, clientIp, emailLower])
|
||||
|
||||
// 세션 생성
|
||||
const sessionId = await createSession(
|
||||
employee.employee_id,
|
||||
loginHistory.history_id,
|
||||
clientIp,
|
||||
userAgent
|
||||
)
|
||||
|
||||
setSessionCookie(event, sessionId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
employeeId: employee.employee_id,
|
||||
employeeName: employee.employee_name,
|
||||
employeeEmail: employee.employee_email,
|
||||
employeePosition: employee.employee_position,
|
||||
company: employee.company
|
||||
}
|
||||
}
|
||||
})
|
||||
78
backend/api/auth/reset-password.post.ts
Normal file
78
backend/api/auth/reset-password.post.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { query, execute } from '../../utils/db'
|
||||
import { hashPassword, generateTempPassword } from '../../utils/password'
|
||||
import { sendTempPasswordEmail } from '../../utils/email'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
|
||||
interface ResetPasswordBody {
|
||||
email: string
|
||||
name: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 찾기 (임시 비밀번호 발급)
|
||||
* POST /api/auth/reset-password
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<ResetPasswordBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
|
||||
if (!body.email || !body.name) {
|
||||
throw createError({ statusCode: 400, message: '이메일과 이름을 입력해주세요.' })
|
||||
}
|
||||
|
||||
const emailLower = body.email.toLowerCase()
|
||||
const nameTrimmed = body.name.trim()
|
||||
|
||||
// 사용자 조회 (이메일 + 이름)
|
||||
let sql = `SELECT * FROM wr_employee_info WHERE employee_email = $1 AND employee_name = $2`
|
||||
const params: any[] = [emailLower, nameTrimmed]
|
||||
|
||||
// 핸드폰 번호도 제공된 경우 추가 검증
|
||||
if (body.phone) {
|
||||
const phoneClean = body.phone.replace(/[^0-9]/g, '')
|
||||
sql += ` AND REPLACE(employee_phone, '-', '') = $3`
|
||||
params.push(phoneClean)
|
||||
}
|
||||
|
||||
const employees = await query<any>(sql, params)
|
||||
|
||||
if (employees.length === 0) {
|
||||
// 보안상 동일한 메시지 반환
|
||||
throw createError({ statusCode: 400, 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, employee.employee_id])
|
||||
|
||||
// 이메일 발송
|
||||
const emailSent = await sendTempPasswordEmail(
|
||||
employee.employee_email,
|
||||
employee.employee_name,
|
||||
tempPassword
|
||||
)
|
||||
|
||||
if (!emailSent) {
|
||||
// 이메일 발송 실패해도 비밀번호는 이미 변경됨
|
||||
console.warn('임시 비밀번호 이메일 발송 실패:', employee.employee_email)
|
||||
}
|
||||
|
||||
// 보안상 이메일 일부만 표시
|
||||
const maskedEmail = employee.employee_email.replace(/(.{2})(.*)(@.*)/, '$1***$3')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `임시 비밀번호가 ${maskedEmail}로 발송되었습니다.`,
|
||||
emailSent
|
||||
}
|
||||
})
|
||||
79
backend/api/auth/set-password.post.ts
Normal file
79
backend/api/auth/set-password.post.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { query, execute } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { getCurrentUser } from '../../utils/session'
|
||||
import { hashPassword, generateTempPassword } from '../../utils/password'
|
||||
|
||||
interface SetPasswordBody {
|
||||
employeeId: number
|
||||
password?: string
|
||||
generateTemp?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 비밀번호 설정 (관리자용)
|
||||
* POST /api/auth/set-password
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await getCurrentUser(event)
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
// 권한 확인 (ROLE_ADMIN만)
|
||||
const roles = await query<any>(`
|
||||
SELECT role_code FROM wr_employee_role WHERE employee_id = $1
|
||||
`, [user.employeeId])
|
||||
|
||||
const isAdmin = roles.some((r: any) => r.role_code === 'ROLE_ADMIN')
|
||||
if (!isAdmin) {
|
||||
throw createError({ statusCode: 403, message: '관리자 권한이 필요합니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<SetPasswordBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
|
||||
if (!body.employeeId) {
|
||||
throw createError({ statusCode: 400, message: '직원 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
let password = body.password
|
||||
|
||||
// 임시 비밀번호 생성
|
||||
if (body.generateTemp || !password) {
|
||||
password = generateTempPassword()
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
throw createError({ statusCode: 400, message: '비밀번호는 8자 이상이어야 합니다.' })
|
||||
}
|
||||
|
||||
// 대상 직원 존재 확인
|
||||
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 targetEmployee = employees[0]
|
||||
|
||||
// 비밀번호 해시
|
||||
const hash = await hashPassword(password)
|
||||
|
||||
// 업데이트
|
||||
await execute(`
|
||||
UPDATE wr_employee_info
|
||||
SET password_hash = $1, updated_at = NOW(), updated_ip = $2, updated_email = $3
|
||||
WHERE employee_id = $4
|
||||
`, [hash, clientIp, user.employeeEmail, body.employeeId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
employeeId: targetEmployee.employee_id,
|
||||
employeeName: targetEmployee.employee_name,
|
||||
employeeEmail: targetEmployee.employee_email,
|
||||
tempPassword: body.generateTemp ? password : undefined,
|
||||
message: body.generateTemp ? '임시 비밀번호가 생성되었습니다.' : '비밀번호가 설정되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -64,6 +64,33 @@ export default defineEventHandler(async (event) => {
|
||||
const totalWorkHours = employeeStats.reduce((sum: number, e: any) => sum + parseFloat(e.work_hours || 0), 0)
|
||||
const totalPlanHours = employeeStats.reduce((sum: number, e: any) => sum + parseFloat(e.plan_hours || 0), 0)
|
||||
|
||||
// 4. TODO 현황
|
||||
const todoStats = await query<any>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'PENDING') as pending,
|
||||
COUNT(*) FILTER (WHERE status = 'IN_PROGRESS') as in_progress,
|
||||
COUNT(*) FILTER (WHERE status = 'COMPLETED' AND completed_at >= NOW() - INTERVAL '7 days') as completed_this_week,
|
||||
COUNT(*) FILTER (WHERE due_date < CURRENT_DATE AND status NOT IN ('COMPLETED', 'CANCELLED')) as overdue
|
||||
FROM wr_todo
|
||||
`, [])
|
||||
|
||||
// 5. 유지보수 현황
|
||||
const maintenanceStats = await query<any>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'PENDING') as pending,
|
||||
COUNT(*) FILTER (WHERE status = 'IN_PROGRESS') as in_progress,
|
||||
COUNT(*) FILTER (WHERE status = 'COMPLETED' AND updated_at >= NOW() - INTERVAL '7 days') as completed_this_week
|
||||
FROM wr_maintenance_task
|
||||
`, [])
|
||||
|
||||
// 6. 최근 회의 현황
|
||||
const meetingStats = await query<any>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE meeting_date >= CURRENT_DATE - INTERVAL '7 days') as this_week,
|
||||
COUNT(*) FILTER (WHERE meeting_date >= CURRENT_DATE - INTERVAL '30 days') as this_month
|
||||
FROM wr_meeting
|
||||
`, [])
|
||||
|
||||
return {
|
||||
year,
|
||||
week,
|
||||
@@ -75,6 +102,21 @@ export default defineEventHandler(async (event) => {
|
||||
totalPlanHours,
|
||||
projectCount: projectStats.length
|
||||
},
|
||||
todo: {
|
||||
pending: parseInt(todoStats[0]?.pending) || 0,
|
||||
inProgress: parseInt(todoStats[0]?.in_progress) || 0,
|
||||
completedThisWeek: parseInt(todoStats[0]?.completed_this_week) || 0,
|
||||
overdue: parseInt(todoStats[0]?.overdue) || 0
|
||||
},
|
||||
maintenance: {
|
||||
pending: parseInt(maintenanceStats[0]?.pending) || 0,
|
||||
inProgress: parseInt(maintenanceStats[0]?.in_progress) || 0,
|
||||
completedThisWeek: parseInt(maintenanceStats[0]?.completed_this_week) || 0
|
||||
},
|
||||
meeting: {
|
||||
thisWeek: parseInt(meetingStats[0]?.this_week) || 0,
|
||||
thisMonth: parseInt(meetingStats[0]?.this_month) || 0
|
||||
},
|
||||
employees: employeeStats.map((e: any) => ({
|
||||
employeeId: e.employee_id,
|
||||
employeeName: e.employee_name,
|
||||
|
||||
145
backend/api/maintenance/stats.get.ts
Normal file
145
backend/api/maintenance/stats.get.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 유지보수 업무 통계
|
||||
* GET /api/maintenance/stats
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
const projectId = params.projectId ? Number(params.projectId) : null
|
||||
const year = params.year ? Number(params.year) : new Date().getFullYear()
|
||||
const month = params.month ? Number(params.month) : null
|
||||
|
||||
const conditions: string[] = ['EXTRACT(YEAR FROM m.request_date) = $1']
|
||||
const values: any[] = [year]
|
||||
let paramIndex = 2
|
||||
|
||||
if (projectId) {
|
||||
conditions.push(`m.project_id = $${paramIndex++}`)
|
||||
values.push(projectId)
|
||||
}
|
||||
|
||||
if (month) {
|
||||
conditions.push(`EXTRACT(MONTH FROM m.request_date) = $${paramIndex++}`)
|
||||
values.push(month)
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(' AND ')
|
||||
|
||||
// 상태별 통계
|
||||
const statusStats = await query(`
|
||||
SELECT
|
||||
m.status,
|
||||
COUNT(*) as count
|
||||
FROM wr_maintenance_task m
|
||||
WHERE ${whereClause}
|
||||
GROUP BY m.status
|
||||
`, values)
|
||||
|
||||
// 유형별 통계
|
||||
const typeStats = await query(`
|
||||
SELECT
|
||||
m.task_type,
|
||||
COUNT(*) as count
|
||||
FROM wr_maintenance_task m
|
||||
WHERE ${whereClause}
|
||||
GROUP BY m.task_type
|
||||
`, values)
|
||||
|
||||
// 우선순위별 통계
|
||||
const priorityStats = await query(`
|
||||
SELECT
|
||||
m.priority,
|
||||
COUNT(*) as count
|
||||
FROM wr_maintenance_task m
|
||||
WHERE ${whereClause}
|
||||
GROUP BY m.priority
|
||||
`, values)
|
||||
|
||||
// 월별 추이 (연간)
|
||||
const monthlyTrend = await query(`
|
||||
SELECT
|
||||
EXTRACT(MONTH FROM m.request_date) as month,
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN m.status = 'COMPLETED' THEN 1 END) as completed
|
||||
FROM wr_maintenance_task m
|
||||
WHERE EXTRACT(YEAR FROM m.request_date) = $1
|
||||
${projectId ? 'AND m.project_id = $2' : ''}
|
||||
GROUP BY EXTRACT(MONTH FROM m.request_date)
|
||||
ORDER BY month
|
||||
`, projectId ? [year, projectId] : [year])
|
||||
|
||||
// 담당자별 통계
|
||||
const assigneeStats = await query(`
|
||||
SELECT
|
||||
e.employee_id,
|
||||
e.employee_name,
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN m.status = 'COMPLETED' THEN 1 END) as completed
|
||||
FROM wr_maintenance_task m
|
||||
JOIN wr_employee_info e ON m.assignee_id = e.employee_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY e.employee_id, e.employee_name
|
||||
ORDER BY total DESC
|
||||
LIMIT 10
|
||||
`, values)
|
||||
|
||||
// 프로젝트별 통계
|
||||
const projectStats = await query(`
|
||||
SELECT
|
||||
p.project_id,
|
||||
p.project_name,
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN m.status = 'COMPLETED' THEN 1 END) as completed
|
||||
FROM wr_maintenance_task m
|
||||
JOIN wr_project_info p ON m.project_id = p.project_id
|
||||
WHERE EXTRACT(YEAR FROM m.request_date) = $1
|
||||
${month ? 'AND EXTRACT(MONTH FROM m.request_date) = $2' : ''}
|
||||
GROUP BY p.project_id, p.project_name
|
||||
ORDER BY total DESC
|
||||
LIMIT 10
|
||||
`, month ? [year, month] : [year])
|
||||
|
||||
// 전체 합계
|
||||
const totalResult = await query(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN m.status = 'COMPLETED' THEN 1 END) as completed,
|
||||
COUNT(CASE WHEN m.status = 'PENDING' THEN 1 END) as pending,
|
||||
COUNT(CASE WHEN m.status = 'IN_PROGRESS' THEN 1 END) as in_progress
|
||||
FROM wr_maintenance_task m
|
||||
WHERE ${whereClause}
|
||||
`, values)
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
projectId,
|
||||
summary: {
|
||||
total: Number(totalResult[0]?.total || 0),
|
||||
completed: Number(totalResult[0]?.completed || 0),
|
||||
pending: Number(totalResult[0]?.pending || 0),
|
||||
inProgress: Number(totalResult[0]?.in_progress || 0)
|
||||
},
|
||||
byStatus: statusStats.map((s: any) => ({ status: s.status, count: Number(s.count) })),
|
||||
byType: typeStats.map((t: any) => ({ taskType: t.task_type, count: Number(t.count) })),
|
||||
byPriority: priorityStats.map((p: any) => ({ priority: p.priority, count: Number(p.count) })),
|
||||
monthlyTrend: monthlyTrend.map((m: any) => ({
|
||||
month: Number(m.month),
|
||||
total: Number(m.total),
|
||||
completed: Number(m.completed)
|
||||
})),
|
||||
byAssignee: assigneeStats.map((a: any) => ({
|
||||
employeeId: a.employee_id,
|
||||
employeeName: a.employee_name,
|
||||
total: Number(a.total),
|
||||
completed: Number(a.completed)
|
||||
})),
|
||||
byProject: projectStats.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectName: p.project_name,
|
||||
total: Number(p.total),
|
||||
completed: Number(p.completed)
|
||||
}))
|
||||
}
|
||||
})
|
||||
67
backend/api/repository/list.get.ts
Normal file
67
backend/api/repository/list.get.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 저장소 목록 조회
|
||||
* GET /api/repository/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
const projectId = params.projectId ? Number(params.projectId) : null
|
||||
const serverId = params.serverId ? Number(params.serverId) : null
|
||||
const includeInactive = params.includeInactive === 'true'
|
||||
|
||||
const conditions: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (projectId) {
|
||||
conditions.push(`r.project_id = $${paramIndex++}`)
|
||||
values.push(projectId)
|
||||
}
|
||||
|
||||
if (serverId) {
|
||||
conditions.push(`r.server_id = $${paramIndex++}`)
|
||||
values.push(serverId)
|
||||
}
|
||||
|
||||
if (!includeInactive) {
|
||||
conditions.push('r.is_active = true')
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
const repos = await query(`
|
||||
SELECT
|
||||
r.*,
|
||||
s.server_name,
|
||||
s.server_type,
|
||||
p.project_name,
|
||||
e.employee_name as created_by_name
|
||||
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
|
||||
LEFT JOIN wr_employee_info e ON r.created_by = e.employee_id
|
||||
${whereClause}
|
||||
ORDER BY r.repo_name
|
||||
`, values)
|
||||
|
||||
return {
|
||||
repositories: repos.map((r: any) => ({
|
||||
repoId: r.repo_id,
|
||||
serverId: r.server_id,
|
||||
serverName: r.server_name,
|
||||
serverType: r.server_type,
|
||||
projectId: r.project_id,
|
||||
projectName: r.project_name,
|
||||
repoName: r.repo_name,
|
||||
repoPath: r.repo_path,
|
||||
repoUrl: r.repo_url,
|
||||
defaultBranch: r.default_branch,
|
||||
description: r.description,
|
||||
isActive: r.is_active,
|
||||
lastSyncAt: r.last_sync_at,
|
||||
createdAt: r.created_at,
|
||||
createdByName: r.created_by_name
|
||||
}))
|
||||
}
|
||||
})
|
||||
18
backend/api/todo/[id]/delete.delete.ts
Normal file
18
backend/api/todo/[id]/delete.delete.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* TODO 삭제
|
||||
* DELETE /api/todo/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const todoId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
const existing = await queryOne('SELECT * FROM wr_todo WHERE todo_id = $1', [todoId])
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: 'TODO를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute('DELETE FROM wr_todo WHERE todo_id = $1', [todoId])
|
||||
|
||||
return { success: true, message: 'TODO가 삭제되었습니다.' }
|
||||
})
|
||||
53
backend/api/todo/[id]/detail.get.ts
Normal file
53
backend/api/todo/[id]/detail.get.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* TODO 상세 조회
|
||||
* GET /api/todo/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const todoId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
const todo = await queryOne(`
|
||||
SELECT
|
||||
t.*,
|
||||
p.project_name,
|
||||
m.meeting_title,
|
||||
a.employee_name as assignee_name,
|
||||
r.employee_name as reporter_name
|
||||
FROM wr_todo t
|
||||
LEFT JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
LEFT JOIN wr_meeting m ON t.meeting_id = m.meeting_id
|
||||
LEFT JOIN wr_employee_info a ON t.assignee_id = a.employee_id
|
||||
LEFT JOIN wr_employee_info r ON t.reporter_id = r.employee_id
|
||||
WHERE t.todo_id = $1
|
||||
`, [todoId])
|
||||
|
||||
if (!todo) {
|
||||
throw createError({ statusCode: 404, message: 'TODO를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
return {
|
||||
todo: {
|
||||
todoId: todo.todo_id,
|
||||
todoTitle: todo.todo_title,
|
||||
todoContent: todo.todo_content,
|
||||
sourceType: todo.source_type,
|
||||
sourceId: todo.source_id,
|
||||
meetingId: todo.meeting_id,
|
||||
meetingTitle: todo.meeting_title,
|
||||
projectId: todo.project_id,
|
||||
projectName: todo.project_name,
|
||||
assigneeId: todo.assignee_id,
|
||||
assigneeName: todo.assignee_name,
|
||||
reporterId: todo.reporter_id,
|
||||
reporterName: todo.reporter_name,
|
||||
dueDate: todo.due_date,
|
||||
status: todo.status,
|
||||
priority: todo.priority,
|
||||
completedAt: todo.completed_at,
|
||||
weeklyReportId: todo.weekly_report_id,
|
||||
createdAt: todo.created_at,
|
||||
updatedAt: todo.updated_at
|
||||
}
|
||||
}
|
||||
})
|
||||
84
backend/api/todo/[id]/update.put.ts
Normal file
84
backend/api/todo/[id]/update.put.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
|
||||
interface UpdateTodoBody {
|
||||
todoTitle?: string
|
||||
todoContent?: string
|
||||
projectId?: number | null
|
||||
assigneeId?: number | null
|
||||
dueDate?: string | null
|
||||
status?: string
|
||||
priority?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO 수정
|
||||
* PUT /api/todo/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const todoId = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody<UpdateTodoBody>(event)
|
||||
|
||||
const existing = await queryOne('SELECT * FROM wr_todo WHERE todo_id = $1', [todoId])
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: 'TODO를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (body.todoTitle !== undefined) {
|
||||
updates.push(`todo_title = $${paramIndex++}`)
|
||||
values.push(body.todoTitle)
|
||||
}
|
||||
|
||||
if (body.todoContent !== undefined) {
|
||||
updates.push(`todo_content = $${paramIndex++}`)
|
||||
values.push(body.todoContent)
|
||||
}
|
||||
|
||||
if (body.projectId !== undefined) {
|
||||
updates.push(`project_id = $${paramIndex++}`)
|
||||
values.push(body.projectId)
|
||||
}
|
||||
|
||||
if (body.assigneeId !== undefined) {
|
||||
updates.push(`assignee_id = $${paramIndex++}`)
|
||||
values.push(body.assigneeId)
|
||||
}
|
||||
|
||||
if (body.dueDate !== undefined) {
|
||||
updates.push(`due_date = $${paramIndex++}`)
|
||||
values.push(body.dueDate)
|
||||
}
|
||||
|
||||
if (body.status !== undefined) {
|
||||
updates.push(`status = $${paramIndex++}`)
|
||||
values.push(body.status)
|
||||
|
||||
// 완료 상태로 변경 시 완료일시 기록
|
||||
if (body.status === 'COMPLETED') {
|
||||
updates.push(`completed_at = NOW()`)
|
||||
} else if (existing.status === 'COMPLETED') {
|
||||
updates.push(`completed_at = NULL`)
|
||||
}
|
||||
}
|
||||
|
||||
if (body.priority !== undefined) {
|
||||
updates.push(`priority = $${paramIndex++}`)
|
||||
values.push(body.priority)
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return { success: true, message: '변경된 내용이 없습니다.' }
|
||||
}
|
||||
|
||||
updates.push('updated_at = NOW()')
|
||||
values.push(todoId)
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_todo SET ${updates.join(', ')} WHERE todo_id = $${paramIndex}
|
||||
`, values)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
55
backend/api/todo/create.post.ts
Normal file
55
backend/api/todo/create.post.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { insertReturning } from '../../utils/db'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
|
||||
interface CreateTodoBody {
|
||||
todoTitle: string
|
||||
todoContent?: string
|
||||
sourceType?: string
|
||||
sourceId?: number
|
||||
meetingId?: number
|
||||
projectId?: number
|
||||
assigneeId?: number
|
||||
dueDate?: string
|
||||
priority?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO 생성
|
||||
* POST /api/todo/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CreateTodoBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!body.todoTitle?.trim()) {
|
||||
throw createError({ statusCode: 400, message: '제목을 입력해주세요.' })
|
||||
}
|
||||
|
||||
const result = await insertReturning(`
|
||||
INSERT INTO wr_todo (
|
||||
todo_title, todo_content, source_type, source_id, meeting_id,
|
||||
project_id, assignee_id, reporter_id, due_date, status, priority, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'PENDING', $10, $8)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.todoTitle.trim(),
|
||||
body.todoContent || null,
|
||||
body.sourceType || null,
|
||||
body.sourceId || null,
|
||||
body.meetingId || null,
|
||||
body.projectId || null,
|
||||
body.assigneeId || null,
|
||||
userId,
|
||||
body.dueDate || null,
|
||||
body.priority || 1
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
todo: {
|
||||
todoId: result.todo_id,
|
||||
todoTitle: result.todo_title,
|
||||
status: result.status
|
||||
}
|
||||
}
|
||||
})
|
||||
97
backend/api/todo/list.get.ts
Normal file
97
backend/api/todo/list.get.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
|
||||
/**
|
||||
* TODO 목록 조회
|
||||
* GET /api/todo/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
const status = params.status as string | null
|
||||
const assigneeId = params.assigneeId ? Number(params.assigneeId) : null
|
||||
const projectId = params.projectId ? Number(params.projectId) : null
|
||||
const meetingId = params.meetingId ? Number(params.meetingId) : null
|
||||
const myOnly = params.myOnly === 'true'
|
||||
|
||||
const conditions: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (status) {
|
||||
conditions.push(`t.status = $${paramIndex++}`)
|
||||
values.push(status)
|
||||
}
|
||||
|
||||
if (assigneeId) {
|
||||
conditions.push(`t.assignee_id = $${paramIndex++}`)
|
||||
values.push(assigneeId)
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
conditions.push(`t.project_id = $${paramIndex++}`)
|
||||
values.push(projectId)
|
||||
}
|
||||
|
||||
if (meetingId) {
|
||||
conditions.push(`t.meeting_id = $${paramIndex++}`)
|
||||
values.push(meetingId)
|
||||
}
|
||||
|
||||
if (myOnly && userId) {
|
||||
conditions.push(`(t.assignee_id = $${paramIndex} OR t.reporter_id = $${paramIndex})`)
|
||||
values.push(userId)
|
||||
paramIndex++
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
t.*,
|
||||
p.project_name,
|
||||
m.meeting_title,
|
||||
a.employee_name as assignee_name,
|
||||
r.employee_name as reporter_name
|
||||
FROM wr_todo t
|
||||
LEFT JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
LEFT JOIN wr_meeting m ON t.meeting_id = m.meeting_id
|
||||
LEFT JOIN wr_employee_info a ON t.assignee_id = a.employee_id
|
||||
LEFT JOIN wr_employee_info r ON t.reporter_id = r.employee_id
|
||||
${whereClause}
|
||||
ORDER BY
|
||||
CASE t.status WHEN 'PENDING' THEN 1 WHEN 'IN_PROGRESS' THEN 2 WHEN 'COMPLETED' THEN 3 ELSE 4 END,
|
||||
t.priority DESC,
|
||||
t.due_date ASC NULLS LAST,
|
||||
t.created_at DESC
|
||||
LIMIT 100
|
||||
`
|
||||
|
||||
const todos = await query(sql, values)
|
||||
|
||||
return {
|
||||
todos: todos.map((t: any) => ({
|
||||
todoId: t.todo_id,
|
||||
todoTitle: t.todo_title,
|
||||
todoContent: t.todo_content,
|
||||
sourceType: t.source_type,
|
||||
sourceId: t.source_id,
|
||||
meetingId: t.meeting_id,
|
||||
meetingTitle: t.meeting_title,
|
||||
projectId: t.project_id,
|
||||
projectName: t.project_name,
|
||||
assigneeId: t.assignee_id,
|
||||
assigneeName: t.assignee_name,
|
||||
reporterId: t.reporter_id,
|
||||
reporterName: t.reporter_name,
|
||||
dueDate: t.due_date,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
completedAt: t.completed_at,
|
||||
weeklyReportId: t.weekly_report_id,
|
||||
createdAt: t.created_at,
|
||||
updatedAt: t.updated_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
43
backend/api/todo/report/link.post.ts
Normal file
43
backend/api/todo/report/link.post.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
|
||||
interface LinkBody {
|
||||
todoId: number
|
||||
weeklyReportId: number
|
||||
markCompleted?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO와 주간보고 연계
|
||||
* POST /api/todo/report/link
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<LinkBody>(event)
|
||||
|
||||
if (!body.todoId || !body.weeklyReportId) {
|
||||
throw createError({ statusCode: 400, message: 'TODO ID와 주간보고 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const todo = await queryOne('SELECT * FROM wr_todo WHERE todo_id = $1', [body.todoId])
|
||||
if (!todo) {
|
||||
throw createError({ statusCode: 404, message: 'TODO를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
const updates = ['weekly_report_id = $1', 'updated_at = NOW()']
|
||||
const values = [body.weeklyReportId]
|
||||
|
||||
if (body.markCompleted) {
|
||||
updates.push('status = $2', 'completed_at = NOW()')
|
||||
values.push('COMPLETED')
|
||||
}
|
||||
|
||||
values.push(body.todoId)
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_todo SET ${updates.join(', ')} WHERE todo_id = $${values.length}
|
||||
`, values)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: body.markCompleted ? 'TODO가 완료 처리되고 주간보고와 연계되었습니다.' : 'TODO가 주간보고와 연계되었습니다.'
|
||||
}
|
||||
})
|
||||
108
backend/api/todo/report/similar.post.ts
Normal file
108
backend/api/todo/report/similar.post.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { query } from '../../../utils/db'
|
||||
import { callOpenAI } from '../../../utils/openai'
|
||||
|
||||
interface SearchBody {
|
||||
taskDescription: string
|
||||
projectId?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 실적과 유사한 TODO 검색
|
||||
* POST /api/todo/report/similar
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<SearchBody>(event)
|
||||
|
||||
if (!body.taskDescription?.trim()) {
|
||||
return { todos: [] }
|
||||
}
|
||||
|
||||
// 해당 프로젝트의 미완료 TODO 조회
|
||||
const conditions = ["t.status IN ('PENDING', 'IN_PROGRESS')"]
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (body.projectId) {
|
||||
conditions.push(`t.project_id = $${paramIndex++}`)
|
||||
values.push(body.projectId)
|
||||
}
|
||||
|
||||
const todos = await query(`
|
||||
SELECT
|
||||
t.todo_id,
|
||||
t.todo_title,
|
||||
t.todo_content,
|
||||
t.project_id,
|
||||
p.project_name,
|
||||
t.status,
|
||||
t.due_date
|
||||
FROM wr_todo t
|
||||
LEFT JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 20
|
||||
`, values)
|
||||
|
||||
if (todos.length === 0) {
|
||||
return { todos: [] }
|
||||
}
|
||||
|
||||
// OpenAI로 유사도 분석
|
||||
const todoList = todos.map((t: any, i: number) =>
|
||||
`${i + 1}. [ID:${t.todo_id}] ${t.todo_title}${t.todo_content ? ' - ' + t.todo_content.substring(0, 50) : ''}`
|
||||
).join('\n')
|
||||
|
||||
const prompt = `다음 주간보고 실적과 가장 유사한 TODO를 찾아주세요.
|
||||
|
||||
실적 내용: "${body.taskDescription}"
|
||||
|
||||
TODO 목록:
|
||||
${todoList}
|
||||
|
||||
유사한 TODO의 ID를 배열로 반환해주세요 (유사도 70% 이상만, 최대 3개).
|
||||
JSON 형식: { "similarIds": [1, 2] }`
|
||||
|
||||
try {
|
||||
const response = await callOpenAI([
|
||||
{ role: 'system', content: '주간보고 실적과 TODO의 유사도를 분석하는 전문가입니다.' },
|
||||
{ role: 'user', content: prompt }
|
||||
], true)
|
||||
|
||||
const parsed = JSON.parse(response)
|
||||
const similarIds = parsed.similarIds || []
|
||||
|
||||
const similarTodos = todos.filter((t: any) => similarIds.includes(t.todo_id))
|
||||
|
||||
return {
|
||||
todos: similarTodos.map((t: any) => ({
|
||||
todoId: t.todo_id,
|
||||
todoTitle: t.todo_title,
|
||||
todoContent: t.todo_content,
|
||||
projectId: t.project_id,
|
||||
projectName: t.project_name,
|
||||
status: t.status,
|
||||
dueDate: t.due_date
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('OpenAI error:', e)
|
||||
// 실패 시 키워드 기반 간단 매칭
|
||||
const keywords = body.taskDescription.split(/\s+/).filter(k => k.length >= 2)
|
||||
const matched = todos.filter((t: any) => {
|
||||
const title = t.todo_title?.toLowerCase() || ''
|
||||
return keywords.some(k => title.includes(k.toLowerCase()))
|
||||
}).slice(0, 3)
|
||||
|
||||
return {
|
||||
todos: matched.map((t: any) => ({
|
||||
todoId: t.todo_id,
|
||||
todoTitle: t.todo_title,
|
||||
projectId: t.project_id,
|
||||
projectName: t.project_name,
|
||||
status: t.status,
|
||||
dueDate: t.due_date
|
||||
})),
|
||||
fallback: true
|
||||
}
|
||||
}
|
||||
})
|
||||
23
backend/api/vcs-account/[id]/delete.delete.ts
Normal file
23
backend/api/vcs-account/[id]/delete.delete.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
import { getCurrentUserId } from '../../../utils/user'
|
||||
|
||||
/**
|
||||
* VCS 계정 삭제
|
||||
* DELETE /api/vcs-account/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const accountId = Number(getRouterParam(event, 'id'))
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
const existing = await queryOne(
|
||||
'SELECT * FROM wr_employee_vcs_account WHERE account_id = $1 AND employee_id = $2',
|
||||
[accountId, userId]
|
||||
)
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: 'VCS 계정을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute('DELETE FROM wr_employee_vcs_account WHERE account_id = $1', [accountId])
|
||||
|
||||
return { success: true, message: 'VCS 계정이 삭제되었습니다.' }
|
||||
})
|
||||
80
backend/api/vcs-account/[id]/update.put.ts
Normal file
80
backend/api/vcs-account/[id]/update.put.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
import { getCurrentUserId } from '../../../utils/user'
|
||||
import crypto from 'crypto'
|
||||
|
||||
interface UpdateBody {
|
||||
vcsUsername?: string
|
||||
vcsEmail?: string
|
||||
authType?: string
|
||||
credential?: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
function encryptCredential(text: string): string {
|
||||
const key = process.env.ENCRYPTION_KEY || 'weeklyreport-default-key-32byte!'
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key.padEnd(32, '0').slice(0, 32)), iv)
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return iv.toString('hex') + ':' + encrypted
|
||||
}
|
||||
|
||||
/**
|
||||
* VCS 계정 수정
|
||||
* PUT /api/vcs-account/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const accountId = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody<UpdateBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
const existing = await queryOne(
|
||||
'SELECT * FROM wr_employee_vcs_account WHERE account_id = $1 AND employee_id = $2',
|
||||
[accountId, userId]
|
||||
)
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: 'VCS 계정을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (body.vcsUsername !== undefined) {
|
||||
updates.push(`vcs_username = $${paramIndex++}`)
|
||||
values.push(body.vcsUsername)
|
||||
}
|
||||
|
||||
if (body.vcsEmail !== undefined) {
|
||||
updates.push(`vcs_email = $${paramIndex++}`)
|
||||
values.push(body.vcsEmail)
|
||||
}
|
||||
|
||||
if (body.authType !== undefined) {
|
||||
updates.push(`auth_type = $${paramIndex++}`)
|
||||
values.push(body.authType)
|
||||
}
|
||||
|
||||
if (body.credential !== undefined && body.credential) {
|
||||
updates.push(`encrypted_credential = $${paramIndex++}`)
|
||||
values.push(encryptCredential(body.credential))
|
||||
}
|
||||
|
||||
if (body.isActive !== undefined) {
|
||||
updates.push(`is_active = $${paramIndex++}`)
|
||||
values.push(body.isActive)
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return { success: true, message: '변경된 내용이 없습니다.' }
|
||||
}
|
||||
|
||||
updates.push('updated_at = NOW()')
|
||||
values.push(accountId)
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_employee_vcs_account SET ${updates.join(', ')} WHERE account_id = $${paramIndex}
|
||||
`, values)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
82
backend/api/vcs-account/create.post.ts
Normal file
82
backend/api/vcs-account/create.post.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { insertReturning, queryOne } from '../../utils/db'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
import crypto from 'crypto'
|
||||
|
||||
interface CreateBody {
|
||||
serverId: number
|
||||
vcsUsername: string
|
||||
vcsEmail?: string
|
||||
authType: string // password | token | ssh
|
||||
credential?: string
|
||||
}
|
||||
|
||||
// 간단한 암호화 (실제 운영에서는 더 강력한 방식 사용)
|
||||
function encryptCredential(text: string): string {
|
||||
const key = process.env.ENCRYPTION_KEY || 'weeklyreport-default-key-32byte!'
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key.padEnd(32, '0').slice(0, 32)), iv)
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return iv.toString('hex') + ':' + encrypted
|
||||
}
|
||||
|
||||
/**
|
||||
* VCS 계정 등록
|
||||
* POST /api/vcs-account/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CreateBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!body.serverId) {
|
||||
throw createError({ statusCode: 400, message: 'VCS 서버를 선택해주세요.' })
|
||||
}
|
||||
|
||||
if (!body.vcsUsername?.trim()) {
|
||||
throw createError({ statusCode: 400, message: 'VCS 사용자명을 입력해주세요.' })
|
||||
}
|
||||
|
||||
// 서버 존재 확인
|
||||
const server = await queryOne('SELECT * FROM wr_vcs_server WHERE server_id = $1 AND is_active = true', [body.serverId])
|
||||
if (!server) {
|
||||
throw createError({ statusCode: 404, message: 'VCS 서버를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 중복 확인
|
||||
const existing = await queryOne(
|
||||
'SELECT * FROM wr_employee_vcs_account WHERE employee_id = $1 AND server_id = $2',
|
||||
[userId, body.serverId]
|
||||
)
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 400, message: '이미 해당 서버에 계정이 등록되어 있습니다.' })
|
||||
}
|
||||
|
||||
// 자격증명 암호화
|
||||
let encryptedCred = null
|
||||
if (body.credential) {
|
||||
encryptedCred = encryptCredential(body.credential)
|
||||
}
|
||||
|
||||
const result = await insertReturning(`
|
||||
INSERT INTO wr_employee_vcs_account (
|
||||
employee_id, server_id, vcs_username, vcs_email, auth_type, encrypted_credential, is_active
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, true)
|
||||
RETURNING *
|
||||
`, [
|
||||
userId,
|
||||
body.serverId,
|
||||
body.vcsUsername.trim(),
|
||||
body.vcsEmail || null,
|
||||
body.authType || 'password',
|
||||
encryptedCred
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account: {
|
||||
accountId: result.account_id,
|
||||
serverId: result.server_id,
|
||||
vcsUsername: result.vcs_username
|
||||
}
|
||||
}
|
||||
})
|
||||
37
backend/api/vcs-account/my.get.ts
Normal file
37
backend/api/vcs-account/my.get.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
|
||||
/**
|
||||
* 내 VCS 계정 목록 조회
|
||||
* GET /api/vcs-account/my
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
const accounts = await query(`
|
||||
SELECT
|
||||
a.*,
|
||||
s.server_name,
|
||||
s.server_type,
|
||||
s.server_url
|
||||
FROM wr_employee_vcs_account a
|
||||
JOIN wr_vcs_server s ON a.server_id = s.server_id
|
||||
WHERE a.employee_id = $1
|
||||
ORDER BY s.server_name, a.vcs_username
|
||||
`, [userId])
|
||||
|
||||
return {
|
||||
accounts: accounts.map((a: any) => ({
|
||||
accountId: a.account_id,
|
||||
serverId: a.server_id,
|
||||
serverName: a.server_name,
|
||||
serverType: a.server_type,
|
||||
serverUrl: a.server_url,
|
||||
vcsUsername: a.vcs_username,
|
||||
vcsEmail: a.vcs_email,
|
||||
authType: a.auth_type,
|
||||
isActive: a.is_active,
|
||||
createdAt: a.created_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
24
backend/api/vcs-server/[id]/delete.delete.ts
Normal file
24
backend/api/vcs-server/[id]/delete.delete.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { execute, queryOne, query } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* VCS 서버 삭제
|
||||
* DELETE /api/vcs-server/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const serverId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
const existing = await queryOne('SELECT * FROM wr_vcs_server WHERE server_id = $1', [serverId])
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: 'VCS 서버를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 연결된 계정이 있는지 확인
|
||||
const accounts = await query('SELECT COUNT(*) as cnt FROM wr_employee_vcs_account WHERE server_id = $1', [serverId])
|
||||
if (parseInt(accounts[0]?.cnt) > 0) {
|
||||
throw createError({ statusCode: 400, message: '연결된 VCS 계정이 있어 삭제할 수 없습니다. 먼저 계정을 삭제하거나 비활성화해주세요.' })
|
||||
}
|
||||
|
||||
await execute('DELETE FROM wr_vcs_server WHERE server_id = $1', [serverId])
|
||||
|
||||
return { success: true, message: 'VCS 서버가 삭제되었습니다.' }
|
||||
})
|
||||
34
backend/api/vcs-server/[id]/detail.get.ts
Normal file
34
backend/api/vcs-server/[id]/detail.get.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* VCS 서버 상세 조회
|
||||
* GET /api/vcs-server/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const serverId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
const server = await queryOne(`
|
||||
SELECT s.*, e.employee_name as created_by_name
|
||||
FROM wr_vcs_server s
|
||||
LEFT JOIN wr_employee_info e ON s.created_by = e.employee_id
|
||||
WHERE s.server_id = $1
|
||||
`, [serverId])
|
||||
|
||||
if (!server) {
|
||||
throw createError({ statusCode: 404, message: 'VCS 서버를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
return {
|
||||
server: {
|
||||
serverId: server.server_id,
|
||||
serverName: server.server_name,
|
||||
serverType: server.server_type,
|
||||
serverUrl: server.server_url,
|
||||
description: server.description,
|
||||
isActive: server.is_active,
|
||||
createdAt: server.created_at,
|
||||
updatedAt: server.updated_at,
|
||||
createdByName: server.created_by_name
|
||||
}
|
||||
}
|
||||
})
|
||||
67
backend/api/vcs-server/[id]/update.put.ts
Normal file
67
backend/api/vcs-server/[id]/update.put.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
import { getCurrentUserId } from '../../../utils/user'
|
||||
|
||||
interface UpdateBody {
|
||||
serverName?: string
|
||||
serverType?: string
|
||||
serverUrl?: string
|
||||
description?: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* VCS 서버 수정
|
||||
* PUT /api/vcs-server/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const serverId = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody<UpdateBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
const existing = await queryOne('SELECT * FROM wr_vcs_server WHERE server_id = $1', [serverId])
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: 'VCS 서버를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (body.serverName !== undefined) {
|
||||
updates.push(`server_name = $${paramIndex++}`)
|
||||
values.push(body.serverName)
|
||||
}
|
||||
|
||||
if (body.serverType !== undefined) {
|
||||
updates.push(`server_type = $${paramIndex++}`)
|
||||
values.push(body.serverType)
|
||||
}
|
||||
|
||||
if (body.serverUrl !== undefined) {
|
||||
updates.push(`server_url = $${paramIndex++}`)
|
||||
values.push(body.serverUrl)
|
||||
}
|
||||
|
||||
if (body.description !== undefined) {
|
||||
updates.push(`description = $${paramIndex++}`)
|
||||
values.push(body.description)
|
||||
}
|
||||
|
||||
if (body.isActive !== undefined) {
|
||||
updates.push(`is_active = $${paramIndex++}`)
|
||||
values.push(body.isActive)
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return { success: true, message: '변경된 내용이 없습니다.' }
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`, `updated_by = $${paramIndex++}`)
|
||||
values.push(userId, serverId)
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_vcs_server SET ${updates.join(', ')} WHERE server_id = $${paramIndex}
|
||||
`, values)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
52
backend/api/vcs-server/create.post.ts
Normal file
52
backend/api/vcs-server/create.post.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { insertReturning } from '../../utils/db'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
|
||||
interface CreateBody {
|
||||
serverName: string
|
||||
serverType: string // git | svn
|
||||
serverUrl: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* VCS 서버 등록
|
||||
* POST /api/vcs-server/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CreateBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!body.serverName?.trim()) {
|
||||
throw createError({ statusCode: 400, message: '서버명을 입력해주세요.' })
|
||||
}
|
||||
|
||||
if (!body.serverType || !['git', 'svn'].includes(body.serverType)) {
|
||||
throw createError({ statusCode: 400, message: '서버 유형을 선택해주세요 (git/svn).' })
|
||||
}
|
||||
|
||||
if (!body.serverUrl?.trim()) {
|
||||
throw createError({ statusCode: 400, message: '서버 URL을 입력해주세요.' })
|
||||
}
|
||||
|
||||
const result = await insertReturning(`
|
||||
INSERT INTO wr_vcs_server (server_name, server_type, server_url, description, is_active, created_by)
|
||||
VALUES ($1, $2, $3, $4, true, $5)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.serverName.trim(),
|
||||
body.serverType,
|
||||
body.serverUrl.trim(),
|
||||
body.description || null,
|
||||
userId
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
server: {
|
||||
serverId: result.server_id,
|
||||
serverName: result.server_name,
|
||||
serverType: result.server_type,
|
||||
serverUrl: result.server_url
|
||||
}
|
||||
}
|
||||
})
|
||||
37
backend/api/vcs-server/list.get.ts
Normal file
37
backend/api/vcs-server/list.get.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* VCS 서버 목록 조회
|
||||
* GET /api/vcs-server/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
const includeInactive = params.includeInactive === 'true'
|
||||
|
||||
const conditions = includeInactive ? [] : ['is_active = true']
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
const servers = await query(`
|
||||
SELECT
|
||||
s.*,
|
||||
e.employee_name as created_by_name
|
||||
FROM wr_vcs_server s
|
||||
LEFT JOIN wr_employee_info e ON s.created_by = e.employee_id
|
||||
${whereClause}
|
||||
ORDER BY s.server_name
|
||||
`, [])
|
||||
|
||||
return {
|
||||
servers: servers.map((s: any) => ({
|
||||
serverId: s.server_id,
|
||||
serverName: s.server_name,
|
||||
serverType: s.server_type,
|
||||
serverUrl: s.server_url,
|
||||
description: s.description,
|
||||
isActive: s.is_active,
|
||||
createdAt: s.created_at,
|
||||
updatedAt: s.updated_at,
|
||||
createdByName: s.created_by_name
|
||||
}))
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user