ตรวจสอบแก้ไชสร้างเอกสาร

This commit is contained in:
phaichayon
2026-06-30 17:35:03 +07:00
parent a4fd166c51
commit e5766ea311
40 changed files with 8288 additions and 106 deletions

View File

@@ -0,0 +1,83 @@
import 'server-only';
import {
downloadArtifact,
} from '@/features/foundation/document-artifact/server/service';
import {
downloadActiveDocumentLibraryVersion,
downloadDocumentLibraryVersion,
} from '@/features/foundation/document-library/server/service';
import type {
NotificationAttachmentRef,
ResolvedNotificationAttachment,
} from '../types';
async function streamToBuffer(stream: ReadableStream<Uint8Array>) {
return Buffer.from(await new Response(stream).arrayBuffer());
}
export async function resolveNotificationAttachments(input: {
organizationId: string;
userId: string;
attachments: NotificationAttachmentRef[];
}): Promise<ResolvedNotificationAttachment[]> {
return Promise.all(
input.attachments.map(async (attachment) => {
if (attachment.type === 'artifact') {
const result = await downloadArtifact({
artifactId: attachment.artifactId,
organizationId: input.organizationId,
userId: input.userId,
auditDownload: false,
});
return {
sourceType: 'artifact' as const,
sourceId: attachment.artifactId,
fileName: result.object.fileName ?? result.artifact.fileName,
contentType: result.object.contentType,
size: result.object.size,
checksum: result.object.checksum ?? null,
label: attachment.label ?? null,
buffer: await streamToBuffer(result.object.body),
};
}
if (attachment.type === 'document_library_version') {
const result = await downloadDocumentLibraryVersion({
versionId: attachment.versionId,
organizationId: input.organizationId,
userId: input.userId,
});
return {
sourceType: 'document_library_version' as const,
sourceId: attachment.versionId,
fileName: result.object.fileName,
contentType: result.object.contentType,
size: result.object.size,
checksum: result.object.checksum ?? null,
label: attachment.label ?? null,
buffer: await streamToBuffer(result.object.body),
};
}
const result = await downloadActiveDocumentLibraryVersion({
libraryId: attachment.libraryId,
organizationId: input.organizationId,
userId: input.userId,
});
return {
sourceType: 'active_document_library' as const,
sourceId: attachment.libraryId,
fileName: result.object.fileName,
contentType: result.object.contentType,
size: result.object.size,
checksum: result.object.checksum ?? null,
label: attachment.label ?? null,
buffer: await streamToBuffer(result.object.body),
};
}),
);
}

View File

@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executeNotificationDispatch } from './delivery-engine';
import type { NotificationProvider } from '../types';
function createProvider(options: {
validateError?: string;
sendError?: string;
}): NotificationProvider {
return {
channel: 'email',
key: 'smtp',
async validate() {
if (options.validateError) {
throw new Error(options.validateError);
}
},
async healthCheck() {
return { ok: true };
},
async send() {
if (options.sendError) {
throw new Error(options.sendError);
}
return {
providerMessageId: 'msg-1',
acceptedRecipients: ['to@example.com'],
rejectedRecipients: [],
};
},
};
}
function createMessage() {
return {
dispatchId: 'dispatch-1',
subject: 'Test',
bodyText: 'Plain',
bodyHtml: null,
to: [{ email: 'to@example.com', name: null }],
cc: [],
bcc: [],
replyTo: null,
attachments: [],
attachmentBuffers: [],
};
}
test('executeNotificationDispatch marks success when provider sends', async () => {
const result = await executeNotificationDispatch({
provider: createProvider({}),
message: createMessage(),
retryCount: 0,
maxRetryCount: 3,
});
assert.equal(result.status, 'sent');
assert.equal(result.retryCount, 1);
assert.equal(result.errorMessage, null);
assert.equal(result.providerResult?.providerMessageId, 'msg-1');
assert.ok(result.sentAt instanceof Date);
});
test('executeNotificationDispatch schedules retry before max retry count', async () => {
const result = await executeNotificationDispatch({
provider: createProvider({ sendError: 'SMTP offline' }),
message: createMessage(),
retryCount: 0,
maxRetryCount: 3,
});
assert.equal(result.status, 'retry');
assert.equal(result.retryCount, 1);
assert.equal(result.errorMessage, 'SMTP offline');
assert.equal(result.sentAt, null);
});
test('executeNotificationDispatch fails permanently at retry limit', async () => {
const result = await executeNotificationDispatch({
provider: createProvider({ validateError: 'SMTP host is not configured.' }),
message: createMessage(),
retryCount: 2,
maxRetryCount: 3,
});
assert.equal(result.status, 'failed');
assert.equal(result.retryCount, 3);
assert.equal(result.errorMessage, 'SMTP host is not configured.');
});

View File

