feat: add Node ACS admin workflows
This commit is contained in:
706
server/routes/business.ts
Normal file
706
server/routes/business.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import ExcelJS from 'exceljs';
|
||||
import QRCode from 'qrcode';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ok } from '../http/apiResponse.js';
|
||||
import { ApiError } from '../http/errors.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { query } from '../db/pool.js';
|
||||
import { requireAdmin, requireCurrentUser, type QueryFn, type UserRow } from './security.js';
|
||||
import { watcher1 } from './admin.js';
|
||||
|
||||
interface BusinessRouterDeps {
|
||||
query: QueryFn;
|
||||
}
|
||||
|
||||
interface VisitRow {
|
||||
id: string;
|
||||
visitor_name: string;
|
||||
company: string | null;
|
||||
contact: string | null;
|
||||
email: string | null;
|
||||
vehicle_no: string | null;
|
||||
host_id: string;
|
||||
host_name: string;
|
||||
host_department: string | null;
|
||||
zone_name: string | null;
|
||||
purpose: string;
|
||||
purpose_code: string | null;
|
||||
purpose_detail: string | null;
|
||||
work_name: string | null;
|
||||
control_name: string | null;
|
||||
control_team: string | null;
|
||||
control_contact: string | null;
|
||||
watcher1_name: string | null;
|
||||
watcher1_team: string | null;
|
||||
watcher1_contact: string | null;
|
||||
watcher2_name: string | null;
|
||||
watcher2_team: string | null;
|
||||
watcher2_contact: string | null;
|
||||
visit_from: string | Date;
|
||||
visit_to: string | Date;
|
||||
status: string;
|
||||
qr_token: string | null;
|
||||
created_at: string | Date;
|
||||
}
|
||||
|
||||
interface AccessRow {
|
||||
visit_request_id: string;
|
||||
visitor_name: string;
|
||||
company: string | null;
|
||||
zone_name: string | null;
|
||||
host_name: string;
|
||||
visit_from: string | Date;
|
||||
visit_to: string | Date;
|
||||
check_in_at: string | Date | null;
|
||||
check_out_at: string | Date | null;
|
||||
}
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 3 * 1024 * 1024 } });
|
||||
|
||||
function asyncRoute(handler: (req: Request, res: Response, next: NextFunction) => Promise<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],
|
||||
);
|
||||
}
|
||||
|
||||
async function resolvePurpose(dbQuery: QueryFn, code: string | undefined, purpose: string, detail?: string) {
|
||||
if (code) {
|
||||
const result = await dbQuery<{ code: string; name: string; custom_allowed: boolean; active: boolean }>(
|
||||
'SELECT code, name, custom_allowed, active FROM purpose_codes WHERE upper(code) = upper($1)',
|
||||
[code],
|
||||
);
|
||||
const item = result.rows[0];
|
||||
if (!item || !item.active) throw new ApiError(400, `존재하지 않는 출입목적 코드입니다: ${code}`);
|
||||
if (!item.custom_allowed) return { code: item.code, name: item.name, detail: null, display: item.name };
|
||||
const text = String(detail || purpose || '').trim();
|
||||
if (!text || text === item.name) throw new ApiError(400, '기타 출입 목적을 입력하세요.');
|
||||
const matched = await dbQuery<{ code: string; name: string }>(
|
||||
'SELECT code, name FROM purpose_codes WHERE active = TRUE AND custom_allowed = FALSE AND (upper(code) = upper($1) OR name = $1)',
|
||||
[text],
|
||||
);
|
||||
if (matched.rows[0]) return { code: matched.rows[0].code, name: matched.rows[0].name, detail: null, display: matched.rows[0].name };
|
||||
return { code: item.code, name: text, detail: text, display: text };
|
||||
}
|
||||
const text = required(purpose, '출입 목적');
|
||||
return { code: null, name: text, detail: null, display: text };
|
||||
}
|
||||
|
||||
async function createOneVisit(dbQuery: QueryFn, actor: UserRow, body: Record<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,
|
||||
actor.full_name,
|
||||
actor.department,
|
||||
null,
|
||||
watcher.name,
|
||||
watcher.team,
|
||||
watcher.contact,
|
||||
body.watcher2Name ? String(body.watcher2Name).trim() : null,
|
||||
body.watcher2Team ? String(body.watcher2Team).trim() : null,
|
||||
body.watcher2Contact ? String(body.watcher2Contact).trim() : null,
|
||||
],
|
||||
);
|
||||
return toVisit((await visitById(dbQuery, Number(created.rows[0].id)))!);
|
||||
}
|
||||
|
||||
export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Router {
|
||||
const router = Router();
|
||||
const dbQuery = deps.query;
|
||||
|
||||
router.get('/zones', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const result = await dbQuery('SELECT id, code, name, security_level FROM zones WHERE active = TRUE ORDER BY id');
|
||||
ok(res, result.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
securityLevel: row.security_level,
|
||||
})));
|
||||
}));
|
||||
|
||||
router.get('/visit-requests', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
ok(res, await listVisitRows(dbQuery));
|
||||
}));
|
||||
|
||||
router.get('/visit-requests/pending', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
ok(res, await listVisitRows(dbQuery, "WHERE vr.status = 'PENDING'"));
|
||||
}));
|
||||
|
||||
router.get('/visit-requests/:id', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const visit = await visitById(dbQuery, Number(req.params.id));
|
||||
if (!visit) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.');
|
||||
ok(res, toVisit(visit));
|
||||
}));
|
||||
|
||||
router.post('/visit-requests', asyncRoute(async (req, res) => {
|
||||
const actor = await requireCurrentUser(dbQuery, req);
|
||||
const rooms = Array.isArray(req.body?.serverRooms) ? req.body.serverRooms.map(String).filter(Boolean) : [];
|
||||
const roomZone = req.body?.roomZone ? String(req.body.roomZone).trim() : '';
|
||||
const zones = rooms.length > 0 ? rooms : roomZone ? [roomZone] : [];
|
||||
if (zones.length === 0) throw new ApiError(400, '출입 구역을 선택하세요.');
|
||||
const created = [];
|
||||
for (const zone of zones) {
|
||||
created.push(await createOneVisit(dbQuery, actor, req.body, zone));
|
||||
}
|
||||
ok(res, created);
|
||||
}));
|
||||
|
||||
router.post('/visit-requests/:id/cancel', asyncRoute(async (req, res) => {
|
||||
const actor = await requireCurrentUser(dbQuery, req);
|
||||
await dbQuery("UPDATE visit_requests SET status = 'CANCELLED', updated_at = now() WHERE id = $1 AND status IN ('PENDING', 'APPROVED')", [Number(req.params.id)]);
|
||||
await audit(dbQuery, actor, 'REJECT', 'VISIT_REQUEST', Number(req.params.id), '신청 취소');
|
||||
ok(res, toVisit((await visitById(dbQuery, Number(req.params.id)))!));
|
||||
}));
|
||||
|
||||
router.post('/visit-requests/upload', upload.single('file'), asyncRoute(async (_req, res) => {
|
||||
ok(res, { totalRows: 0, successCount: 0, errors: ['Node 배포본의 방문자 명단 엑셀 업로드는 다음 단계에서 이관 예정입니다.'], success: false });
|
||||
}));
|
||||
|
||||
router.post('/approvals/:id/approve', asyncRoute(async (req, res) => {
|
||||
const actor = await requireAdmin(dbQuery, req);
|
||||
const id = Number(req.params.id);
|
||||
await dbQuery("UPDATE visit_requests SET status = 'APPROVED', updated_at = now() WHERE id = $1", [id]);
|
||||
await dbQuery(
|
||||
"INSERT INTO approvals (created_at, updated_at, visit_request_id, approver_id, decision, comment, decided_at) VALUES (now(), now(), $1, $2, 'APPROVED', $3, now())",
|
||||
[id, Number(actor.id), req.body?.comment ? String(req.body.comment) : null],
|
||||
);
|
||||
await dbQuery(
|
||||
"INSERT INTO pass_deliveries (created_at, updated_at, visit_request_id, channel, recipient, status, attempts, last_error) VALUES (now(), now(), $1, 'DEV', NULL, 'SENT', 1, NULL)",
|
||||
[id],
|
||||
);
|
||||
await audit(dbQuery, actor, 'APPROVE', 'VISIT_REQUEST', id, '승인');
|
||||
ok(res, toVisit((await visitById(dbQuery, id))!));
|
||||
}));
|
||||
|
||||
router.post('/approvals/:id/reject', asyncRoute(async (req, res) => {
|
||||
const actor = await requireAdmin(dbQuery, req);
|
||||
const id = Number(req.params.id);
|
||||
await dbQuery("UPDATE visit_requests SET status = 'REJECTED', updated_at = now() WHERE id = $1", [id]);
|
||||
await dbQuery(
|
||||
"INSERT INTO approvals (created_at, updated_at, visit_request_id, approver_id, decision, comment, decided_at) VALUES (now(), now(), $1, $2, 'REJECTED', $3, now())",
|
||||
[id, Number(actor.id), req.body?.comment ? String(req.body.comment) : null],
|
||||
);
|
||||
await audit(dbQuery, actor, 'REJECT', 'VISIT_REQUEST', id, '반려');
|
||||
ok(res, toVisit((await visitById(dbQuery, id))!));
|
||||
}));
|
||||
|
||||
router.get('/passes/:id', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const visit = await visitById(dbQuery, Number(req.params.id));
|
||||
if (!visit) throw new ApiError(404, '출입증을 찾을 수 없습니다.');
|
||||
ok(res, toVisit(visit));
|
||||
}));
|
||||
|
||||
router.get('/passes/:id/qr.png', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const visit = await visitById(dbQuery, Number(req.params.id));
|
||||
if (!visit?.qr_token) throw new ApiError(404, 'QR을 찾을 수 없습니다.');
|
||||
const png = await QRCode.toBuffer(`${env.publicBaseUrl}/pass/${visit.qr_token}`, { type: 'png', width: 220 });
|
||||
res.type('png').send(png);
|
||||
}));
|
||||
|
||||
router.get('/public/passes/:token', asyncRoute(async (req, res) => {
|
||||
const result = await dbQuery<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,
|
||||
min(ae.event_at) FILTER (WHERE ae.direction = 'IN') AS check_in_at,
|
||||
max(ae.event_at) FILTER (WHERE ae.direction = 'OUT') AS check_out_at,
|
||||
(max(ae.event_at) FILTER (WHERE ae.direction = 'IN')) > COALESCE(max(ae.event_at) FILTER (WHERE ae.direction = 'OUT'), 'epoch') AS inside
|
||||
FROM visit_requests vr
|
||||
JOIN visitors v ON v.id = vr.visitor_id
|
||||
JOIN users h ON h.id = vr.host_id
|
||||
LEFT JOIN access_events ae ON ae.visit_request_id = vr.id AND ae.event_at::date = now()::date
|
||||
WHERE vr.visit_from::date = now()::date
|
||||
GROUP BY vr.id, v.id, h.id
|
||||
ORDER BY vr.visit_from
|
||||
`,
|
||||
);
|
||||
ok(res, result.rows.map((row) => ({
|
||||
visitRequestId: Number(row.visit_request_id),
|
||||
visitorName: row.visitor_name,
|
||||
company: row.company ?? undefined,
|
||||
zoneName: row.zone_name ?? undefined,
|
||||
visitFrom: toIso(row.visit_from)!,
|
||||
visitTo: toIso(row.visit_to)!,
|
||||
checkInAt: toIso(row.check_in_at),
|
||||
checkOutAt: toIso(row.check_out_at),
|
||||
inside: row.inside,
|
||||
})));
|
||||
}));
|
||||
|
||||
router.get('/stats/summary', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const result = await dbQuery<{ today_visits: string; pending: string; approved: string; total: string }>(
|
||||
`
|
||||
SELECT
|
||||
count(*) FILTER (WHERE visit_from::date = now()::date) AS today_visits,
|
||||
count(*) FILTER (WHERE status = 'PENDING') AS pending,
|
||||
count(*) FILTER (WHERE status = 'APPROVED') AS approved,
|
||||
count(*) AS total
|
||||
FROM visit_requests
|
||||
`,
|
||||
);
|
||||
const inside = await dbQuery<{ count: string }>(
|
||||
`
|
||||
SELECT count(DISTINCT ae.visit_request_id)
|
||||
FROM access_events ae
|
||||
WHERE ae.direction = 'IN'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM access_events out_ae
|
||||
WHERE out_ae.visit_request_id = ae.visit_request_id AND out_ae.direction = 'OUT' AND out_ae.event_at > ae.event_at
|
||||
)
|
||||
`,
|
||||
);
|
||||
const row = result.rows[0];
|
||||
ok(res, {
|
||||
todayVisits: Number(row.today_visits),
|
||||
pending: Number(row.pending),
|
||||
approved: Number(row.approved),
|
||||
currentlyInside: Number(inside.rows[0].count),
|
||||
total: Number(row.total),
|
||||
});
|
||||
}));
|
||||
|
||||
router.get('/blacklist', asyncRoute(async (req, res) => {
|
||||
await requireAdmin(dbQuery, req);
|
||||
const result = await dbQuery(
|
||||
`
|
||||
SELECT b.*, u.full_name AS created_by_name
|
||||
FROM blacklist b
|
||||
LEFT JOIN users u ON u.id = b.created_by
|
||||
ORDER BY b.created_at DESC
|
||||
`,
|
||||
);
|
||||
ok(res, result.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
company: row.company ?? undefined,
|
||||
contact: row.contact ?? undefined,
|
||||
reason: row.reason,
|
||||
active: row.active,
|
||||
createdByName: row.created_by_name ?? undefined,
|
||||
createdAt: toIso(row.created_at)!,
|
||||
})));
|
||||
}));
|
||||
|
||||
router.post('/blacklist', asyncRoute(async (req, res) => {
|
||||
const actor = await requireAdmin(dbQuery, req);
|
||||
const result = await dbQuery<{ id: string }>(
|
||||
'INSERT INTO blacklist (created_at, updated_at, name, company, contact, reason, active, created_by) VALUES (now(), now(), $1, $2, $3, $4, TRUE, $5) RETURNING id',
|
||||
[required(req.body?.name, '이름'), req.body?.company || null, req.body?.contact || null, required(req.body?.reason, '사유'), Number(actor.id)],
|
||||
);
|
||||
await audit(dbQuery, actor, 'BLACKLIST_ADD', 'BLACKLIST', Number(result.rows[0].id), '블랙리스트 추가');
|
||||
ok(res, (await dbQuery('SELECT * FROM blacklist WHERE id = $1', [Number(result.rows[0].id)])).rows[0]);
|
||||
}));
|
||||
|
||||
router.delete('/blacklist/:id', asyncRoute(async (req, res) => {
|
||||
const actor = await requireAdmin(dbQuery, req);
|
||||
await dbQuery('UPDATE blacklist SET active = FALSE, updated_at = now() WHERE id = $1', [Number(req.params.id)]);
|
||||
await audit(dbQuery, actor, 'BLACKLIST_REMOVE', 'BLACKLIST', Number(req.params.id), '블랙리스트 해제');
|
||||
ok(res, '삭제되었습니다.');
|
||||
}));
|
||||
|
||||
router.get('/reports/visits', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
ok(res, await listVisitRows(dbQuery, 'WHERE vr.visit_from::date BETWEEN $1::date AND $2::date', [req.query.from, req.query.to]));
|
||||
}));
|
||||
|
||||
router.get('/reports/visits.xlsx', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const visits = await listVisitRows(dbQuery, 'WHERE vr.visit_from::date BETWEEN $1::date AND $2::date', [req.query.from, req.query.to]);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('visits');
|
||||
sheet.addRow(['방문자', '회사', '구역', '목적', '작업명', '상태', '출입일시', '퇴실일시']);
|
||||
for (const visit of visits) {
|
||||
sheet.addRow([visit.visitorName, visit.company ?? '', visit.zoneName ?? '', visit.purpose, visit.workName ?? '', visit.status, visit.visitFrom, visit.visitTo]);
|
||||
}
|
||||
sheet.getRow(1).font = { bold: true };
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(Buffer.from(buffer));
|
||||
}));
|
||||
|
||||
router.get('/admin/audit', asyncRoute(async (req, res) => {
|
||||
await requireAdmin(dbQuery, req);
|
||||
const result = await dbQuery('SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 300');
|
||||
ok(res, result.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
at: toIso(row.created_at)!,
|
||||
actorId: row.actor_id ? Number(row.actor_id) : undefined,
|
||||
actorUsername: row.actor_username ?? undefined,
|
||||
action: row.action,
|
||||
targetType: row.target_type ?? undefined,
|
||||
targetId: row.target_id ? Number(row.target_id) : undefined,
|
||||
detail: row.detail ?? undefined,
|
||||
})));
|
||||
}));
|
||||
|
||||
router.get('/admin/deliveries', asyncRoute(async (req, res) => {
|
||||
await requireAdmin(dbQuery, req);
|
||||
const status = req.query.status ? String(req.query.status) : null;
|
||||
const result = await dbQuery(
|
||||
`SELECT * FROM pass_deliveries ${status ? 'WHERE status = $1' : ''} ORDER BY created_at DESC LIMIT 300`,
|
||||
status ? [status] : [],
|
||||
);
|
||||
ok(res, result.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
visitRequestId: Number(row.visit_request_id),
|
||||
channel: row.channel ?? undefined,
|
||||
recipient: row.recipient ?? undefined,
|
||||
status: row.status,
|
||||
attempts: Number(row.attempts),
|
||||
lastError: row.last_error ?? undefined,
|
||||
createdAt: toIso(row.created_at)!,
|
||||
updatedAt: toIso(row.updated_at)!,
|
||||
})));
|
||||
}));
|
||||
|
||||
router.post('/admin/deliveries/:id/retry', asyncRoute(async (req, res) => {
|
||||
await requireAdmin(dbQuery, req);
|
||||
const result = await dbQuery(
|
||||
"UPDATE pass_deliveries SET attempts = attempts + 1, status = 'SENT', last_error = NULL, updated_at = now() WHERE id = $1 RETURNING *",
|
||||
[Number(req.params.id)],
|
||||
);
|
||||
ok(res, {
|
||||
id: Number(result.rows[0].id),
|
||||
visitRequestId: Number(result.rows[0].visit_request_id),
|
||||
channel: result.rows[0].channel ?? undefined,
|
||||
recipient: result.rows[0].recipient ?? undefined,
|
||||
status: result.rows[0].status,
|
||||
attempts: Number(result.rows[0].attempts),
|
||||
lastError: result.rows[0].last_error ?? undefined,
|
||||
createdAt: toIso(result.rows[0].created_at)!,
|
||||
updatedAt: toIso(result.rows[0].updated_at)!,
|
||||
});
|
||||
}));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export const businessRouter = createBusinessRouter();
|
||||
Reference in New Issue
Block a user