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

@@ -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` 성공.

View File

@@ -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://'),

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)!,
});
}));

79
server/services/sms.ts Normal file
View File

@@ -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<SmsSendResult> {
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);
}
}