feat: send visitor pass link on approval
This commit is contained in:
@@ -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` 성공.
|
||||
|
||||
@@ -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://'),
|
||||
|
||||
@@ -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
79
server/services/sms.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user