@@ -0,0 +1,62 @@
import type {
NotificationDispatchStatus,
NotificationProvider,
NotificationProviderSendInput,
NotificationProviderSendResult,
} from '../types';
export type NotificationDispatchExecutionInput = {
provider: NotificationProvider;
message: NotificationProviderSendInput;
retryCount: number;
maxRetryCount: number;
};
export type NotificationDispatchExecutionResult = {
status: NotificationDispatchStatus;
retryCount: number;
sentAt: Date | null;
providerResult: NotificationProviderSendResult | null;
errorMessage: string | null;
attemptedAt: Date;
};
function normalizeProviderError(error: unknown) {
if (error instanceof Error) {
return error.message.slice(0, 500);
}
return 'Unknown notification delivery error';
}
export async function executeNotificationDispatch(
input: NotificationDispatchExecutionInput,
): Promise<NotificationDispatchExecutionResult> {
const attemptedAt = new Date();
try {
await input.provider.validate();
const providerResult = await input.provider.send(input.message);
return {
status: 'sent',
retryCount: input.retryCount + 1,
sentAt: attemptedAt,
providerResult,
errorMessage: null,
attemptedAt,
};
} catch (error) {
const retryCount = input.retryCount + 1;
const shouldRetry = retryCount < input.maxRetryCount;
return {
status: shouldRetry ? 'retry' : 'failed',
retryCount,
sentAt: null,
providerResult: null,
errorMessage: normalizeProviderError(error),
attemptedAt,
};
}
}

View File

@@ -0,0 +1,134 @@
import 'server-only';
import type { Transporter } from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import type {
NotificationChannel,
NotificationProvider,
NotificationProviderSendInput,
NotificationProviderSendResult,
} from '../types';
type SmtpConfig = {
host: string;
port: number;
secure: boolean;
user?: string;
pass?: string;
from: string;
};
function parseBoolean(value: string | undefined, fallback: boolean) {
if (!value) {
return fallback;
}
return value === 'true' || value === '1';
}
function getSmtpConfig(): SmtpConfig {
const host = process.env.SMTP_HOST?.trim() ?? '';
const port = Number(process.env.SMTP_PORT ?? '587');
const secure = parseBoolean(process.env.SMTP_SECURE, port === 465);
const user = process.env.SMTP_USER?.trim();
const pass = process.env.SMTP_PASS?.trim();
const from = process.env.SMTP_FROM?.trim() ?? '';
return { host, port, secure, user, pass, from };
}
async function createTransporter() {
const nodemailer = await import('nodemailer');
const config = getSmtpConfig();
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
} satisfies SMTPTransport.Options);
}
function formatRecipients(
recipients: NotificationProviderSendInput['to'],
): string[] {
return recipients.map((recipient) =>
recipient.name?.trim()
? `"${recipient.name.trim()}" <${recipient.email}>`
: recipient.email,
);
}
export class EmailNotificationProvider implements NotificationProvider {
readonly channel: NotificationChannel = 'email';
readonly key = 'smtp';
async validate() {
const config = getSmtpConfig();
if (!config.host) {
throw new Error('SMTP host is not configured.');
}
if (!Number.isFinite(config.port) || config.port <= 0) {
throw new Error('SMTP port is invalid.');
}
if (!config.from) {
throw new Error('SMTP from address is not configured.');
}
}
async healthCheck() {
try {
await this.validate();
const transporter = await createTransporter();
await transporter.verify();
return { ok: true as const };
} catch (error) {
return {
ok: false as const,
detail: error instanceof Error ? error.message : 'Unknown SMTP health check failure',
};
}
}
async send(
input: NotificationProviderSendInput,
): Promise<NotificationProviderSendResult> {
await this.validate();
const transporter = (await createTransporter()) as Transporter;
const config = getSmtpConfig();
const info = await transporter.sendMail({
from: config.from,
to: formatRecipients(input.to),
cc: input.cc.length ? formatRecipients(input.cc) : undefined,
bcc: input.bcc.length ? formatRecipients(input.bcc) : undefined,
replyTo: input.replyTo ?? undefined,
subject: input.subject,
text: input.bodyText,
html: input.bodyHtml ?? undefined,
attachments: input.attachments.map((attachment, index) => ({
filename: attachment.fileName,
content: input.attachmentBuffers[index],
contentType: attachment.contentType,
})),
});
return {
providerMessageId: info.messageId ?? null,
acceptedRecipients: Array.isArray(info.accepted)
? info.accepted.map((value: string | { address: string }) => String(value))
: [],
rejectedRecipients: Array.isArray(info.rejected)
? info.rejected.map((value: string | { address: string }) => String(value))
: [],
metadata: {
envelope: info.envelope,
response: info.response ?? null,
},
};
}
}

View File

