feat: send visitor pass link on approval

This commit is contained in:
unknown
2026-07-16 14:14:47 +09:00
parent cc79abde44
commit a1c82d819d
4 changed files with 173 additions and 18 deletions

View File

@@ -10,6 +10,7 @@ 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;
@@ -209,6 +210,67 @@ async function audit(dbQuery: QueryFn, actor: UserRow, action: string, targetTyp
);
}
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];
}
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 }>(
@@ -511,12 +573,10 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
"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))!));
const visit = (await visitById(dbQuery, id))!;
await sendPassDelivery(dbQuery, visit);
ok(res, toVisit(visit));
}));
router.post('/approvals/:id/reject', asyncRoute(async (req, res) => {
@@ -825,20 +885,17 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
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)],
);
const row = await retryPassDelivery(dbQuery, 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)!,
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)!,
});
}));