Files
acs/server/routes/business.ts
2026-07-20 18:55:28 +09:00

959 lines
38 KiB
TypeScript

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_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<void>) {
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<VisitRow | undefined> {
const result = await dbQuery<VisitRow>(
`
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<VisitRow>(
`
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<VisitRow> {
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<ExcelJS.Workbook> {
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<string, unknown>, 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.delete('/visit-requests/:id', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
const id = Number(req.params.id);
const visit = await visitById(dbQuery, id);
if (!visit) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.');
const events = await dbQuery<{ count: string }>(
'SELECT count(*) AS count FROM access_events WHERE visit_request_id = $1',
[id],
);
if (Number(events.rows[0]?.count ?? 0) > 0) {
throw new ApiError(400, '실제 입/퇴장 기록이 있는 신청은 삭제할 수 없습니다.');
}
await dbQuery('DELETE FROM pass_deliveries WHERE visit_request_id = $1', [id]);
await dbQuery('DELETE FROM approvals WHERE visit_request_id = $1', [id]);
await dbQuery('DELETE FROM visit_requests WHERE id = $1', [id]);
await dbQuery(
`DELETE FROM visitors v
WHERE v.id = $1
AND NOT EXISTS (SELECT 1 FROM visit_requests vr WHERE vr.visitor_id = v.id)`,
[Number(visit.visitor_id)],
);
await audit(dbQuery, actor, 'DELETE', 'VISIT_REQUEST', id, `신청 삭제: ${visit.visitor_name} / ${visit.zone_name ?? '-'}`);
ok(res, { deleted: true });
}));
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<VisitRow & { inside: boolean; completed_today: boolean }>(
`
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<AccessRow>(
`
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<AccessRow & { inside: boolean }>(
`
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();