feat: add public visitor application flow
This commit is contained in:
@@ -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);
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user