import type { NextFunction, Request, Response } from 'express'; import { Router } from 'express'; import multer from 'multer'; import ExcelJS from 'exceljs'; import { ok } from '../http/apiResponse.js'; import { ApiError } from '../http/errors.js'; import { query } from '../db/pool.js'; import { parseRole, parseRoles, requireAdmin, requireCurrentUser, type QueryFn, type UserRow } from './security.js'; type Row = Record; interface TeamRow extends Row { id: string; code: string; name: string; active: boolean; default_roles: string[]; } interface PurposeRow extends Row { id: string; code: string; name: string; sort_order: number; active: boolean; custom_allowed: boolean; } interface AdminUserRow extends Row { id: string; username: string; full_name: string; department: string | null; team_id: string | null; team_code: string | null; team_name: string | null; email: string | null; enabled: boolean; locked: boolean; roles: string[]; } interface ImportRow { rowNumber: number; status: 'CREATE' | 'UPDATE' | 'ERROR'; key?: string; summary?: string; errors: string[]; warnings: string[]; } interface ImportResult { totalRows: number; createCount: number; updateCount: number; errorCount: number; warningCount: number; applied: boolean; success: boolean; rows: ImportRow[]; } interface AdminRouterDeps { query: QueryFn; } const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 }, }); function asyncRoute(handler: (req: Request, res: Response, next: NextFunction) => Promise) { return (req: Request, res: Response, next: NextFunction) => { handler(req, res, next).catch(next); }; } function toTeam(row: TeamRow) { return { id: Number(row.id), code: row.code, name: row.name, active: row.active, defaultRoles: row.default_roles ?? [], }; } function toPurpose(row: PurposeRow) { return { id: Number(row.id), code: row.code, name: row.name, sortOrder: row.sort_order, active: row.active, customAllowed: row.custom_allowed, }; } function toAdminUser(row: AdminUserRow) { return { id: Number(row.id), username: row.username, fullName: row.full_name, department: row.department ?? undefined, teamId: row.team_id ? Number(row.team_id) : undefined, teamCode: row.team_code ?? undefined, teamName: row.team_name ?? undefined, email: row.email ?? undefined, enabled: row.enabled, locked: row.locked, roles: row.roles ?? [], }; } async function audit(dbQuery: QueryFn, actor: UserRow, targetType: string, targetId: number | null, detail: string) { await dbQuery( ` INSERT INTO audit_logs (created_at, updated_at, actor_id, actor_username, action, target_type, target_id, detail) VALUES (now(), now(), $1, $2, 'ADMIN_CONFIG_UPDATE', $3, $4, $5) `, [Number(actor.id), actor.username, targetType, targetId, detail], ); } async function listTeams(dbQuery: QueryFn) { const result = await dbQuery( ` SELECT t.id, t.code, t.name, t.active, COALESCE(array_agg(tdr.role ORDER BY tdr.role) FILTER (WHERE tdr.role IS NOT NULL), '{}') AS default_roles FROM teams t LEFT JOIN team_default_roles tdr ON tdr.team_id = t.id GROUP BY t.id ORDER BY t.name ASC `, ); return result.rows.map(toTeam); } async function listUsers(dbQuery: QueryFn) { const result = await dbQuery( ` SELECT u.id, u.username, u.full_name, u.department, u.team_id, t.code AS team_code, t.name AS team_name, u.email, u.enabled, u.locked, COALESCE(array_agg(ur.role ORDER BY ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles FROM users u LEFT JOIN teams t ON t.id = u.team_id LEFT JOIN user_roles ur ON ur.user_id = u.id GROUP BY u.id, t.id ORDER BY u.username ASC `, ); return result.rows.map(toAdminUser); } async function setTeamRoles(dbQuery: QueryFn, teamId: number, roles: string[]) { await dbQuery('DELETE FROM team_default_roles WHERE team_id = $1', [teamId]); for (const role of roles) { await dbQuery('INSERT INTO team_default_roles (team_id, role) VALUES ($1, $2)', [teamId, role]); } } async function setUserRoles(dbQuery: QueryFn, userId: number, roles: string[]) { await dbQuery('DELETE FROM user_roles WHERE user_id = $1', [userId]); for (const role of roles) { await dbQuery('INSERT INTO user_roles (user_id, role) VALUES ($1, $2)', [userId, role]); } } async function findTeamById(dbQuery: QueryFn, id: number): Promise { const result = await dbQuery( ` SELECT t.id, t.code, t.name, t.active, COALESCE(array_agg(tdr.role ORDER BY tdr.role) FILTER (WHERE tdr.role IS NOT NULL), '{}') AS default_roles FROM teams t LEFT JOIN team_default_roles tdr ON tdr.team_id = t.id WHERE t.id = $1 GROUP BY t.id `, [id], ); return result.rows[0]; } async function findUserRowById(dbQuery: QueryFn, id: number): Promise { const result = await dbQuery( ` SELECT u.id, u.username, u.full_name, u.department, u.team_id, t.code AS team_code, t.name AS team_name, u.email, u.enabled, u.locked, COALESCE(array_agg(ur.role ORDER BY ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles FROM users u LEFT JOIN teams t ON t.id = u.team_id LEFT JOIN user_roles ur ON ur.user_id = u.id WHERE u.id = $1 GROUP BY u.id, t.id `, [id], ); return result.rows[0]; } function requiredString(value: unknown, label: string): string { const text = String(value ?? '').trim(); if (!text) { throw new ApiError(400, `${label}은(는) 필수입니다.`); } return text; } function roleDisplay(roles: string[]) { return `[${roles.join(', ')}]`; } async function workbookFromBuffer(file?: Express.Multer.File): Promise { if (!file) { throw new ApiError(400, '업로드할 파일을 선택하세요.'); } const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ExcelJS.Buffer); return workbook; } function cellText(row: ExcelJS.Row, index: number): string { const value = row.getCell(index).value; if (value == null) return ''; if (typeof value === 'object' && 'text' in value) return String(value.text ?? '').trim(); if (typeof value === 'object' && 'result' in value) return String(value.result ?? '').trim(); return String(value).trim(); } function splitRoles(raw: string, errors: string[]): string[] { const roles: string[] = []; for (const token of raw.split(/[,;/\s]+/)) { if (!token.trim()) continue; try { roles.push(parseRole(token)); } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); } } return [...new Set(roles)]; } async function teamImportResult(dbQuery: QueryFn, file?: Express.Multer.File): Promise<{ result: ImportResult; rows: Array<{ rowNumber: number; code: string; name: string; roles: string[]; errors: string[] }>; }> { const workbook = await workbookFromBuffer(file); const sheet = workbook.worksheets[0]; const parsed: Array<{ rowNumber: number; code: string; name: string; roles: string[]; errors: string[] }> = []; const seen = new Set(); sheet.eachRow((row, rowNumber) => { if (rowNumber === 1) return; const code = cellText(row, 1).toUpperCase(); const name = cellText(row, 2); const errors: string[] = []; const roles = splitRoles(cellText(row, 3), errors); if (!code && !name && roles.length === 0) return; if (!code) errors.push('팀 코드은(는) 필수입니다.'); if (!name) errors.push('팀명은(는) 필수입니다.'); if (roles.length === 0) errors.push('기본권한은 최소 1개 이상 필요합니다.'); if (seen.has(code)) errors.push('파일 안에 같은 팀 코드가 중복되었습니다.'); seen.add(code); parsed.push({ rowNumber, code, name, roles, errors }); }); const result: ImportResult = { totalRows: parsed.length, createCount: 0, updateCount: 0, errorCount: 0, warningCount: 0, applied: false, success: true, rows: [], }; for (const row of parsed) { const codeResult = row.code ? await dbQuery<{ id: string }>('SELECT id FROM teams WHERE upper(code) = upper($1)', [row.code]) : { rows: [] }; const nameResult = row.name ? await dbQuery<{ code: string }>('SELECT code FROM teams WHERE name = $1 AND upper(code) <> upper($2)', [row.name, row.code]) : { rows: [] }; if (nameResult.rows[0]) { row.errors.push(`이미 다른 팀 코드에서 사용하는 팀명입니다: ${nameResult.rows[0].code}`); } const exists = codeResult.rows.length > 0; if (row.errors.length === 0) { if (exists) result.updateCount += 1; else result.createCount += 1; } result.rows.push({ rowNumber: row.rowNumber, status: row.errors.length > 0 ? 'ERROR' : exists ? 'UPDATE' : 'CREATE', key: row.code, summary: `${row.name} / ${roleDisplay(row.roles)}`, errors: row.errors, warnings: [], }); } result.errorCount = result.rows.reduce((sum, row) => sum + row.errors.length, 0); result.success = result.errorCount === 0; if (!result.success) { result.createCount = 0; result.updateCount = 0; } return { result, rows: parsed }; } async function userRoleImportResult(dbQuery: QueryFn, file?: Express.Multer.File): Promise<{ result: ImportResult; rows: Array<{ rowNumber: number; username: string; fullName: string; teamName: string; roles: string[]; errors: string[] }>; }> { const workbook = await workbookFromBuffer(file); const sheet = workbook.worksheets[0]; const parsed: Array<{ rowNumber: number; username: string; fullName: string; teamName: string; roles: string[]; errors: string[] }> = []; const seen = new Set(); sheet.eachRow((row, rowNumber) => { if (rowNumber === 1) return; const username = cellText(row, 1); const fullName = cellText(row, 2); const teamName = cellText(row, 3); const errors: string[] = []; const roles = splitRoles(cellText(row, 4), errors); if (!username && !fullName && !teamName && roles.length === 0) return; if (!username) errors.push('아이디은(는) 필수입니다.'); if (!fullName) errors.push('이름은(는) 필수입니다.'); if (!teamName) errors.push('팀명은(는) 필수입니다.'); if (roles.length === 0) errors.push('권한은 최소 1개 이상 필요합니다.'); if (seen.has(username.toLowerCase())) errors.push('파일 안에 같은 아이디가 중복되었습니다.'); seen.add(username.toLowerCase()); parsed.push({ rowNumber, username, fullName, teamName, roles, errors }); }); const result: ImportResult = { totalRows: parsed.length, createCount: 0, updateCount: 0, errorCount: 0, warningCount: 0, applied: false, success: true, rows: [], }; for (const row of parsed) { const userResult = row.username ? await dbQuery<{ id: string; full_name: string }>('SELECT id, full_name FROM users WHERE username = $1', [row.username]) : { rows: [] }; const teamResult = row.teamName ? await dbQuery<{ id: string; active: boolean }>('SELECT id, active FROM teams WHERE name = $1', [row.teamName]) : { rows: [] }; const user = userResult.rows[0]; const team = teamResult.rows[0]; if (!user) row.errors.push('존재하지 않는 아이디입니다.'); else if (user.full_name !== row.fullName) row.errors.push(`등록된 이름과 일치하지 않습니다: ${user.full_name}`); if (!team) row.errors.push('존재하지 않는 팀명입니다.'); else if (!team.active) row.errors.push('사용 중지된 팀입니다.'); if (row.errors.length === 0) result.updateCount += 1; result.rows.push({ rowNumber: row.rowNumber, status: row.errors.length > 0 ? 'ERROR' : 'UPDATE', key: row.username, summary: `${row.fullName} / ${row.teamName} / ${roleDisplay(row.roles)}`, errors: row.errors, warnings: [], }); } result.errorCount = result.rows.reduce((sum, row) => sum + row.errors.length, 0); result.success = result.errorCount === 0; if (!result.success) result.updateCount = 0; return { result, rows: parsed }; } async function sendTemplate(res: Response, filename: string, headers: string[], samples: string[][]) { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('template'); sheet.addRow(headers); for (const sample of samples) sheet.addRow(sample); sheet.getRow(1).font = { bold: true }; sheet.columns.forEach((column) => { column.width = 18; }); const buffer = await workbook.xlsx.writeBuffer(); res .status(200) .setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') .setHeader('Content-Disposition', `attachment; filename="${filename}"`) .send(Buffer.from(buffer)); } export function createAdminRouter(deps: AdminRouterDeps = { query }): Router { const router = Router(); const dbQuery = deps.query; router.get('/purpose-codes', asyncRoute(async (req, res) => { await requireAdmin(dbQuery, req); const result = await dbQuery('SELECT * FROM purpose_codes ORDER BY sort_order ASC, name ASC'); ok(res, result.rows.map(toPurpose)); })); router.post('/purpose-codes', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const code = requiredString(req.body?.code, '코드').toUpperCase(); const name = requiredString(req.body?.name, '표시명'); const sortOrder = Number(req.body?.sortOrder ?? 100); const active = Boolean(req.body?.active); const customAllowed = Boolean(req.body?.customAllowed); const result = await dbQuery( ` INSERT INTO purpose_codes (created_at, updated_at, code, name, sort_order, active, custom_allowed) VALUES (now(), now(), $1, $2, $3, $4, $5) RETURNING * `, [code, name, sortOrder, active, customAllowed], ); await audit(dbQuery, actor, 'PURPOSE_CODE', Number(result.rows[0].id), `출입목적 코드 추가: ${code}/${name}`); ok(res, toPurpose(result.rows[0])); })); router.put('/purpose-codes/:id', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const id = Number(req.params.id); const code = requiredString(req.body?.code, '코드').toUpperCase(); const name = requiredString(req.body?.name, '표시명'); const result = await dbQuery( ` UPDATE purpose_codes SET code = $1, name = $2, sort_order = $3, active = $4, custom_allowed = $5, updated_at = now() WHERE id = $6 RETURNING * `, [code, name, Number(req.body?.sortOrder ?? 100), Boolean(req.body?.active), Boolean(req.body?.customAllowed), id], ); if (!result.rows[0]) throw new ApiError(404, '출입목적 코드를 찾을 수 없습니다.'); await audit(dbQuery, actor, 'PURPOSE_CODE', id, `출입목적 코드 수정: ${code}/${name}`); ok(res, toPurpose(result.rows[0])); })); router.get('/settings/watcher1', asyncRoute(async (req, res) => { await requireAdmin(dbQuery, req); ok(res, await watcher1(dbQuery)); })); router.put('/settings/watcher1', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); await setSetting(dbQuery, 'watcher1.name', requiredString(req.body?.name, '이름')); await setSetting(dbQuery, 'watcher1.team', requiredString(req.body?.team, '소속')); await setSetting(dbQuery, 'watcher1.contact', requiredString(req.body?.contact, '연락처')); await audit(dbQuery, actor, 'SYSTEM_SETTING', null, `현장감시자1 설정 수정: ${req.body.name}/${req.body.team}`); ok(res, await watcher1(dbQuery)); })); router.get('/teams', asyncRoute(async (req, res) => { await requireAdmin(dbQuery, req); ok(res, await listTeams(dbQuery)); })); router.post('/teams', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const code = requiredString(req.body?.code, '팀 코드').toUpperCase(); const name = requiredString(req.body?.name, '팀명'); const roles = parseRoles(req.body?.defaultRoles); const result = await dbQuery( ` INSERT INTO teams (created_at, updated_at, code, name, active) VALUES (now(), now(), $1, $2, $3) RETURNING id, code, name, active, '{}'::varchar[] AS default_roles `, [code, name, Boolean(req.body?.active)], ); await setTeamRoles(dbQuery, Number(result.rows[0].id), roles); await audit(dbQuery, actor, 'TEAM', Number(result.rows[0].id), `팀 추가: ${code}/${name}`); ok(res, toTeam((await findTeamById(dbQuery, Number(result.rows[0].id)))!)); })); router.put('/teams/:id', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const id = Number(req.params.id); const code = requiredString(req.body?.code, '팀 코드').toUpperCase(); const name = requiredString(req.body?.name, '팀명'); const roles = parseRoles(req.body?.defaultRoles); const old = await findTeamById(dbQuery, id); if (!old) throw new ApiError(404, '팀을 찾을 수 없습니다.'); const result = await dbQuery( ` UPDATE teams SET code = $1, name = $2, active = $3, updated_at = now() WHERE id = $4 RETURNING id, code, name, active, '{}'::varchar[] AS default_roles `, [code, name, Boolean(req.body?.active), id], ); await setTeamRoles(dbQuery, id, roles); if (old.name !== name) { await dbQuery('UPDATE users SET department = $1, updated_at = now() WHERE team_id = $2', [name, id]); } await audit(dbQuery, actor, 'TEAM', id, `팀 수정: ${code}/${name}`); ok(res, toTeam((await findTeamById(dbQuery, Number(result.rows[0].id)))!)); })); router.post('/teams/:id/apply-default-roles', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const id = Number(req.params.id); const team = await findTeamById(dbQuery, id); if (!team) throw new ApiError(404, '팀을 찾을 수 없습니다.'); const roles = team.default_roles ?? []; if (roles.length === 0) throw new ApiError(400, '팀 기본권한이 비어 있습니다.'); const users = await dbQuery<{ id: string }>('SELECT id FROM users WHERE team_id = $1 ORDER BY username', [id]); for (const user of users.rows) { await setUserRoles(dbQuery, Number(user.id), roles); } await audit(dbQuery, actor, 'TEAM', id, `팀원 전체 기본권한 적용: ${team.name} (${users.rows.length}명)`); ok(res, `${users.rows.length}명의 팀원에게 기본권한을 적용했습니다.`); })); router.get('/teams/template', asyncRoute(async (req, res) => { await requireAdmin(dbQuery, req); await sendTemplate(res, 'admin-teams-template.xlsx', ['팀 코드', '팀명', '기본권한'], [ ['DEV2', '개발2팀', 'HOST'], ['SEC', '보안팀', 'SECURITY'], ]); })); router.post('/teams/upload', upload.single('file'), asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const dryRun = String(req.query.dryRun ?? 'true') !== 'false'; const { result, rows } = await teamImportResult(dbQuery, req.file); if (!dryRun && result.success) { for (const row of rows) { const existing = await dbQuery<{ id: string }>('SELECT id FROM teams WHERE upper(code) = upper($1)', [row.code]); let teamId: number; if (existing.rows[0]) { teamId = Number(existing.rows[0].id); await dbQuery('UPDATE teams SET name = $1, updated_at = now() WHERE id = $2', [row.name, teamId]); } else { const inserted = await dbQuery<{ id: string }>( 'INSERT INTO teams (created_at, updated_at, code, name, active) VALUES (now(), now(), $1, $2, TRUE) RETURNING id', [row.code, row.name], ); teamId = Number(inserted.rows[0].id); } await setTeamRoles(dbQuery, teamId, row.roles); } result.applied = true; await audit(dbQuery, actor, 'TEAM', null, `팀 엑셀 업로드: 신규 ${result.createCount}건, 수정 ${result.updateCount}건`); } ok(res, result); })); router.get('/users', asyncRoute(async (req, res) => { await requireAdmin(dbQuery, req); ok(res, await listUsers(dbQuery)); })); router.put('/users/:id/team', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const id = Number(req.params.id); const teamId = Number(req.body?.teamId); const team = await findTeamById(dbQuery, teamId); if (!team) throw new ApiError(404, '팀을 찾을 수 없습니다.'); await dbQuery('UPDATE users SET team_id = $1, department = $2, updated_at = now() WHERE id = $3', [teamId, team.name, id]); if (Boolean(req.body?.applyDefaultRoles)) await setUserRoles(dbQuery, id, team.default_roles ?? []); await audit(dbQuery, actor, 'USER', id, `팀 수정: ${id} -> ${team.name}`); ok(res, toAdminUser((await findUserRowById(dbQuery, id))!)); })); router.put('/users/:id/roles', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const id = Number(req.params.id); const roles = parseRoles(req.body?.roles); await setUserRoles(dbQuery, id, roles); await audit(dbQuery, actor, 'USER', id, `권한 수정: ${id} -> ${roles.join(',')}`); ok(res, toAdminUser((await findUserRowById(dbQuery, id))!)); })); router.post('/users/:id/apply-team-default-roles', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const user = await findUserRowById(dbQuery, Number(req.params.id)); if (!user || !user.team_id) throw new ApiError(400, '사용자에게 팀이 지정되어 있지 않습니다.'); const team = await findTeamById(dbQuery, Number(user.team_id)); if (!team) throw new ApiError(404, '팀을 찾을 수 없습니다.'); await setUserRoles(dbQuery, Number(user.id), team.default_roles ?? []); await audit(dbQuery, actor, 'USER', Number(user.id), `팀 기본권한 적용: ${user.username} -> ${team.name}`); ok(res, toAdminUser((await findUserRowById(dbQuery, Number(user.id)))!)); })); router.get('/users/roles/template', asyncRoute(async (req, res) => { await requireAdmin(dbQuery, req); await sendTemplate(res, 'admin-user-roles-template.xlsx', ['아이디', '이름', '팀명', '권한'], [ ['h', '홍길동', '개발1팀', 'HOST'], ['s', '보안담당', '보안팀', 'SECURITY'], ]); })); router.post('/users/roles/upload', upload.single('file'), asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const dryRun = String(req.query.dryRun ?? 'true') !== 'false'; const { result, rows } = await userRoleImportResult(dbQuery, req.file); if (!dryRun && result.success) { for (const row of rows) { const user = await dbQuery<{ id: string }>('SELECT id FROM users WHERE username = $1', [row.username]); const team = await dbQuery<{ id: string }>('SELECT id FROM teams WHERE name = $1', [row.teamName]); await dbQuery('UPDATE users SET team_id = $1, department = $2, updated_at = now() WHERE id = $3', [ Number(team.rows[0].id), row.teamName, Number(user.rows[0].id), ]); await setUserRoles(dbQuery, Number(user.rows[0].id), row.roles); } result.applied = true; await audit(dbQuery, actor, 'USER', null, `권한 엑셀 업로드: 수정 ${result.updateCount}건`); } ok(res, result); })); return router; } async function setting(dbQuery: QueryFn, key: string, fallback: string): Promise { const result = await dbQuery<{ setting_value: string | null }>('SELECT setting_value FROM system_settings WHERE setting_key = $1', [key]); return result.rows[0]?.setting_value || fallback; } async function setSetting(dbQuery: QueryFn, key: string, value: string) { await dbQuery( ` INSERT INTO system_settings (created_at, updated_at, setting_key, setting_value) VALUES (now(), now(), $1, $2) ON CONFLICT (setting_key) DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = now() `, [key, value], ); } export async function watcher1(dbQuery: QueryFn) { return { name: await setting(dbQuery, 'watcher1.name', '류관순'), team: await setting(dbQuery, 'watcher1.team', 'IT전략국'), contact: await setting(dbQuery, 'watcher1.contact', '313'), }; } export function createConfigRouter(deps: AdminRouterDeps = { query }): Router { const router = Router(); const dbQuery = deps.query; router.get('/purpose-codes', asyncRoute(async (_req, res) => { const result = await dbQuery( 'SELECT * FROM purpose_codes WHERE active = TRUE ORDER BY sort_order ASC, name ASC', ); ok(res, result.rows.map(toPurpose)); })); router.get('/settings/watcher1', asyncRoute(async (req, res) => { await requireCurrentUser(dbQuery, req); ok(res, await watcher1(dbQuery)); })); return router; } export const adminRouter = createAdminRouter(); export const configRouter = createConfigRouter();