feat: send visitor pass link on approval
This commit is contained in:
@@ -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)!,
|
||||
});
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user