From a1c82d819d9df340e68925c52960faee48fcfd20 Mon Sep 17 00:00:00 2001 From: unknown <0420301@BOKNET.INTRA> Date: Thu, 16 Jul 2026 14:14:47 +0900 Subject: [PATCH] feat: send visitor pass link on approval --- docs/ACS작업일지.md | 15 +++++++ server/config/env.ts | 4 ++ server/routes/business.ts | 93 +++++++++++++++++++++++++++++++-------- server/services/sms.ts | 79 +++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 18 deletions(-) create mode 100644 server/services/sms.ts diff --git a/docs/ACS작업일지.md b/docs/ACS작업일지.md index aeba566..d9c4e48 100644 --- a/docs/ACS작업일지.md +++ b/docs/ACS작업일지.md @@ -780,3 +780,18 @@ - `npm.cmd run typecheck` 성공. - `npm.cmd run build` 성공. - `npm.cmd test` 성공. + +- 2026-07-16 승인 시 방문자 QR 문자 발송 구조 추가: + - 관리자 승인(`POST /api/approvals/{id}/approve`) 시 방문자 연락처로 공개 출입증 링크(`/pass/{qrToken}`)를 SMS 전송하도록 Node 서버에 `sendSms` 어댑터 추가. + - 문자 본문은 방문 승인 안내, 방문자명, 출입일시, 출입증(QR) URL을 포함. + - QR 이미지를 MMS로 직접 첨부하지 않고, 방문자 휴대폰에서 공개 출입증 URL을 열면 QR Code가 표시되는 방식으로 구성. + - 문자 발송 실패가 승인 자체를 롤백하지 않도록 하고, 성공/실패 및 오류는 `pass_deliveries`에 기록. + - 발송내역 화면의 재발송 버튼이 실제 SMS 재전송을 수행하도록 변경. + - SMS 환경변수: + - `ACS_SMS_PROVIDER=dev`: 실제 발송 없이 서버 로그 및 발송내역 성공 처리. + - `ACS_SMS_PROVIDER=http`: `ACS_SMS_API_URL`로 HTTP POST 전송. + - 선택값: `ACS_SMS_API_KEY`, `ACS_SMS_SENDER`, `ACS_SMS_TIMEOUT_MS`. + - 검증: + - `npm.cmd run typecheck` 성공. + - `npm.cmd test` 성공. + - `npm.cmd run build` 성공. diff --git a/server/config/env.ts b/server/config/env.ts index 70618ea..0e5ad36 100644 --- a/server/config/env.ts +++ b/server/config/env.ts @@ -18,6 +18,10 @@ export const env = { sessionSecret: process.env.SESSION_SECRET ?? 'dev-only-change-me', publicBaseUrl: process.env.ACS_PUBLIC_BASE_URL ?? 'http://localhost:3000', smsProvider: process.env.ACS_SMS_PROVIDER ?? 'dev', + smsApiUrl: process.env.ACS_SMS_API_URL, + smsApiKey: process.env.ACS_SMS_API_KEY, + smsSender: process.env.ACS_SMS_SENDER, + smsTimeoutMs: Number(process.env.ACS_SMS_TIMEOUT_MS ?? 10000), cookieSecure: (process.env.ACS_COOKIE_SECURE ?? '').toLowerCase() === 'true' || (process.env.ACS_PUBLIC_BASE_URL ?? '').startsWith('https://'), diff --git a/server/routes/business.ts b/server/routes/business.ts index 7055c59..a7cad96 100644 --- a/server/routes/business.ts +++ b/server/routes/business.ts @@ -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)!, }); })); diff --git a/server/services/sms.ts b/server/services/sms.ts new file mode 100644 index 0000000..ba9ea4c --- /dev/null +++ b/server/services/sms.ts @@ -0,0 +1,79 @@ +import { env } from '../config/env.js'; + +export interface SmsSendResult { + channel: string; + recipient: string; + status: 'SENT' | 'FAILED'; + lastError: string | null; +} + +export interface SmsMessage { + to: string; + text: string; +} + +function normalizePhone(value: string | null | undefined): string { + return String(value ?? '').replace(/[^\d+]/g, ''); +} + +function fail(channel: string, recipient: string, error: unknown): SmsSendResult { + const message = error instanceof Error ? error.message : String(error); + return { + channel, + recipient, + status: 'FAILED', + lastError: message.slice(0, 500), + }; +} + +export async function sendSms(message: SmsMessage): Promise { + const provider = env.smsProvider.toLowerCase(); + const recipient = normalizePhone(message.to); + + if (!recipient) { + return fail(provider.toUpperCase(), recipient, '수신자 휴대폰 번호가 없습니다.'); + } + + if (provider === 'dev') { + console.log('[sms:dev]', { to: recipient, text: message.text }); + return { channel: 'DEV', recipient, status: 'SENT', lastError: null }; + } + + if (provider !== 'http') { + return fail(provider.toUpperCase(), recipient, `지원하지 않는 SMS provider입니다: ${env.smsProvider}`); + } + + if (!env.smsApiUrl) { + return fail('SMS', recipient, 'ACS_SMS_API_URL이 설정되지 않았습니다.'); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), env.smsTimeoutMs); + + try { + const response = await fetch(env.smsApiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(env.smsApiKey ? { Authorization: `Bearer ${env.smsApiKey}` } : {}), + }, + body: JSON.stringify({ + to: recipient, + from: env.smsSender, + message: message.text, + }), + signal: controller.signal, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`SMS API HTTP ${response.status}${text ? `: ${text.slice(0, 300)}` : ''}`); + } + + return { channel: 'SMS', recipient, status: 'SENT', lastError: null }; + } catch (error) { + return fail('SMS', recipient, error); + } finally { + clearTimeout(timeout); + } +}