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'; 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; 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], ); } 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, '출입 목적'); return { code: null, name: text, detail: null, display: text }; } 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, actor.full_name, actor.department, null, watcher.name, watcher.team, 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) { created.push(await createOneVisit(dbQuery, actor, req.body, zone)); } 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) => { ok(res, { totalRows: 0, successCount: 0, errors: ['Node 배포본의 방문자 명단 엑셀 업로드는 다음 단계에서 이관 예정입니다.'], success: false }); })); router.post('/approvals/:id/approve', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const id = Number(req.params.id); await dbQuery("UPDATE visit_requests SET status = 'APPROVED', 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, 'APPROVED', $3, now())", [id, Number(actor.id), req.body?.comment ? String(req.body.comment) : null], ); await dbQuery( "INSERT INTO pass_deliveries (created_at, updated_at, visit_request_id, channel, recipient, status, attempts, last_error) VALUES (now(), now(), $1, 'DEV', NULL, 'SENT', 1, NULL)", [id], ); await audit(dbQuery, actor, 'APPROVE', 'VISIT_REQUEST', id, '승인'); ok(res, toVisit((await visitById(dbQuery, id))!)); })); 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, 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, (max(ae.event_at) FILTER (WHERE ae.direction = 'IN')) > COALESCE(max(ae.event_at) FILTER (WHERE ae.direction = 'OUT'), 'epoch') 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)!, checkInAt: toIso(row.check_in_at), checkOutAt: toIso(row.check_out_at), inside: 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) AS today_visits, count(*) FILTER (WHERE status = 'PENDING') 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 result = await dbQuery( "UPDATE pass_deliveries SET attempts = attempts + 1, status = 'SENT', last_error = NULL, updated_at = now() WHERE id = $1 RETURNING *", [Number(req.params.id)], ); ok(res, { id: Number(result.rows[0].id), visitRequestId: Number(result.rows[0].visit_request_id), channel: result.rows[0].channel ?? undefined, recipient: result.rows[0].recipient ?? undefined, status: result.rows[0].status, attempts: Number(result.rows[0].attempts), lastError: result.rows[0].last_error ?? undefined, createdAt: toIso(result.rows[0].created_at)!, updatedAt: toIso(result.rows[0].updated_at)!, }); })); return router; } export const businessRouter = createBusinessRouter();