@@ -102,10 +102,19 @@ function mapTemplateRecord(
id: row.id,
organizationId: row.organizationId,
eventType: row.eventType,
code: row.code,
name: row.name,
channel: row.channel as NotificationTemplateRecord['channel'],
subjectTemplate: row.subjectTemplate ?? null,
titleTemplate: row.titleTemplate,
bodyTemplate: row.bodyTemplate,
bodyFormat: row.bodyFormat as NotificationTemplateRecord['bodyFormat'],
linkTemplate: row.linkTemplate ?? null,
language: row.language,
variables: Array.isArray(row.variables)
? row.variables.filter((value): value is string => typeof value === 'string')
: [],
version: row.version,
isActive: row.isActive,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
createdAt: row.createdAt.toISOString(),
@@ -239,7 +248,7 @@ export async function processNotificationEvent(eventId: string) {
recipientUserId,
eventId: event.id,
title: rendered.title,
body: rendered.body,
body: rendered.bodyText,
linkUrl: rendered.linkUrl,
severity: rule.severity,
status: 'unread',

View File

@@ -0,0 +1,21 @@
import 'server-only';
import { EmailNotificationProvider } from './email-provider';
import type { NotificationChannel, NotificationProvider } from '../types';
export function getNotificationProvider(
channel: NotificationChannel,
providerKey?: string,
): NotificationProvider {
if (channel === 'email') {
const provider = new EmailNotificationProvider();
if (providerKey && provider.key !== providerKey) {
throw new Error(`Unsupported email provider: ${providerKey}`);
}
return provider;
}
throw new Error(`Notification provider for channel ${channel} is not implemented yet.`);
}

View File

@@ -0,0 +1,733 @@
import 'server-only';
import { and, count, desc, eq, ilike, isNull, or } from 'drizzle-orm';
import {
appNotificationDispatches,
appNotificationEvents,
appNotificationTemplates,
} from '@/db/schema';
import { auditAction } from '@/features/foundation/audit-log/service';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { resolveNotificationAttachments } from './attachment-service';
import { executeNotificationDispatch } from './delivery-engine';
import { getNotificationProvider } from './provider-factory';
import { renderNotificationTemplate } from './template-renderer';
import type {
NotificationDispatchRecord,
NotificationDispatchRecipient,
NotificationEventRecord,
NotificationHistoryFilters,
NotificationPreviewRequest,
NotificationRenderResult,
NotificationSendRequest,
NotificationTemplateRecord,
ResolvedNotificationAttachment,
} from '../types';
type TemplateOverride = NotificationPreviewRequest['templateOverride'];
function normalizeDispatchRecipients(value: unknown): NotificationDispatchRecipient[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item) => {
if (!item || typeof item !== 'object') {
return [];
}
const email = typeof (item as { email?: unknown }).email === 'string'
? (item as { email: string }).email.trim().toLowerCase()
: '';
if (!email) {
return [];
}
const name = typeof (item as { name?: unknown }).name === 'string'
? (item as { name: string }).name.trim()
: null;
return [{ email, name }];
});
}
function normalizeAttachments(
value: unknown,
): Array<Omit<ResolvedNotificationAttachment, 'buffer'>> {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item) => {
if (!item || typeof item !== 'object') {
return [];
}
const candidate = item as Record<string, unknown>;
if (
typeof candidate.sourceType !== 'string' ||
typeof candidate.sourceId !== 'string' ||
typeof candidate.fileName !== 'string' ||
typeof candidate.contentType !== 'string' ||
typeof candidate.size !== 'number'
) {
return [];
}
return [
{
sourceType: candidate.sourceType as ResolvedNotificationAttachment['sourceType'],
sourceId: candidate.sourceId,
fileName: candidate.fileName,
contentType: candidate.contentType,
size: candidate.size,
checksum:
typeof candidate.checksum === 'string' ? candidate.checksum : null,
label: typeof candidate.label === 'string' ? candidate.label : null,
},
];
});
}
function mapEventRecord(row: typeof appNotificationEvents.$inferSelect): NotificationEventRecord {
return {
id: row.id,
organizationId: row.organizationId,
eventType: row.eventType,
entityType: row.entityType,
entityId: row.entityId,
actorUserId: row.actorUserId ?? null,
payload: (row.payload as Record<string, unknown> | null) ?? null,
dedupeKey: row.dedupeKey ?? null,
status: row.status as NotificationEventRecord['status'],
lastError: row.lastError ?? null,
publishedAt: row.publishedAt.toISOString(),
processedAt: row.processedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function mapTemplateRecord(
row: typeof appNotificationTemplates.$inferSelect,
): NotificationTemplateRecord {
return {
id: row.id,
organizationId: row.organizationId,
eventType: row.eventType,
code: row.code,
name: row.name,
channel: row.channel as NotificationTemplateRecord['channel'],
subjectTemplate: row.subjectTemplate ?? null,
titleTemplate: row.titleTemplate,
bodyTemplate: row.bodyTemplate,
bodyFormat: row.bodyFormat as NotificationTemplateRecord['bodyFormat'],
linkTemplate: row.linkTemplate ?? null,
language: row.language,
variables: Array.isArray(row.variables)
? row.variables.filter((value): value is string => typeof value === 'string')
: [],
version: row.version,
isActive: row.isActive,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
createdBy: row.createdBy,
updatedBy: row.updatedBy,
};
}
function mapDispatchRecord(
row: typeof appNotificationDispatches.$inferSelect,
): NotificationDispatchRecord {
return {
id: row.id,
organizationId: row.organizationId,
eventId: row.eventId,
templateId: row.templateId ?? null,
entityType: row.entityType,
entityId: row.entityId,
channel: row.channel as NotificationDispatchRecord['channel'],
providerKey: row.providerKey,
status: row.status as NotificationDispatchRecord['status'],
recipientTo: normalizeDispatchRecipients(row.recipientTo),
recipientCc: normalizeDispatchRecipients(row.recipientCc),
recipientBcc: normalizeDispatchRecipients(row.recipientBcc),
replyTo: row.replyTo ?? null,
subject: row.subject,
bodyText: row.bodyText,
bodyHtml: row.bodyHtml ?? null,
attachments: normalizeAttachments(row.attachments),
recipientUserId: row.recipientUserId ?? null,
recipientAddress: row.recipientAddress ?? null,
requestedBy: row.requestedBy,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
lastError: row.lastError ?? null,
retryCount: row.retryCount,
maxRetryCount: row.maxRetryCount,
lastAttemptAt: row.lastAttemptAt?.toISOString() ?? null,
sentAt: row.sentAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function normalizeEmailAddress(value: string) {
return value.trim().toLowerCase();
}
function assertRecipients(recipients: NotificationDispatchRecipient[], label: string) {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!recipients.length) {
if (label === 'to') {
throw new AuthError('At least one recipient is required.', 400);
}
return;
}
for (const recipient of recipients) {
if (!emailPattern.test(recipient.email)) {
throw new AuthError(`Invalid ${label} email: ${recipient.email}`, 400);
}
}
}
async function loadTemplate(
organizationId: string,
eventType: string,
channel: NotificationPreviewRequest['channel'],
) {
const rows = await db
.select()
.from(appNotificationTemplates)
.where(
and(
eq(appNotificationTemplates.organizationId, organizationId),
eq(appNotificationTemplates.eventType, eventType),
eq(appNotificationTemplates.isActive, true),
isNull(appNotificationTemplates.deletedAt),
),
)
.limit(10);
const row =
rows.find((candidate) => candidate.channel === channel) ??
rows.find((candidate) => candidate.channel === 'in_app') ??
rows[0];
return row ? mapTemplateRecord(row) : null;
}
async function ensureEvent(input: {
organizationId: string;
eventType: string;
entityType: string;
entityId: string;
actorUserId: string;
payload: Record<string, unknown>;
}) {
const [created] = await db
.insert(appNotificationEvents)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
eventType: input.eventType,
entityType: input.entityType,
entityId: input.entityId,
actorUserId: input.actorUserId,
payload: input.payload,
status: 'pending',
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.actorUserId,
entityType: 'app_notification_event',
entityId: created.id,
action: 'notification_requested',
afterData: {
eventType: input.eventType,
entityType: input.entityType,
entityId: input.entityId,
},
});
return mapEventRecord(created);
}
function buildTemplateFromOverride(
template: NotificationTemplateRecord,
override?: TemplateOverride,
): NotificationTemplateRecord {
if (!override) {
return template;
}
return {
...template,
subjectTemplate: override.subjectTemplate ?? template.subjectTemplate,
titleTemplate: override.titleTemplate ?? template.titleTemplate,
bodyTemplate: override.bodyTemplate ?? template.bodyTemplate,
bodyFormat: override.bodyFormat ?? template.bodyFormat,
linkTemplate:
override.linkTemplate === undefined
? template.linkTemplate
: override.linkTemplate,
};
}
async function buildDispatchPreview(
input: NotificationPreviewRequest,
): Promise<{
template: NotificationTemplateRecord;
attachments: ResolvedNotificationAttachment[];
rendered: NotificationRenderResult;
providerKey: string;
}> {
const to = input.recipientOverride.to.map((recipient) => ({
...recipient,
email: normalizeEmailAddress(recipient.email),
}));
const cc = (input.recipientOverride.cc ?? []).map((recipient) => ({
...recipient,
email: normalizeEmailAddress(recipient.email),
}));
const bcc = (input.recipientOverride.bcc ?? []).map((recipient) => ({
...recipient,
email: normalizeEmailAddress(recipient.email),
}));
assertRecipients(to, 'to');
assertRecipients(cc, 'cc');
assertRecipients(bcc, 'bcc');
const template = await loadTemplate(
input.organizationId,
input.eventType,
input.channel,
);
if (!template) {
throw new AuthError(
`Notification template not found for ${input.eventType} (${input.channel}).`,
404,
);
}
const nextTemplate = buildTemplateFromOverride(template, input.templateOverride);
const rendered = renderNotificationTemplate(
{
subjectTemplate: nextTemplate.subjectTemplate,
titleTemplate: nextTemplate.titleTemplate,
bodyTemplate: nextTemplate.bodyTemplate,
bodyFormat: nextTemplate.bodyFormat,
linkTemplate: nextTemplate.linkTemplate,
},
input.payload ?? {},
);
const attachments = input.attachments?.length
? await resolveNotificationAttachments({
organizationId: input.organizationId,
userId: input.actorUserId,
attachments: input.attachments,
})
: [];
const providerKey = input.providerKey ?? (input.channel === 'email' ? 'smtp' : input.channel);
return { template: nextTemplate, attachments, rendered, providerKey };
}
export async function previewNotification(input: NotificationPreviewRequest) {
const preview = await buildDispatchPreview(input);
return {
eventType: input.eventType,
entityType: input.entityType,
entityId: input.entityId,
channel: input.channel,
providerKey: preview.providerKey,
template: preview.template,
rendered: preview.rendered,
recipients: input.recipientOverride,
attachments: preview.attachments.map(({ buffer: _buffer, ...attachment }) => attachment),
};
}
async function persistDispatch(input: {
request: NotificationSendRequest;
event: NotificationEventRecord;
template: NotificationTemplateRecord;
rendered: NotificationRenderResult;
attachments: ResolvedNotificationAttachment[];
providerKey: string;
}) {
const [created] = await db
.insert(appNotificationDispatches)
.values({
id: crypto.randomUUID(),
organizationId: input.request.organizationId,
eventId: input.event.id,
templateId: input.template.id,
entityType: input.request.entityType,
entityId: input.request.entityId,
channel: input.request.channel,
providerKey: input.providerKey,
status: 'pending',
recipientTo: input.request.recipientOverride.to,
recipientCc: input.request.recipientOverride.cc ?? [],
recipientBcc: input.request.recipientOverride.bcc ?? [],
replyTo: input.request.recipientOverride.replyTo ?? null,
subject: input.rendered.subject,
bodyText: input.rendered.bodyText,
bodyHtml: input.rendered.bodyHtml,
attachments: input.attachments.map(({ buffer: _buffer, ...attachment }) => attachment),
recipientUserId: input.request.recipientOverride.recipientUserId ?? null,
recipientAddress: input.request.recipientOverride.to[0]?.email ?? null,
requestedBy: input.request.actorUserId,
metadata: {
payload: input.request.payload ?? {},
linkUrl: input.rendered.linkUrl,
attachmentRefs: input.request.attachments ?? [],
templateOverride: input.request.templateOverride ?? null,
},
retryCount: 0,
maxRetryCount: input.request.maxRetryCount ?? 3,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
await auditAction({
organizationId: input.request.organizationId,
userId: input.request.actorUserId,
entityType: 'app_notification_dispatch',
entityId: created.id,
action: 'notification_provider_selected',
afterData: {
providerKey: input.providerKey,
channel: input.request.channel,
recipientAddress: created.recipientAddress,
},
});
return mapDispatchRecord(created);
}
async function runDispatch(input: {
organizationId: string;
actorUserId: string;
dispatch: NotificationDispatchRecord;
attachments: ResolvedNotificationAttachment[];
}) {
const provider = getNotificationProvider(input.dispatch.channel, input.dispatch.providerKey);
await db
.update(appNotificationDispatches)
.set({
status: 'sending',
lastAttemptAt: new Date(),
updatedAt: new Date(),
})
.where(eq(appNotificationDispatches.id, input.dispatch.id));
await auditAction({
organizationId: input.organizationId,
userId: input.actorUserId,
entityType: 'app_notification_dispatch',
entityId: input.dispatch.id,
action: 'notification_send_started',
afterData: {
providerKey: input.dispatch.providerKey,
channel: input.dispatch.channel,
},
});
const result = await executeNotificationDispatch({
provider,
retryCount: input.dispatch.retryCount,
maxRetryCount: input.dispatch.maxRetryCount,
message: {
dispatchId: input.dispatch.id,
subject: input.dispatch.subject,
bodyText: input.dispatch.bodyText,
bodyHtml: input.dispatch.bodyHtml,
to: input.dispatch.recipientTo,
cc: input.dispatch.recipientCc,
bcc: input.dispatch.recipientBcc,
replyTo: input.dispatch.replyTo,
attachments: input.dispatch.attachments,
attachmentBuffers: input.attachments.map((attachment) => attachment.buffer),
},
});
const [updated] = await db
.update(appNotificationDispatches)
.set({
status: result.status,
retryCount: result.retryCount,
lastError: result.errorMessage,
lastAttemptAt: result.attemptedAt,
sentAt: result.sentAt,
metadata: {
...(input.dispatch.metadata ?? {}),
providerResult: result.providerResult,
},
updatedAt: new Date(),
})
.where(eq(appNotificationDispatches.id, input.dispatch.id))
.returning();
await db
.update(appNotificationEvents)
.set({
status: result.status === 'sent' ? 'processed' : 'failed',
lastError: result.errorMessage,
processedAt: result.sentAt,
updatedAt: new Date(),
})
.where(eq(appNotificationEvents.id, input.dispatch.eventId));
await auditAction({
organizationId: input.organizationId,
userId: input.actorUserId,
entityType: 'app_notification_dispatch',
entityId: input.dispatch.id,
action:
result.status === 'sent'
? 'notification_send_succeeded'
: 'notification_send_failed',
afterData: {
status: result.status,
retryCount: result.retryCount,
error: result.errorMessage,
},
});
return mapDispatchRecord(updated);
}
export async function sendNotification(input: NotificationSendRequest) {
const preview = await buildDispatchPreview(input);
const event = await ensureEvent({
organizationId: input.organizationId,
eventType: input.eventType,
entityType: input.entityType,
entityId: input.entityId,
actorUserId: input.actorUserId,
payload: input.payload ?? {},
});
const dispatch = await persistDispatch({
request: input,
event,
template: preview.template,
rendered: preview.rendered,
attachments: preview.attachments,
providerKey: preview.providerKey,
});
return runDispatch({
organizationId: input.organizationId,
actorUserId: input.actorUserId,
dispatch,
attachments: preview.attachments,
});
}
export async function listNotificationHistory(
organizationId: string,
filters: NotificationHistoryFilters,
) {
const page = Math.max(filters.page ?? 1, 1);
const limit = Math.min(Math.max(filters.limit ?? 20, 1), 100);
const where = and(
eq(appNotificationDispatches.organizationId, organizationId),
...(filters.channel && filters.channel !== 'all'
? [eq(appNotificationDispatches.channel, filters.channel)]
: []),
...(filters.status && filters.status !== 'all'
? [eq(appNotificationDispatches.status, filters.status)]
: []),
...(filters.eventType ? [eq(appNotificationEvents.eventType, filters.eventType)] : []),
...(filters.entityType ? [eq(appNotificationDispatches.entityType, filters.entityType)] : []),
...(filters.entityId ? [eq(appNotificationDispatches.entityId, filters.entityId)] : []),
...(filters.search
? [
or(
ilike(appNotificationDispatches.subject, `%${filters.search}%`),
ilike(appNotificationDispatches.recipientAddress, `%${filters.search}%`),
)!,
]
: []),
);
const [rows, totalRow] = await Promise.all([
db
.select({
dispatch: appNotificationDispatches,
})
.from(appNotificationDispatches)
.innerJoin(
appNotificationEvents,
eq(appNotificationEvents.id, appNotificationDispatches.eventId),
)
.where(where)
.orderBy(desc(appNotificationDispatches.createdAt))
.limit(limit)
.offset((page - 1) * limit),
db
.select({ value: count() })
.from(appNotificationDispatches)
.innerJoin(
appNotificationEvents,
eq(appNotificationEvents.id, appNotificationDispatches.eventId),
)
.where(where),
]);
return {
items: rows.map((row) => mapDispatchRecord(row.dispatch)),
page,
limit,
totalItems: totalRow[0]?.value ?? 0,
totalPages: Math.max(Math.ceil((totalRow[0]?.value ?? 0) / limit), 1),
};
}
export async function getNotificationDispatchDetail(
organizationId: string,
dispatchId: string,
) {
const [row] = await db
.select({
dispatch: appNotificationDispatches,
event: appNotificationEvents,
template: appNotificationTemplates,
})
.from(appNotificationDispatches)
.innerJoin(
appNotificationEvents,
eq(appNotificationEvents.id, appNotificationDispatches.eventId),
)
.leftJoin(
appNotificationTemplates,
eq(appNotificationTemplates.id, appNotificationDispatches.templateId),
)
.where(
and(
eq(appNotificationDispatches.id, dispatchId),
eq(appNotificationDispatches.organizationId, organizationId),
),
)
.limit(1);
if (!row) {
throw new AuthError('Notification history item not found.', 404);
}
return {
dispatch: mapDispatchRecord(row.dispatch),
event: mapEventRecord(row.event),
template: row.template ? mapTemplateRecord(row.template) : null,
};
}
export async function resendNotification(input: {
organizationId: string;
actorUserId: string;
dispatchId: string;
}) {
const detail = await getNotificationDispatchDetail(
input.organizationId,
input.dispatchId,
);
const attachmentRefs = Array.isArray(detail.dispatch.metadata?.attachmentRefs)
? detail.dispatch.metadata?.attachmentRefs
: [];
const preview = await buildDispatchPreview({
organizationId: input.organizationId,
actorUserId: input.actorUserId,
eventType: detail.event.eventType,
entityType: detail.dispatch.entityType,
entityId: detail.dispatch.entityId,
channel: detail.dispatch.channel,
payload:
(detail.dispatch.metadata?.payload as Record<string, unknown> | undefined) ?? {},
templateOverride:
(detail.dispatch.metadata?.templateOverride as TemplateOverride | undefined) ??
undefined,
recipientOverride: {
to: detail.dispatch.recipientTo,
cc: detail.dispatch.recipientCc,
bcc: detail.dispatch.recipientBcc,
replyTo: detail.dispatch.replyTo,
recipientUserId: detail.dispatch.recipientUserId,
},
attachments: attachmentRefs,
providerKey: detail.dispatch.providerKey,
});
const [created] = await db
.insert(appNotificationDispatches)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
eventId: detail.event.id,
templateId: detail.template?.id ?? null,
entityType: detail.dispatch.entityType,
entityId: detail.dispatch.entityId,
channel: detail.dispatch.channel,
providerKey: detail.dispatch.providerKey,
status: 'pending',
recipientTo: detail.dispatch.recipientTo,
recipientCc: detail.dispatch.recipientCc,
recipientBcc: detail.dispatch.recipientBcc,
replyTo: detail.dispatch.replyTo,
subject: preview.rendered.subject,
bodyText: preview.rendered.bodyText,
bodyHtml: preview.rendered.bodyHtml,
attachments: preview.attachments.map(({ buffer: _buffer, ...attachment }) => attachment),
recipientUserId: detail.dispatch.recipientUserId,
recipientAddress: detail.dispatch.recipientAddress,
requestedBy: input.actorUserId,
metadata: detail.dispatch.metadata,
retryCount: 0,
maxRetryCount: detail.dispatch.maxRetryCount,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.actorUserId,
entityType: 'app_notification_dispatch',
entityId: created.id,
action: 'notification_resend',
afterData: {
originalDispatchId: detail.dispatch.id,
eventId: detail.event.id,
},
});
return runDispatch({
organizationId: input.organizationId,
actorUserId: input.actorUserId,
dispatch: mapDispatchRecord(created),
attachments: preview.attachments,
});
}

View File

@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
extractTemplateVariables,
renderNotificationTemplate,
renderTemplateString,
} from './template-renderer';
test('extractTemplateVariables deduplicates placeholders across template', () => {
assert.deepEqual(extractTemplateVariables('Hello {{name}} {{name}} {{customer.code}}'), [
'name',
'customer.code',
]);
});
test('renderTemplateString replaces nested tokens and defaults missing values', () => {
assert.equal(
renderTemplateString('Quotation {{quotation.code}} for {{customerName}}', {
quotation: { code: 'QT-001' },
customerName: 'ACME',
}),
'Quotation QT-001 for ACME',
);
assert.equal(
renderTemplateString('Missing {{unknown}}', {}),
'Missing -',
);
});
test('renderNotificationTemplate generates html and plain text bodies', () => {
const rendered = renderNotificationTemplate(
{
subjectTemplate: 'Approved {{quotationCode}}',
titleTemplate: 'Approved {{quotationCode}}',
bodyTemplate: '<p>Hello {{customerName}}</p><br/>Ready</p>',
bodyFormat: 'html',
linkTemplate: '/q/{{quotationId}}',
},
{
quotationCode: 'QT-2026-001',
quotationId: 'quo-1',
customerName: 'ACME',
},
);
assert.equal(rendered.subject, 'Approved QT-2026-001');
assert.equal(rendered.bodyHtml, '<p>Hello ACME</p><br/>Ready</p>');
assert.match(rendered.bodyText, /Hello ACME/);
assert.equal(rendered.linkUrl, '/q/quo-1');
assert.deepEqual(rendered.variables.sort(), [
'customerName',
'quotationCode',
'quotationId',
]);
});

