952 lines
38 KiB
TypeScript
952 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, '방문 신청을 찾을 수 없습니다.');
|
|
|
|
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,
|
|
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();
|