1403 lines
54 KiB
TypeScript
1403 lines
54 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 { createHash, randomInt, randomUUID } from 'node:crypto';
|
|
import { existsSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
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;
|
|
}
|
|
|
|
interface ReportVisitRow extends VisitRow {
|
|
check_in_at: string | Date | null;
|
|
check_out_at: string | Date | null;
|
|
inside: boolean;
|
|
}
|
|
|
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 3 * 1024 * 1024 } });
|
|
const visitRequestTemplatePath = path.resolve(process.cwd(), 'docs', 'form_sample.xlsx');
|
|
|
|
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 visitStatusLabel(status: string): string {
|
|
switch (status) {
|
|
case 'DRAFT': return '임시저장';
|
|
case 'PENDING': return '승인대기';
|
|
case 'APPROVED': return '승인완료';
|
|
case 'REJECTED': return '반려';
|
|
case 'CANCELLED': return '취소';
|
|
case 'EXPIRED': return '만료';
|
|
default: return status;
|
|
}
|
|
}
|
|
|
|
function reportStatusLabel(row: Pick<ReportVisitRow, 'status' | 'inside' | 'check_out_at'>): string {
|
|
if (row.inside) return '재실중';
|
|
if (row.check_out_at) return '퇴실';
|
|
return visitStatusLabel(row.status);
|
|
}
|
|
|
|
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) {
|
|
const koreanMonthDay = normalized.match(/^(\d{1,2})\uC6D4(\d{1,2})\uC77C/);
|
|
if (koreanMonthDay) {
|
|
const year = new Date().getFullYear();
|
|
return `${year}-${pad2(Number(koreanMonthDay[1]))}-${pad2(Number(koreanMonthDay[2]))}`;
|
|
}
|
|
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));
|
|
}
|
|
|
|
function compactCellText(row: ExcelJS.Row, index: number): string {
|
|
return cellText(row, index).replace(/\s+/g, '');
|
|
}
|
|
|
|
function isVisitUploadNonDataRow(row: ExcelJS.Row): boolean {
|
|
const sequence = compactCellText(row, 1);
|
|
const purpose = compactCellText(row, 2);
|
|
const date = compactCellText(row, 5);
|
|
const name = compactCellText(row, 7);
|
|
|
|
return (
|
|
sequence.includes('\uC21C\uBC88') ||
|
|
purpose.includes('\uCD9C\uC785\uAC1C\uC694') ||
|
|
date.includes('\uCD9C\uC785\uAC1C\uC694') ||
|
|
date.includes('\uCD9C\uC785\uC77C\uC790') ||
|
|
name.includes('\uCD9C\uC785\uC790\uC815\uBCF4') ||
|
|
name.includes('\uC774\uB984')
|
|
);
|
|
}
|
|
|
|
function findVisitUploadStartRow(sheet: ExcelJS.Worksheet): number {
|
|
for (let rowNumber = 1; rowNumber <= sheet.rowCount; rowNumber += 1) {
|
|
const row = sheet.getRow(rowNumber);
|
|
const dateHeader = compactCellText(row, 5);
|
|
const nameHeader = compactCellText(row, 7);
|
|
if (
|
|
dateHeader.includes('\uCD9C\uC785\uC77C\uC790') &&
|
|
nameHeader.includes('\uC774\uB984')
|
|
) {
|
|
return rowNumber + 1;
|
|
}
|
|
}
|
|
return 3;
|
|
}
|
|
|
|
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 };
|
|
const startRow = findVisitUploadStartRow(sheet);
|
|
|
|
for (let rowNumber = startRow; rowNumber <= sheet.rowCount; rowNumber += 1) {
|
|
const row = sheet.getRow(rowNumber);
|
|
if (isVisitUploadRowEmpty(row)) continue;
|
|
if (isVisitUploadNonDataRow(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)))!);
|
|
}
|
|
|
|
interface VisitorApplicationRow {
|
|
id: string;
|
|
created_at: string | Date;
|
|
status: string;
|
|
visitor_name: string;
|
|
company: string | null;
|
|
contact: string | null;
|
|
email: string | null;
|
|
vehicle_no: string | null;
|
|
zone_name: string | null;
|
|
room_zone: 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;
|
|
verification_method: string;
|
|
verification_target: string;
|
|
verified_at: string | Date | null;
|
|
imported_visit_request_id: string | null;
|
|
imported_at: string | Date | null;
|
|
}
|
|
|
|
function normalizeDigits(value: string): string {
|
|
return value.replace(/\D/g, '');
|
|
}
|
|
|
|
function normalizeVerificationTarget(method: string, value: string): string {
|
|
const target = value.trim();
|
|
return method === 'PHONE' ? normalizeDigits(target) : target.toLowerCase();
|
|
}
|
|
|
|
function hashVerificationCode(id: string, target: string, code: string): string {
|
|
return createHash('sha256').update(`${id}:${target}:${code}:${env.sessionSecret}`).digest('hex');
|
|
}
|
|
|
|
function toVisitorApplication(row: VisitorApplicationRow) {
|
|
return {
|
|
id: Number(row.id),
|
|
createdAt: toIso(row.created_at)!,
|
|
status: row.status,
|
|
visitorName: row.visitor_name,
|
|
company: row.company ?? undefined,
|
|
contact: row.contact ?? undefined,
|
|
email: row.email ?? undefined,
|
|
vehicleNo: row.vehicle_no ?? undefined,
|
|
zoneName: row.zone_name ?? undefined,
|
|
roomZone: row.room_zone ?? 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)!,
|
|
verificationMethod: row.verification_method,
|
|
verificationTarget: row.verification_target,
|
|
verifiedAt: toIso(row.verified_at),
|
|
importedVisitRequestId: row.imported_visit_request_id ? Number(row.imported_visit_request_id) : undefined,
|
|
importedAt: toIso(row.imported_at),
|
|
};
|
|
}
|
|
|
|
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.post('/public/visitor-verifications', asyncRoute(async (req, res) => {
|
|
const method = String(req.body?.method ?? '').trim().toUpperCase();
|
|
if (!['PHONE', 'EMAIL'].includes(method)) {
|
|
throw new ApiError(400, '인증수단을 선택하세요.');
|
|
}
|
|
const target = normalizeVerificationTarget(method, required(req.body?.target, '인증 대상'));
|
|
if (method === 'PHONE' && target.length < 10) throw new ApiError(400, '휴대폰번호를 확인하세요.');
|
|
if (method === 'EMAIL' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(target)) throw new ApiError(400, '이메일 형식을 확인하세요.');
|
|
|
|
const id = randomUUID();
|
|
const code = String(randomInt(100000, 1000000));
|
|
await dbQuery(
|
|
`
|
|
INSERT INTO visitor_verifications (id, verification_method, target, code_hash, expires_at)
|
|
VALUES ($1, $2, $3, $4, now() + interval '5 minutes')
|
|
`,
|
|
[id, method, target, hashVerificationCode(id, target, code)],
|
|
);
|
|
|
|
let deliveryStatus = 'DEV';
|
|
if (method === 'PHONE') {
|
|
const delivery = await sendSms({ to: target, text: `[ACS 방문신청] 인증번호는 ${code} 입니다.` });
|
|
deliveryStatus = delivery.status;
|
|
}
|
|
|
|
ok(res, {
|
|
verificationId: id,
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
deliveryStatus,
|
|
devCode: env.nodeEnv === 'production' ? undefined : code,
|
|
});
|
|
}));
|
|
|
|
router.post('/public/visitor-verifications/:id/confirm', asyncRoute(async (req, res) => {
|
|
const id = String(req.params.id);
|
|
const code = String(req.body?.code ?? '').trim();
|
|
const result = await dbQuery<{ id: string; target: string; code_hash: string; expires_at: string | Date; verified_at: string | Date | null }>(
|
|
'SELECT id, target, code_hash, expires_at, verified_at FROM visitor_verifications WHERE id = $1',
|
|
[id],
|
|
);
|
|
const row = result.rows[0];
|
|
if (!row) throw new ApiError(404, '인증 요청을 찾을 수 없습니다.');
|
|
if (row.verified_at) throw new ApiError(400, '이미 확인된 인증번호입니다.');
|
|
if (new Date(row.expires_at).getTime() < Date.now()) throw new ApiError(400, '인증번호가 만료되었습니다.');
|
|
if (!/^\d{6}$/.test(code) || hashVerificationCode(id, row.target, code) !== row.code_hash) {
|
|
throw new ApiError(400, '인증번호가 일치하지 않습니다.');
|
|
}
|
|
const token = randomUUID().replaceAll('-', '');
|
|
await dbQuery(
|
|
'UPDATE visitor_verifications SET verified_at = now(), verification_token = $2 WHERE id = $1',
|
|
[id, token],
|
|
);
|
|
ok(res, { verificationToken: token });
|
|
}));
|
|
|
|
router.post('/public/visitor-applications', asyncRoute(async (req, res) => {
|
|
const method = String(req.body?.verificationMethod ?? '').trim().toUpperCase();
|
|
if (!['PHONE', 'EMAIL'].includes(method)) throw new ApiError(400, '인증수단을 선택하세요.');
|
|
const visitorName = required(req.body?.visitorName, '방문자 이름');
|
|
const company = required(req.body?.company, '회사/소속');
|
|
const controlName = required(req.body?.controlName, '출입통제담당자 이름');
|
|
const controlTeam = required(req.body?.controlTeam, '출입통제담당자 소속');
|
|
const contact = req.body?.contact ? String(req.body.contact).trim() : '';
|
|
const email = req.body?.email ? String(req.body.email).trim() : '';
|
|
const verificationTarget = normalizeVerificationTarget(method, method === 'PHONE' ? contact : email);
|
|
if (!verificationTarget) throw new ApiError(400, '인증 대상과 신청서 연락처가 일치해야 합니다.');
|
|
const token = required(req.body?.verificationToken, '본인확인');
|
|
const verification = await dbQuery<{ id: string; target: string; verification_method: string; verified_at: string | Date | null }>(
|
|
'SELECT id, target, verification_method, verified_at FROM visitor_verifications WHERE verification_token = $1',
|
|
[token],
|
|
);
|
|
const verified = verification.rows[0];
|
|
if (!verified?.verified_at) throw new ApiError(400, '본인확인을 완료하세요.');
|
|
if (verified.verification_method !== method || verified.target !== verificationTarget) {
|
|
throw new ApiError(400, '인증 대상과 신청서 연락처가 일치해야 합니다.');
|
|
}
|
|
|
|
const visitFrom = new Date(required(req.body?.visitFrom, '방문 일시'));
|
|
const visitTo = new Date(required(req.body?.visitTo, '퇴실 예정일시'));
|
|
if (visitFrom > visitTo) throw new ApiError(400, '퇴실 예정일시는 방문 일시 이후여야 합니다.');
|
|
if (!sameDate(visitFrom, visitTo)) throw new ApiError(400, '퇴실 예정일이 다음 날이면 날짜별로 나누어 신청하세요.');
|
|
|
|
const purpose = await resolvePurpose(
|
|
dbQuery,
|
|
req.body?.purposeCode ? String(req.body.purposeCode) : undefined,
|
|
String(req.body?.purpose ?? ''),
|
|
req.body?.purposeDetail ? String(req.body.purposeDetail) : undefined,
|
|
);
|
|
const zoneName = req.body?.zoneName ? String(req.body.zoneName).trim() : null;
|
|
const roomZone = req.body?.roomZone ? String(req.body.roomZone).trim() : null;
|
|
if (!zoneName && !roomZone) throw new ApiError(400, '방문 구역을 선택하세요.');
|
|
const defaultWatcher1 = await watcher1(dbQuery);
|
|
|
|
const inserted = await dbQuery<VisitorApplicationRow>(
|
|
`
|
|
INSERT INTO visitor_applications (
|
|
visitor_name, company, contact, email, vehicle_no,
|
|
zone_name, room_zone, purpose, purpose_code, purpose_detail, work_name,
|
|
control_name, control_team, control_contact,
|
|
watcher1_name, watcher1_team, watcher1_contact,
|
|
watcher2_name, watcher2_team, watcher2_contact,
|
|
visit_from, visit_to, verification_method, verification_target, verification_id, verified_at
|
|
)
|
|
VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
$6, $7, $8, $9, $10, $11,
|
|
$12, $13, $14,
|
|
$15, $16, $17,
|
|
$18, $19, $20,
|
|
$21, $22, $23, $24, $25, now()
|
|
)
|
|
RETURNING *
|
|
`,
|
|
[
|
|
visitorName,
|
|
company,
|
|
contact || null,
|
|
email || null,
|
|
req.body?.vehicleNo ? String(req.body.vehicleNo).trim() : null,
|
|
zoneName,
|
|
roomZone,
|
|
purpose.display,
|
|
purpose.code,
|
|
purpose.detail,
|
|
req.body?.workName ? String(req.body.workName).trim() : null,
|
|
controlName,
|
|
controlTeam,
|
|
req.body?.controlContact ? String(req.body.controlContact).trim() : null,
|
|
req.body?.watcher1Name ? String(req.body.watcher1Name).trim() : defaultWatcher1.name,
|
|
req.body?.watcher1Team ? String(req.body.watcher1Team).trim() : defaultWatcher1.team,
|
|
req.body?.watcher1Contact ? String(req.body.watcher1Contact).trim() : defaultWatcher1.contact,
|
|
req.body?.watcher2Name ? String(req.body.watcher2Name).trim() : null,
|
|
req.body?.watcher2Team ? String(req.body.watcher2Team).trim() : null,
|
|
req.body?.watcher2Contact ? String(req.body.watcher2Contact).trim() : null,
|
|
visitFrom,
|
|
visitTo,
|
|
method,
|
|
verificationTarget,
|
|
verified.id,
|
|
],
|
|
);
|
|
ok(res, toVisitorApplication(inserted.rows[0]));
|
|
}));
|
|
|
|
router.get('/visitor-applications', asyncRoute(async (req, res) => {
|
|
await requireCurrentUser(dbQuery, req);
|
|
const q = String(req.query.q ?? '').trim();
|
|
const where = q
|
|
? "WHERE status = 'SUBMITTED' AND (visitor_name ILIKE $1 OR company ILIKE $1 OR contact ILIKE $1 OR email ILIKE $1)"
|
|
: "WHERE status = 'SUBMITTED'";
|
|
const params = q ? [`%${q}%`] : [];
|
|
const result = await dbQuery<VisitorApplicationRow>(
|
|
`
|
|
SELECT *
|
|
FROM visitor_applications
|
|
${where}
|
|
ORDER BY created_at DESC
|
|
LIMIT 50
|
|
`,
|
|
params,
|
|
);
|
|
ok(res, result.rows.map(toVisitorApplication));
|
|
}));
|
|
|
|
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/template', asyncRoute(async (req, res) => {
|
|
await requireCurrentUser(dbQuery, req);
|
|
if (!existsSync(visitRequestTemplatePath)) {
|
|
throw new ApiError(404, '엑셀 업로드 양식을 찾을 수 없습니다.');
|
|
}
|
|
res.download(visitRequestTemplatePath, 'visit-request-template.xlsx');
|
|
}));
|
|
|
|
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);
|
|
}
|
|
}
|
|
if (req.body?.sourceApplicationId && created[0]) {
|
|
await dbQuery(
|
|
`
|
|
UPDATE visitor_applications
|
|
SET status = 'IMPORTED',
|
|
imported_visit_request_id = $2,
|
|
imported_at = now(),
|
|
updated_at = now()
|
|
WHERE id = $1 AND status = 'SUBMITTED'
|
|
`,
|
|
[Number(req.body.sourceApplicationId), created[0].id],
|
|
);
|
|
}
|
|
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, '방문 신청을 찾을 수 없습니다.');
|
|
if (visit.status !== 'CANCELLED') 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 access_events 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,
|
|
zoneName: visit.zone_name ?? undefined,
|
|
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);
|
|
const result = await dbQuery<ReportVisitRow>(
|
|
`
|
|
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,
|
|
acc.check_in_at,
|
|
acc.check_out_at,
|
|
COALESCE(acc.check_in_at > COALESCE(acc.check_out_at, 'epoch'::timestamp), 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 LATERAL (
|
|
SELECT
|
|
max(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
|
|
FROM access_events ae
|
|
WHERE ae.visit_request_id = vr.id
|
|
) acc ON TRUE
|
|
WHERE vr.visit_from::date BETWEEN $1::date AND $2::date
|
|
ORDER BY vr.created_at DESC
|
|
`,
|
|
[req.query.from, req.query.to],
|
|
);
|
|
ok(res, result.rows.map((row) => ({
|
|
...toVisit(row),
|
|
checkInAt: toIso(row.check_in_at),
|
|
checkOutAt: toIso(row.check_out_at),
|
|
inside: Boolean(row.inside),
|
|
reportStatusLabel: reportStatusLabel(row),
|
|
})));
|
|
}));
|
|
|
|
router.get('/reports/visits.xlsx', asyncRoute(async (req, res) => {
|
|
await requireCurrentUser(dbQuery, req);
|
|
const visits = await dbQuery<ReportVisitRow>(
|
|
`
|
|
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,
|
|
acc.check_in_at,
|
|
acc.check_out_at,
|
|
COALESCE(acc.check_in_at > COALESCE(acc.check_out_at, 'epoch'::timestamp), 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 LATERAL (
|
|
SELECT
|
|
max(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
|
|
FROM access_events ae
|
|
WHERE ae.visit_request_id = vr.id
|
|
) acc ON TRUE
|
|
WHERE vr.visit_from::date BETWEEN $1::date AND $2::date
|
|
ORDER BY vr.created_at DESC
|
|
`,
|
|
[req.query.from, req.query.to],
|
|
);
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('visits');
|
|
sheet.addRow(['방문자', '회사', '구역', '목적', '작업명', '상태', '출입일시', '퇴실일시']);
|
|
for (const visit of visits.rows) {
|
|
sheet.addRow([
|
|
visit.visitor_name,
|
|
visit.company ?? '',
|
|
visit.zone_name ?? '',
|
|
visit.purpose,
|
|
visit.work_name ?? '',
|
|
reportStatusLabel(visit),
|
|
toIso(visit.visit_from),
|
|
toIso(visit.check_out_at) ?? toIso(visit.visit_to),
|
|
]);
|
|
}
|
|
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();
|