View File

@@ -1,4 +1,8 @@
import type { NotificationTemplateRecord } from '../types';
import type {
NotificationBodyFormat,
NotificationRenderResult,
NotificationTemplateRecord,
} from '../types';
const TOKEN_PATTERN = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
@@ -26,21 +30,71 @@ function resolveTokenValue(payload: Record<string, unknown>, token: string): str
return '-';
}
function renderString(template: string | null, payload: Record<string, unknown>): string | null {
export function extractTemplateVariables(template: string | null): string[] {
if (!template) {
return [];
}
return [...new Set(Array.from(template.matchAll(TOKEN_PATTERN), (match) => match[1]))];
}
export function renderTemplateString(
template: string | null,
payload: Record<string, unknown>,
): string | null {
if (!template) {
return null;
}
return template.replace(TOKEN_PATTERN, (_match, token: string) => resolveTokenValue(payload, token));
return template.replace(TOKEN_PATTERN, (_match, token: string) =>
resolveTokenValue(payload, token),
);
}
function toPlainText(body: string, format: NotificationBodyFormat): string {
if (format !== 'html') {
return body;
}
return body
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<[^>]+>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
export function renderNotificationTemplate(
template: Pick<NotificationTemplateRecord, 'titleTemplate' | 'bodyTemplate' | 'linkTemplate'>,
payload: Record<string, unknown>
) {
template: Pick<
NotificationTemplateRecord,
| 'subjectTemplate'
| 'titleTemplate'
| 'bodyTemplate'
| 'bodyFormat'
| 'linkTemplate'
>,
payload: Record<string, unknown>,
): NotificationRenderResult {
const subject =
renderTemplateString(template.subjectTemplate ?? template.titleTemplate, payload) ?? '-';
const title = renderTemplateString(template.titleTemplate, payload) ?? subject;
const renderedBody = renderTemplateString(template.bodyTemplate, payload) ?? '-';
const bodyHtml = template.bodyFormat === 'html' ? renderedBody : null;
const bodyText = toPlainText(renderedBody, template.bodyFormat);
return {
title: renderString(template.titleTemplate, payload) ?? '-',
body: renderString(template.bodyTemplate, payload) ?? '-',
linkUrl: renderString(template.linkTemplate, payload)
subject,
title,
bodyText: bodyText || '-',
bodyHtml,
linkUrl: renderTemplateString(template.linkTemplate, payload),
variables: [
...new Set([
...extractTemplateVariables(template.subjectTemplate),
...extractTemplateVariables(template.titleTemplate),
...extractTemplateVariables(template.bodyTemplate),
...extractTemplateVariables(template.linkTemplate),
]),
],
};
}

View File

@@ -1,16 +1,69 @@
export type NotificationChannel = 'in_app' | 'email' | 'line' | 'webhook';
export type NotificationEventStatus = 'pending' | 'processed' | 'failed';
export type NotificationStatus = 'unread' | 'read' | 'archived';
export type NotificationSeverity = 'info' | 'success' | 'warning' | 'error';
export const NOTIFICATION_CHANNELS = [
'in_app',
'email',
'teams',
'line',
'slack',
'mobile_push',
'sms',
'web_notification',
'webhook',
] as const;
export const NOTIFICATION_EVENT_STATUSES = [
'pending',
'processed',
'failed',
] as const;
export const NOTIFICATION_DISPATCH_STATUSES = [
'pending',
'sending',
'sent',
'failed',
'retry',
] as const;
export const NOTIFICATION_INBOX_STATUSES = [
'unread',
'read',
'archived',
] as const;
export const NOTIFICATION_BODY_FORMATS = ['plain', 'html'] as const;
export const NOTIFICATION_SEVERITIES = [
'info',
'success',
'warning',
'error',
] as const;
export type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number];
export type NotificationEventStatus =
(typeof NOTIFICATION_EVENT_STATUSES)[number];
export type NotificationDispatchStatus =
(typeof NOTIFICATION_DISPATCH_STATUSES)[number];
export type NotificationStatus = (typeof NOTIFICATION_INBOX_STATUSES)[number];
export type NotificationBodyFormat =
(typeof NOTIFICATION_BODY_FORMATS)[number];
export type NotificationSeverity = (typeof NOTIFICATION_SEVERITIES)[number];
export type NotificationTemplateRecord = {
id: string;
organizationId: string;
eventType: string;
code: string;
name: string;
channel: NotificationChannel;
subjectTemplate: string | null;
titleTemplate: string;
bodyTemplate: string;
bodyFormat: NotificationBodyFormat;
linkTemplate: string | null;
language: string;
variables: string[];
version: number;
isActive: boolean;
metadata: Record<string, unknown> | null;
createdAt: string;
@@ -54,6 +107,70 @@ export type AppNotificationRecord = {
updatedAt: string;
};
export type NotificationDispatchRecipient = {
name?: string | null;
email: string;
};
export type NotificationAttachmentRef =
| {
type: 'artifact';
artifactId: string;
label?: string | null;
}
| {
type: 'document_library_version';
versionId: string;
label?: string | null;
}
| {
type: 'active_document_library';
libraryId: string;
label?: string | null;
};
export type ResolvedNotificationAttachment = {
sourceType: NotificationAttachmentRef['type'];
sourceId: string;
fileName: string;
contentType: string;
size: number;
checksum: string | null;
label: string | null;
buffer: Buffer;
};
export type NotificationDispatchRecord = {
id: string;
organizationId: string;
eventId: string;
templateId: string | null;
entityType: string;
entityId: string;
channel: NotificationChannel;
providerKey: string;
status: NotificationDispatchStatus;
recipientTo: NotificationDispatchRecipient[];
recipientCc: NotificationDispatchRecipient[];
recipientBcc: NotificationDispatchRecipient[];
replyTo: string | null;
subject: string;
bodyText: string;
bodyHtml: string | null;
attachments: Array<Omit<ResolvedNotificationAttachment, 'buffer'>>;
recipientUserId: string | null;
recipientAddress: string | null;
requestedBy: string;
metadata: Record<string, unknown> | null;
lastError: string | null;
retryCount: number;
maxRetryCount: number;
lastAttemptAt: string | null;
sentAt: string | null;
createdAt: string;
updatedAt: string;
};
export type NotificationRecipientType =
| 'explicit_user'
| 'approval_current_step_approvers'
@@ -81,5 +198,85 @@ export type PublishNotificationEventInput = {
export type NotificationListFilters = {
page?: number;
limit?: number;
status?: NotificationStatus | 'all';
status?: 'all' | NotificationStatus;
};
export type NotificationHistoryFilters = {
page?: number;
limit?: number;
channel?: NotificationChannel | 'all';
status?: NotificationDispatchStatus | 'all';
eventType?: string;
entityType?: string;
entityId?: string;
search?: string;
};
export type NotificationPreviewRequest = {
organizationId: string;
actorUserId: string;
eventType: string;
entityType: string;
entityId: string;
channel: NotificationChannel;
payload?: Record<string, unknown>;
templateOverride?: Partial<{
subjectTemplate: string;
titleTemplate: string;
bodyTemplate: string;
bodyFormat: NotificationBodyFormat;
linkTemplate: string | null;
}>;
recipientOverride: {
to: NotificationDispatchRecipient[];
cc?: NotificationDispatchRecipient[];
bcc?: NotificationDispatchRecipient[];
replyTo?: string | null;
recipientUserId?: string | null;
};
attachments?: NotificationAttachmentRef[];
providerKey?: string;
};
export type NotificationSendRequest = NotificationPreviewRequest & {
maxRetryCount?: number;
};
export type NotificationRenderResult = {
subject: string;
title: string;
bodyText: string;
bodyHtml: string | null;
linkUrl: string | null;
variables: string[];
};
export type NotificationProviderSendInput = {
dispatchId: string;
subject: string;
bodyText: string;
bodyHtml: string | null;
to: NotificationDispatchRecipient[];
cc: NotificationDispatchRecipient[];
bcc: NotificationDispatchRecipient[];
replyTo: string | null;
attachments: Array<Omit<ResolvedNotificationAttachment, 'buffer'>>;
attachmentBuffers: Buffer[];
};
export type NotificationProviderSendResult = {
providerMessageId: string | null;
acceptedRecipients: string[];
rejectedRecipients: string[];
metadata?: Record<string, unknown>;
};
export interface NotificationProvider {
readonly channel: NotificationChannel;
readonly key: string;
validate(): Promise<void>;
healthCheck(): Promise<{ ok: boolean; detail?: string }>;
send(
input: NotificationProviderSendInput,
): Promise<NotificationProviderSendResult>;
}