feat: add public visitor application flow

This commit is contained in:
unknown
2026-08-03 17:11:04 +09:00
parent 7ff45fb4ec
commit 7239c21a7d
10 changed files with 1041 additions and 13 deletions

View File

@@ -3,7 +3,7 @@ import { Router } from 'express';
import multer from 'multer';
import ExcelJS from 'exceljs';
import QRCode from 'qrcode';
import { randomUUID } from 'node:crypto';
import { createHash, randomInt, randomUUID } from 'node:crypto';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { ok } from '../http/apiResponse.js';
@@ -597,6 +597,87 @@ async function createOneVisit(dbQuery: QueryFn, actor: UserRow, body: Record<str
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;
@@ -612,6 +693,170 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
})));
}));
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));
@@ -652,6 +897,19 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
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);
}));