import type { NextFunction, Request, Response } from 'express'; import { Router } from 'express'; import multer from 'multer'; import ExcelJS from 'exceljs'; import QRCode from 'qrcode'; import { randomUUID } from 'node:crypto'; import { ok } from '../http/apiResponse.js'; import { ApiError } from '../http/errors.js'; import { env } from '../config/env.js'; import { query } from '../db/pool.js'; import { requireAdmin, requireCurrentUser, type QueryFn, type UserRow } from './security.js'; import { watcher1 } from './admin.js'; import { sendSms } from '../services/sms.js'; interface BusinessRouterDeps { query: QueryFn; } interface VisitRow { id: string; visitor_name: string; company: string | null; contact: string | null; email: string | null; vehicle_no: string | null; host_id: string; host_name: string; host_department: string | null; zone_name: string | null; purpose: string; purpose_code: string | null; purpose_detail: string | null; work_name: string | null; control_name: string | null; control_team: string | null; control_contact: string | null; watcher1_name: string | null; watcher1_team: string | null; watcher1_contact: string | null; watcher2_name: string | null; watcher2_team: string | null; watcher2_contact: string | null; visit_from: string | Date; visit_to: string | Date; status: string; qr_token: string | null; created_at: string | Date; } interface AccessRow { visit_request_id: string; visitor_name: string; company: string | null; zone_name: string | null; host_name: string; visit_from: string | Date; visit_to: string | Date; status: string; check_in_at: string | Date | null; check_out_at: string | Date | null; } const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 3 * 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 toIso(value: string | Date | null | undefined): string | undefined { if (value == null) return undefined; return new Date(value).toISOString(); } function toVisit(row: VisitRow) { return { id: Number(row.id), visitorName: row.visitor_name, company: row.company ?? undefined, contact: row.contact ?? undefined, email: row.email ?? undefined, vehicleNo: row.vehicle_no ?? undefined, hostId: Number(row.host_id), hostName: row.host_name, hostDepartment: row.host_department ?? undefined, zoneName: row.zone_name ?? undefined, purpose: row.purpose, purposeCode: row.purpose_code ?? undefined, purposeDetail: row.purpose_detail ?? undefined, workName: row.work_name ?? undefined, controlName: row.control_name ?? undefined, controlTeam: row.control_team ?? undefined, controlContact: row.control_contact ?? undefined, watcher1Name: row.watcher1_name ?? undefined, watcher1Team: row.watcher1_team ?? undefined, watcher1Contact: row.watcher1_contact ?? undefined, watcher2Name: row.watcher2_name ?? undefined, watcher2Team: row.watcher2_team ?? undefined, watcher2Contact: row.watcher2_contact ?? undefined, visitFrom: toIso(row.visit_from)!, visitTo: toIso(row.visit_to)!, status: row.status, qrToken: row.qr_token ?? undefined, createdAt: toIso(row.created_at)!, }; } function required(value: unknown, label: string): string { const text = String(value ?? '').trim(); if (!text) throw new ApiError(400, `${label}을(를) 입력하세요.`); return text; } function sameDate(a: Date, b: Date): boolean { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } async function visitById(dbQuery: QueryFn, id: number): Promise { const result = await dbQuery( ` SELECT vr.id, v.name AS visitor_name, v.company, v.contact, v.email, v.vehicle_no, h.id AS host_id, h.full_name AS host_name, h.department AS host_department, vr.zone_name, vr.purpose, vr.purpose_code, vr.purpose_detail, vr.work_name, vr.control_name, vr.control_team, vr.control_contact, vr.watcher1_name, vr.watcher1_team, vr.watcher1_contact, vr.watcher2_name, vr.watcher2_team, vr.watcher2_contact, vr.visit_from, vr.visit_to, vr.status, vr.qr_token, vr.created_at FROM visit_requests vr JOIN visitors v ON v.id = vr.visitor_id JOIN users h ON h.id = vr.host_id WHERE vr.id = $1 `, [id], ); return result.rows[0]; } async function listVisitRows(dbQuery: QueryFn, where = '', params: unknown[] = []) { const result = await dbQuery( ` SELECT vr.id, v.name AS visitor_name, v.company, v.contact, v.email, v.vehicle_no, h.id AS host_id, h.full_name AS host_name, h.department AS host_department, vr.zone_name, vr.purpose, vr.purpose_code, vr.purpose_detail, vr.work_name, vr.control_name, vr.control_team, vr.control_contact, vr.watcher1_name, vr.watcher1_team, vr.watcher1_contact, vr.watcher2_name, vr.watcher2_team, vr.watcher2_contact, vr.visit_from, vr.visit_to, vr.status, vr.qr_token, vr.created_at FROM visit_requests vr JOIN visitors v ON v.id = vr.visitor_id JOIN users h ON h.id = vr.host_id ${where} ORDER BY vr.created_at DESC `, params, ); return result.rows.map(toVisit); } async function audit(dbQuery: QueryFn, actor: UserRow, action: string, 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, $3, $4, $5, $6) `, [Number(actor.id), actor.username, action, targetType, targetId, detail], ); } function publicPassUrl(qrToken: string): string { return `${env.publicBaseUrl.replace(/\/$/, '')}/pass/${qrToken}`; } function passSmsText(visit: VisitRow): string { return [ '[IT센터 출입] 방문 신청이 승인되었습니다.', `방문자: ${visit.visitor_name}`, `출입일시: ${toIso(visit.visit_from)}`, `출입증(QR): ${publicPassUrl(visit.qr_token!)}`, ].join('\n'); } async function sendPassDelivery(dbQuery: QueryFn, visit: VisitRow) { if (!visit.qr_token) { return { channel: 'SMS', recipient: visit.contact ?? '', status: 'FAILED' as const, lastError: 'QR 토큰이 없습니다.' }; } const delivery = await sendSms({ to: visit.contact ?? '', text: passSmsText(visit), }); await dbQuery( ` INSERT INTO pass_deliveries (created_at, updated_at, visit_request_id, channel, recipient, status, attempts, last_error) VALUES (now(), now(), $1, $2, $3, $4, 1, $5) `, [Number(visit.id), delivery.channel, delivery.recipient, delivery.status, delivery.lastError], ); return delivery; } async function retryPassDelivery(dbQuery: QueryFn, deliveryId: number) { const existing = await dbQuery<{ id: string; visit_request_id: string; attempts: string }>( 'SELECT id, visit_request_id, attempts FROM pass_deliveries WHERE id = $1', [deliveryId], ); const row = existing.rows[0]; if (!row) throw new ApiError(404, '발송 기록을 찾을 수 없습니다.'); const visit = await visitById(dbQuery, Number(row.visit_request_id)); if (!visit) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.'); const delivery = await sendSms({ to: visit.contact ?? '', text: passSmsText(visit), }); const updated = await dbQuery( ` UPDATE pass_deliveries SET attempts = attempts + 1, channel = $2, recipient = $3, status = $4, last_error = $5, updated_at = now() WHERE id = $1 RETURNING * `, [deliveryId, delivery.channel, delivery.recipient, delivery.status, delivery.lastError], ); return updated.rows[0]; } function canSelfApprove(user: UserRow): boolean { return user.roles.includes('ADMIN'); } async function approveVisit(dbQuery: QueryFn, visitId: number, actor: UserRow, comment: string | null): Promise { await dbQuery("UPDATE visit_requests SET status = 'APPROVED', updated_at = now() WHERE id = $1", [visitId]); await dbQuery( "INSERT INTO approvals (created_at, updated_at, visit_request_id, approver_id, decision, comment, decided_at) VALUES (now(), now(), $1, $2, 'APPROVED', $3, now())", [visitId, Number(actor.id), comment], ); await audit(dbQuery, actor, 'APPROVE', 'VISIT_REQUEST', visitId, '승인'); const visit = (await visitById(dbQuery, visitId))!; await sendPassDelivery(dbQuery, visit); return visit; } async function resolvePurpose(dbQuery: QueryFn, code: string | undefined, purpose: string, detail?: string) { if (code) { const result = await dbQuery<{ code: string; name: string; custom_allowed: boolean; active: boolean }>( 'SELECT code, name, custom_allowed, active FROM purpose_codes WHERE upper(code) = upper($1)', [code], ); const item = result.rows[0]; if (!item || !item.active) throw new ApiError(400, `존재하지 않는 출입목적 코드입니다: ${code}`); if (!item.custom_allowed) return { code: item.code, name: item.name, detail: null, display: item.name }; const text = String(detail || purpose || '').trim(); if (!text || text === item.name) throw new ApiError(400, '기타 출입 목적을 입력하세요.'); const matched = await dbQuery<{ code: string; name: string }>( 'SELECT code, name FROM purpose_codes WHERE active = TRUE AND custom_allowed = FALSE AND (upper(code) = upper($1) OR name = $1)', [text], ); if (matched.rows[0]) return { code: matched.rows[0].code, name: matched.rows[0].name, detail: null, display: matched.rows[0].name }; return { code: item.code, name: text, detail: text, display: text }; } const text = required(purpose, '출입 목적'); const matched = await dbQuery<{ code: string; name: string }>( 'SELECT code, name FROM purpose_codes WHERE active = TRUE AND (upper(code) = upper($1) OR name = $1)', [text], ); if (matched.rows[0]) return { code: matched.rows[0].code, name: matched.rows[0].name, detail: null, display: matched.rows[0].name }; return { code: null, name: text, detail: null, display: text }; } function cellText(row: ExcelJS.Row, index: number): string { const value = row.getCell(index).value; if (value == null) return ''; if (value instanceof Date) return value.toISOString(); if (typeof value === 'object' && 'text' in value) return String(value.text ?? '').trim(); if (typeof value === 'object' && 'result' in value) return String(value.result ?? '').trim(); if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) { return value.richText.map((part) => part.text).join('').trim(); } return String(value).trim(); } function requiredCell(row: ExcelJS.Row, index: number, label: string): string { const value = cellText(row, index); if (!value) throw new ApiError(400, `${label}은(는) 필수입니다.`); return value; } function excelSerialToDate(serial: number): Date { return new Date(Math.round((serial - 25569) * 86400 * 1000)); } function pad2(value: number): string { return String(value).padStart(2, '0'); } function datePart(date: Date): string { return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`; } function parseVisitDate(row: ExcelJS.Row, index: number, label: string): string { const value = row.getCell(index).value; if (value instanceof Date) return datePart(value); if (typeof value === 'number') return datePart(excelSerialToDate(value)); const text = requiredCell(row, index, label); const normalized = text .split('(')[0] .trim() .replace(/[./]/g, '-') .replace(/\s+/g, '') .replace(/-+/g, '-') .replace(/-$/, ''); const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); if (!match) throw new ApiError(400, `${label} 형식이 올바르지 않습니다. 예: 2026-07-08 또는 2026.7.8`); return `${match[1]}-${pad2(Number(match[2]))}-${pad2(Number(match[3]))}`; } function secondsToTime(seconds: number): string { const normalized = ((Math.round(seconds) % 86400) + 86400) % 86400; const h = Math.floor(normalized / 3600); const m = Math.floor((normalized % 3600) / 60); const s = normalized % 60; return `${pad2(h)}:${pad2(m)}:${pad2(s)}`; } function parseVisitTime(row: ExcelJS.Row, index: number): string { const value = row.getCell(index).value; if (value instanceof Date) { return `${pad2(value.getUTCHours())}:${pad2(value.getUTCMinutes())}:${pad2(value.getUTCSeconds())}`; } if (typeof value === 'number') return secondsToTime((value - Math.floor(value)) * 86400); const text = cellText(row, index); if (!text) return '00:00:00'; const match = text.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); if (match) { const h = Number(match[1]); const m = Number(match[2]); const s = Number(match[3] ?? 0); if (h >= 0 && h <= 23 && m >= 0 && m <= 59 && s >= 0 && s <= 59) return `${pad2(h)}:${pad2(m)}:${pad2(s)}`; } const numeric = Number(text); if (Number.isFinite(numeric)) return secondsToTime((numeric - Math.floor(numeric)) * 86400); return '00:00:00'; } function parseRooms(raw: string): string[] { return raw.split(/[,;/]/).map((part) => part.trim()).filter(Boolean); } function isVisitUploadRowEmpty(row: ExcelJS.Row): boolean { return [2, 4, 5, 7, 9].every((index) => !cellText(row, index)); } async function workbookFromUpload(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; } async function importVisitWorkbook(dbQuery: QueryFn, actor: UserRow, file?: Express.Multer.File) { const workbook = await workbookFromUpload(file); const sheet = workbook.getWorksheet('방문자명단') ?? workbook.worksheets[0]; if (!sheet) throw new ApiError(400, '엑셀 시트를 찾을 수 없습니다.'); const result = { totalRows: 0, successCount: 0, errors: [] as string[], success: false }; for (let rowNumber = 3; rowNumber <= sheet.rowCount; rowNumber += 1) { const row = sheet.getRow(rowNumber); if (isVisitUploadRowEmpty(row)) continue; result.totalRows += 1; try { const date = parseVisitDate(row, 5, '출입일자'); const time = parseVisitTime(row, 6); const rooms = parseRooms(requiredCell(row, 4, '장소')); if (rooms.length === 0) throw new ApiError(400, '장소는 필수입니다.'); for (const room of rooms) { await createOneVisit(dbQuery, actor, { purpose: requiredCell(row, 2, '출입목적'), workName: cellText(row, 3) || undefined, visitorName: requiredCell(row, 7, '이름'), company: cellText(row, 8) || undefined, contact: requiredCell(row, 9, '연락처'), vehicleNo: cellText(row, 10) || undefined, controlName: cellText(row, 11) || undefined, controlTeam: cellText(row, 12) || undefined, controlContact: cellText(row, 13) || undefined, watcher1Name: cellText(row, 14) || undefined, watcher1Team: cellText(row, 15) || undefined, watcher1Contact: cellText(row, 16) || undefined, watcher2Name: cellText(row, 17) || undefined, watcher2Team: cellText(row, 18) || undefined, watcher2Contact: cellText(row, 19) || undefined, visitFrom: `${date}T${time}`, visitTo: `${date}T23:59:59`, }, room); } result.successCount += 1; } catch (error) { const message = error instanceof Error ? error.message : String(error); result.errors.push(`${rowNumber}행: ${message}`); } } result.success = result.errors.length === 0; return result; } async function createOneVisit(dbQuery: QueryFn, actor: UserRow, body: Record, zoneName: string) { const visitorName = required(body.visitorName, '방문자 이름'); const contact = required(body.contact, '방문자 연락처'); const visitFrom = new Date(required(body.visitFrom, '출입 일시')); const visitTo = new Date(required(body.visitTo, '퇴실 일시')); if (visitFrom > visitTo) throw new ApiError(400, '퇴장일시는 출입일시 이후로 입력해야 합니다.'); if (!sameDate(visitFrom, visitTo)) { throw new ApiError(400, '퇴장일이 출입일 다음날 이후가 되는 경우는 2건으로 신청해야 합니다'); } const purpose = await resolvePurpose( dbQuery, body.purposeCode ? String(body.purposeCode) : undefined, String(body.purpose ?? ''), body.purposeDetail ? String(body.purposeDetail) : undefined, ); const watcher = await watcher1(dbQuery); const visitor = await dbQuery<{ id: string }>( ` INSERT INTO visitors (created_at, updated_at, name, company, contact, email, vehicle_no) VALUES (now(), now(), $1, $2, $3, $4, $5) RETURNING id `, [ visitorName, body.company ? String(body.company).trim() : null, contact, body.email ? String(body.email).trim() : null, body.vehicleNo ? String(body.vehicleNo).trim() : null, ], ); const qrToken = randomUUID().replaceAll('-', ''); const created = await dbQuery<{ id: string }>( ` INSERT INTO visit_requests ( created_at, updated_at, visitor_id, host_id, zone_name, purpose, purpose_code, purpose_detail, work_name, visit_from, visit_to, status, qr_token, control_name, control_team, control_contact, watcher1_name, watcher1_team, watcher1_contact, watcher2_name, watcher2_team, watcher2_contact ) VALUES ( now(), now(), $1, $2, $3, $4, $5, $6, $7, $8, $9, 'PENDING', $10, $11, $12, $13, $14, $15, $16, $17, $18, $19 ) RETURNING id `, [ Number(visitor.rows[0].id), Number(actor.id), zoneName, purpose.display, purpose.code, purpose.detail, body.workName ? String(body.workName).trim() : null, visitFrom, visitTo, qrToken, body.controlName ? String(body.controlName).trim() : actor.full_name, body.controlTeam ? String(body.controlTeam).trim() : actor.department, body.controlContact ? String(body.controlContact).trim() : null, body.watcher1Name ? String(body.watcher1Name).trim() : watcher.name, body.watcher1Team ? String(body.watcher1Team).trim() : watcher.team, body.watcher1Contact ? String(body.watcher1Contact).trim() : watcher.contact, body.watcher2Name ? String(body.watcher2Name).trim() : null, body.watcher2Team ? String(body.watcher2Team).trim() : null, body.watcher2Contact ? String(body.watcher2Contact).trim() : null, ], ); return toVisit((await visitById(dbQuery, Number(created.rows[0].id)))!); } export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Router { const router = Router(); const dbQuery = deps.query; router.get('/zones', asyncRoute(async (req, res) => { await requireCurrentUser(dbQuery, req); const result = await dbQuery('SELECT id, code, name, security_level FROM zones WHERE active = TRUE ORDER BY id'); ok(res, result.rows.map((row) => ({ id: Number(row.id), code: row.code, name: row.name, securityLevel: row.security_level, }))); })); router.get('/visit-requests', asyncRoute(async (req, res) => { await requireCurrentUser(dbQuery, req); ok(res, await listVisitRows(dbQuery)); })); router.get('/visit-requests/pending', asyncRoute(async (req, res) => { await requireCurrentUser(dbQuery, req); ok(res, await listVisitRows(dbQuery, "WHERE vr.status = 'PENDING'")); })); router.get('/visit-requests/:id', asyncRoute(async (req, res) => { await requireCurrentUser(dbQuery, req); const visit = await visitById(dbQuery, Number(req.params.id)); if (!visit) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.'); ok(res, toVisit(visit)); })); router.post('/visit-requests', asyncRoute(async (req, res) => { const actor = await requireCurrentUser(dbQuery, req); const rooms = Array.isArray(req.body?.serverRooms) ? req.body.serverRooms.map(String).filter(Boolean) : []; const roomZone = req.body?.roomZone ? String(req.body.roomZone).trim() : ''; const zones = rooms.length > 0 ? rooms : roomZone ? [roomZone] : []; if (zones.length === 0) throw new ApiError(400, '출입 구역을 선택하세요.'); const created = []; for (const zone of zones) { const visit = await createOneVisit(dbQuery, actor, req.body, zone); if (canSelfApprove(actor)) { created.push(toVisit(await approveVisit(dbQuery, visit.id, actor, '본인 등록 자동승인'))); } else { created.push(visit); } } ok(res, created); })); router.post('/visit-requests/:id/cancel', asyncRoute(async (req, res) => { const actor = await requireCurrentUser(dbQuery, req); await dbQuery("UPDATE visit_requests SET status = 'CANCELLED', updated_at = now() WHERE id = $1 AND status IN ('PENDING', 'APPROVED')", [Number(req.params.id)]); await audit(dbQuery, actor, 'REJECT', 'VISIT_REQUEST', Number(req.params.id), '신청 취소'); ok(res, toVisit((await visitById(dbQuery, Number(req.params.id)))!)); })); router.post('/visit-requests/upload', upload.single('file'), asyncRoute(async (req, res) => { const actor = await requireCurrentUser(dbQuery, req); ok(res, await importVisitWorkbook(dbQuery, actor, req.file)); })); router.post('/approvals/:id/approve', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const id = Number(req.params.id); const visit = await approveVisit(dbQuery, id, actor, req.body?.comment ? String(req.body.comment) : null); ok(res, toVisit(visit)); })); router.post('/approvals/:id/reject', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const id = Number(req.params.id); await dbQuery("UPDATE visit_requests SET status = 'REJECTED', updated_at = now() WHERE id = $1", [id]); await dbQuery( "INSERT INTO approvals (created_at, updated_at, visit_request_id, approver_id, decision, comment, decided_at) VALUES (now(), now(), $1, $2, 'REJECTED', $3, now())", [id, Number(actor.id), req.body?.comment ? String(req.body.comment) : null], ); await audit(dbQuery, actor, 'REJECT', 'VISIT_REQUEST', id, '반려'); ok(res, toVisit((await visitById(dbQuery, id))!)); })); router.get('/passes/:id', asyncRoute(async (req, res) => { await requireCurrentUser(dbQuery, req); const visit = await visitById(dbQuery, Number(req.params.id)); if (!visit) throw new ApiError(404, '출입증을 찾을 수 없습니다.'); ok(res, toVisit(visit)); })); router.get('/passes/:id/qr.png', asyncRoute(async (req, res) => { await requireCurrentUser(dbQuery, req); const visit = await visitById(dbQuery, Number(req.params.id)); if (!visit?.qr_token) throw new ApiError(404, 'QR을 찾을 수 없습니다.'); const png = await QRCode.toBuffer(`${env.publicBaseUrl}/pass/${visit.qr_token}`, { type: 'png', width: 220 }); res.type('png').send(png); })); router.get('/public/passes/:token', asyncRoute(async (req, res) => { const result = await dbQuery( ` SELECT vr.*, v.name AS visitor_name, v.company, v.contact, v.email, v.vehicle_no, h.id AS host_id, h.full_name AS host_name, h.department AS host_department, EXISTS ( SELECT 1 FROM access_events ae WHERE ae.visit_request_id = vr.id AND ae.direction = 'IN' AND NOT EXISTS ( SELECT 1 FROM access_events out_ae WHERE out_ae.visit_request_id = vr.id AND out_ae.direction = 'OUT' AND out_ae.event_at > ae.event_at ) ) AS inside, EXISTS ( SELECT 1 FROM access_events ae WHERE ae.visit_request_id = vr.id AND ae.direction = 'OUT' AND ae.event_at::date = now()::date ) AS completed_today FROM visit_requests vr JOIN visitors v ON v.id = vr.visitor_id JOIN users h ON h.id = vr.host_id WHERE vr.qr_token = $1 `, [req.params.token], ); const row = result.rows[0]; if (!row) throw new ApiError(404, '출입증을 찾을 수 없습니다.'); ok(res, { visitorName: row.visitor_name, company: row.company ?? undefined, zoneName: row.zone_name ?? undefined, visitFrom: toIso(row.visit_from)!, visitTo: toIso(row.visit_to)!, inside: row.inside, completedToday: row.completed_today, }); })); router.get('/public/passes/:token/qr.png', asyncRoute(async (req, res) => { const png = await QRCode.toBuffer(`${env.publicBaseUrl}/pass/${req.params.token}`, { type: 'png', width: 220 }); res.type('png').send(png); })); async function accessAction(req: Request, res: Response, direction: 'IN' | 'OUT') { const actor = req.path.startsWith('/public/') ? null : await requireCurrentUser(dbQuery, req); const token = req.params.token || req.body?.qrToken; let id = req.body?.visitRequestId ? Number(req.body.visitRequestId) : undefined; if (!id && token) { const found = await dbQuery<{ id: string }>('SELECT id FROM visit_requests WHERE qr_token = $1', [String(token)]); id = found.rows[0] ? Number(found.rows[0].id) : undefined; } if (!id) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.'); const visit = await visitById(dbQuery, id); if (!visit) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.'); if (visit.status !== 'APPROVED') throw new ApiError(400, '승인된 방문 신청이 아닙니다.'); const today = new Date(); if (!sameDate(today, new Date(visit.visit_from))) { await dbQuery("UPDATE visit_requests SET status = 'EXPIRED', updated_at = now() WHERE id = $1", [id]); throw new ApiError(400, 'QR 유효기간이 지났습니다.'); } await dbQuery( 'INSERT INTO access_events (created_at, updated_at, visit_request_id, direction, gate_id, operator_id, event_at) VALUES (now(), now(), $1, $2, $3, $4, now())', [id, direction, req.body?.gateId ? String(req.body.gateId) : null, actor ? Number(actor.id) : null], ); if (direction === 'OUT') { await dbQuery( "UPDATE visitors SET name='***', contact='***', email='***', vehicle_no='***', updated_at = now() WHERE id = (SELECT visitor_id FROM visit_requests WHERE id = $1)", [id], ); } ok(res, { visitRequestId: id, visitorName: visit.visitor_name, direction, eventAt: new Date().toISOString(), gateOpened: true, message: direction === 'IN' ? '입장 처리되었습니다.' : '퇴장 처리되었습니다.', }); } router.post('/access/check-in', asyncRoute(async (req, res) => accessAction(req, res, 'IN'))); router.post('/access/check-out', asyncRoute(async (req, res) => accessAction(req, res, 'OUT'))); router.post('/public/passes/:token/check-in', asyncRoute(async (req, res) => accessAction(req, res, 'IN'))); router.post('/public/passes/:token/check-out', asyncRoute(async (req, res) => accessAction(req, res, 'OUT'))); router.get('/access/search', asyncRoute(async (req, res) => { await requireCurrentUser(dbQuery, req); const q = `%${String(req.query.q ?? '').trim()}%`; ok(res, await listVisitRows(dbQuery, "WHERE vr.status = 'APPROVED' AND (v.name ILIKE $1 OR v.company ILIKE $1 OR vr.zone_name ILIKE $1)", [q])); })); router.get('/access/inside', asyncRoute(async (req, res) => { await requireCurrentUser(dbQuery, req); const result = await dbQuery( ` SELECT DISTINCT ON (vr.id) vr.id AS visit_request_id, v.name AS visitor_name, v.company, vr.zone_name, h.full_name AS host_name, vr.visit_from, vr.visit_to, ae.event_at AS check_in_at, NULL::timestamp AS check_out_at FROM visit_requests vr JOIN visitors v ON v.id = vr.visitor_id JOIN users h ON h.id = vr.host_id JOIN access_events ae ON ae.visit_request_id = vr.id AND ae.direction = 'IN' WHERE NOT EXISTS ( SELECT 1 FROM access_events out_ae WHERE out_ae.visit_request_id = vr.id AND out_ae.direction = 'OUT' AND out_ae.event_at > ae.event_at ) ORDER BY vr.id, ae.event_at DESC `, ); ok(res, result.rows.map((row) => ({ visitRequestId: Number(row.visit_request_id), visitorName: row.visitor_name, company: row.company ?? undefined, zoneName: row.zone_name ?? undefined, hostName: row.host_name, checkInAt: toIso(row.check_in_at)!, }))); })); router.get('/access/today', asyncRoute(async (req, res) => { await requireCurrentUser(dbQuery, req); const result = await dbQuery( ` SELECT vr.id AS visit_request_id, v.name AS visitor_name, v.company, vr.zone_name, h.full_name AS host_name, vr.visit_from, vr.visit_to, vr.status, min(ae.event_at) FILTER (WHERE ae.direction = 'IN') AS check_in_at, max(ae.event_at) FILTER (WHERE ae.direction = 'OUT') AS check_out_at, COALESCE( (max(ae.event_at) FILTER (WHERE ae.direction = 'IN')) > COALESCE(max(ae.event_at) FILTER (WHERE ae.direction = 'OUT'), 'epoch'), false ) AS inside FROM visit_requests vr JOIN visitors v ON v.id = vr.visitor_id JOIN users h ON h.id = vr.host_id LEFT JOIN access_events ae ON ae.visit_request_id = vr.id AND ae.event_at::date = now()::date WHERE vr.visit_from::date = now()::date GROUP BY vr.id, v.id, h.id ORDER BY vr.visit_from `, ); ok(res, result.rows.map((row) => ({ visitRequestId: Number(row.visit_request_id), visitorName: row.visitor_name, company: row.company ?? undefined, zoneName: row.zone_name ?? undefined, visitFrom: toIso(row.visit_from)!, visitTo: toIso(row.visit_to)!, status: row.status, checkInAt: toIso(row.check_in_at), checkOutAt: toIso(row.check_out_at), inside: Boolean(row.inside), }))); })); router.get('/stats/summary', asyncRoute(async (req, res) => { await requireCurrentUser(dbQuery, req); const result = await dbQuery<{ today_visits: string; pending: string; approved: string; total: string }>( ` SELECT count(*) FILTER ( WHERE visit_from::date = now()::date AND status IN ('PENDING', 'APPROVED') ) AS today_visits, count(*) FILTER ( WHERE status = 'PENDING' AND visit_to >= now() ) AS pending, count(*) FILTER (WHERE status = 'APPROVED') AS approved, count(*) AS total FROM visit_requests `, ); const inside = await dbQuery<{ count: string }>( ` SELECT count(DISTINCT ae.visit_request_id) FROM access_events ae WHERE ae.direction = 'IN' AND NOT EXISTS ( SELECT 1 FROM access_events out_ae WHERE out_ae.visit_request_id = ae.visit_request_id AND out_ae.direction = 'OUT' AND out_ae.event_at > ae.event_at ) `, ); const row = result.rows[0]; ok(res, { todayVisits: Number(row.today_visits), pending: Number(row.pending), approved: Number(row.approved), currentlyInside: Number(inside.rows[0].count), total: Number(row.total), }); })); router.get('/blacklist', asyncRoute(async (req, res) => { await requireAdmin(dbQuery, req); const result = await dbQuery( ` SELECT b.*, u.full_name AS created_by_name FROM blacklist b LEFT JOIN users u ON u.id = b.created_by ORDER BY b.created_at DESC `, ); ok(res, result.rows.map((row) => ({ id: Number(row.id), name: row.name, company: row.company ?? undefined, contact: row.contact ?? undefined, reason: row.reason, active: row.active, createdByName: row.created_by_name ?? undefined, createdAt: toIso(row.created_at)!, }))); })); router.post('/blacklist', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const result = await dbQuery<{ id: string }>( 'INSERT INTO blacklist (created_at, updated_at, name, company, contact, reason, active, created_by) VALUES (now(), now(), $1, $2, $3, $4, TRUE, $5) RETURNING id', [required(req.body?.name, '이름'), req.body?.company || null, req.body?.contact || null, required(req.body?.reason, '사유'), Number(actor.id)], ); await audit(dbQuery, actor, 'BLACKLIST_ADD', 'BLACKLIST', Number(result.rows[0].id), '블랙리스트 추가'); ok(res, (await dbQuery('SELECT * FROM blacklist WHERE id = $1', [Number(result.rows[0].id)])).rows[0]); })); router.delete('/blacklist/:id', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); await dbQuery('UPDATE blacklist SET active = FALSE, updated_at = now() WHERE id = $1', [Number(req.params.id)]); await audit(dbQuery, actor, 'BLACKLIST_REMOVE', 'BLACKLIST', Number(req.params.id), '블랙리스트 해제'); ok(res, '삭제되었습니다.'); })); router.get('/reports/visits', asyncRoute(async (req, res) => { await requireCurrentUser(dbQuery, req); ok(res, await listVisitRows(dbQuery, 'WHERE vr.visit_from::date BETWEEN $1::date AND $2::date', [req.query.from, req.query.to])); })); router.get('/reports/visits.xlsx', asyncRoute(async (req, res) => { await requireCurrentUser(dbQuery, req); const visits = await listVisitRows(dbQuery, 'WHERE vr.visit_from::date BETWEEN $1::date AND $2::date', [req.query.from, req.query.to]); const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('visits'); sheet.addRow(['방문자', '회사', '구역', '목적', '작업명', '상태', '출입일시', '퇴실일시']); for (const visit of visits) { sheet.addRow([visit.visitorName, visit.company ?? '', visit.zoneName ?? '', visit.purpose, visit.workName ?? '', visit.status, visit.visitFrom, visit.visitTo]); } sheet.getRow(1).font = { bold: true }; const buffer = await workbook.xlsx.writeBuffer(); res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(Buffer.from(buffer)); })); router.get('/admin/audit', asyncRoute(async (req, res) => { await requireAdmin(dbQuery, req); const result = await dbQuery('SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 300'); ok(res, result.rows.map((row) => ({ id: Number(row.id), at: toIso(row.created_at)!, actorId: row.actor_id ? Number(row.actor_id) : undefined, actorUsername: row.actor_username ?? undefined, action: row.action, targetType: row.target_type ?? undefined, targetId: row.target_id ? Number(row.target_id) : undefined, detail: row.detail ?? undefined, }))); })); router.get('/admin/deliveries', asyncRoute(async (req, res) => { await requireAdmin(dbQuery, req); const status = req.query.status ? String(req.query.status) : null; const result = await dbQuery( `SELECT * FROM pass_deliveries ${status ? 'WHERE status = $1' : ''} ORDER BY created_at DESC LIMIT 300`, status ? [status] : [], ); ok(res, result.rows.map((row) => ({ id: Number(row.id), visitRequestId: Number(row.visit_request_id), channel: row.channel ?? undefined, recipient: row.recipient ?? undefined, status: row.status, attempts: Number(row.attempts), lastError: row.last_error ?? undefined, createdAt: toIso(row.created_at)!, updatedAt: toIso(row.updated_at)!, }))); })); router.post('/admin/deliveries/:id/retry', asyncRoute(async (req, res) => { await requireAdmin(dbQuery, req); const row = await retryPassDelivery(dbQuery, Number(req.params.id)); ok(res, { id: Number(row.id), visitRequestId: Number(row.visit_request_id), channel: row.channel ?? undefined, recipient: row.recipient ?? undefined, status: row.status, attempts: Number(row.attempts), lastError: row.last_error ?? undefined, createdAt: toIso(row.created_at)!, updatedAt: toIso(row.updated_at)!, }); })); return router; } export const businessRouter = createBusinessRouter();