ตรวจสอบแก้ไชสร้างเอกสาร
This commit is contained in:
36
src/app/api/notifications/history/[id]/resend/route.ts
Normal file
36
src/app/api/notifications/history/[id]/resend/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { resendNotification } from '@/features/foundation/notifications/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.notificationResend,
|
||||
});
|
||||
const { id } = await context.params;
|
||||
const dispatch = await resendNotification({
|
||||
organizationId: organization.id,
|
||||
actorUserId: session.user.id,
|
||||
dispatchId: id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Notification resent successfully',
|
||||
dispatch,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to resend notification' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
29
src/app/api/notifications/history/[id]/route.ts
Normal file
29
src/app/api/notifications/history/[id]/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getNotificationDispatchDetail } from '@/features/foundation/notifications/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.notificationViewHistory,
|
||||
});
|
||||
const { id } = await context.params;
|
||||
const result = await getNotificationDispatchDetail(organization.id, id);
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to load notification detail' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
48
src/app/api/notifications/history/route.ts
Normal file
48
src/app/api/notifications/history/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { listNotificationHistory } from '@/features/foundation/notifications/server/service';
|
||||
import { NOTIFICATION_CHANNELS, NOTIFICATION_DISPATCH_STATUSES } from '@/features/foundation/notifications/types';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.notificationViewHistory,
|
||||
});
|
||||
const { searchParams } = request.nextUrl;
|
||||
|
||||
const page = Number(searchParams.get('page') ?? 1);
|
||||
const limit = Number(searchParams.get('limit') ?? 20);
|
||||
const channel = searchParams.get('channel');
|
||||
const status = searchParams.get('status');
|
||||
|
||||
const result = await listNotificationHistory(organization.id, {
|
||||
page,
|
||||
limit,
|
||||
channel:
|
||||
channel && [...NOTIFICATION_CHANNELS, 'all'].includes(channel as never)
|
||||
? (channel as (typeof NOTIFICATION_CHANNELS)[number] | 'all')
|
||||
: 'all',
|
||||
status:
|
||||
status && [...NOTIFICATION_DISPATCH_STATUSES, 'all'].includes(status as never)
|
||||
? (status as (typeof NOTIFICATION_DISPATCH_STATUSES)[number] | 'all')
|
||||
: 'all',
|
||||
eventType: searchParams.get('eventType') ?? undefined,
|
||||
entityType: searchParams.get('entityType') ?? undefined,
|
||||
entityId: searchParams.get('entityId') ?? undefined,
|
||||
search: searchParams.get('search') ?? undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to load notification history' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
92
src/app/api/notifications/preview/route.ts
Normal file
92
src/app/api/notifications/preview/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
NOTIFICATION_BODY_FORMATS,
|
||||
NOTIFICATION_CHANNELS,
|
||||
} from '@/features/foundation/notifications/types';
|
||||
import { previewNotification } from '@/features/foundation/notifications/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
const recipientSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().trim().min(1).optional().nullable(),
|
||||
});
|
||||
|
||||
const attachmentSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('artifact'),
|
||||
artifactId: z.string().min(1),
|
||||
label: z.string().optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('document_library_version'),
|
||||
versionId: z.string().min(1),
|
||||
label: z.string().optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('active_document_library'),
|
||||
libraryId: z.string().min(1),
|
||||
label: z.string().optional().nullable(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const previewSchema = z.object({
|
||||
eventType: z.string().min(1),
|
||||
entityType: z.string().min(1),
|
||||
entityId: z.string().min(1),
|
||||
channel: z.enum(NOTIFICATION_CHANNELS),
|
||||
payload: z.record(z.string(), z.unknown()).optional(),
|
||||
templateOverride: z
|
||||
.object({
|
||||
subjectTemplate: z.string().optional(),
|
||||
titleTemplate: z.string().optional(),
|
||||
bodyTemplate: z.string().optional(),
|
||||
bodyFormat: z.enum(NOTIFICATION_BODY_FORMATS).optional(),
|
||||
linkTemplate: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
recipientOverride: z.object({
|
||||
to: z.array(recipientSchema).min(1),
|
||||
cc: z.array(recipientSchema).optional(),
|
||||
bcc: z.array(recipientSchema).optional(),
|
||||
replyTo: z.string().email().nullable().optional(),
|
||||
recipientUserId: z.string().nullable().optional(),
|
||||
}),
|
||||
attachments: z.array(attachmentSchema).optional(),
|
||||
providerKey: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.notificationSend,
|
||||
});
|
||||
const parsed = previewSchema.parse(await request.json());
|
||||
|
||||
const result = await previewNotification({
|
||||
organizationId: organization.id,
|
||||
actorUserId: session.user.id,
|
||||
...parsed,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ message: 'Invalid preview payload', issues: error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to preview notification' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
96
src/app/api/notifications/send/route.ts
Normal file
96
src/app/api/notifications/send/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
NOTIFICATION_BODY_FORMATS,
|
||||
NOTIFICATION_CHANNELS,
|
||||
} from '@/features/foundation/notifications/types';
|
||||
import { sendNotification } from '@/features/foundation/notifications/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
const recipientSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().trim().min(1).optional().nullable(),
|
||||
});
|
||||
|
||||
const attachmentSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('artifact'),
|
||||
artifactId: z.string().min(1),
|
||||
label: z.string().optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('document_library_version'),
|
||||
versionId: z.string().min(1),
|
||||
label: z.string().optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('active_document_library'),
|
||||
libraryId: z.string().min(1),
|
||||
label: z.string().optional().nullable(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const sendSchema = z.object({
|
||||
eventType: z.string().min(1),
|
||||
entityType: z.string().min(1),
|
||||
entityId: z.string().min(1),
|
||||
channel: z.enum(NOTIFICATION_CHANNELS),
|
||||
payload: z.record(z.string(), z.unknown()).optional(),
|
||||
templateOverride: z
|
||||
.object({
|
||||
subjectTemplate: z.string().optional(),
|
||||
titleTemplate: z.string().optional(),
|
||||
bodyTemplate: z.string().optional(),
|
||||
bodyFormat: z.enum(NOTIFICATION_BODY_FORMATS).optional(),
|
||||
linkTemplate: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
recipientOverride: z.object({
|
||||
to: z.array(recipientSchema).min(1),
|
||||
cc: z.array(recipientSchema).optional(),
|
||||
bcc: z.array(recipientSchema).optional(),
|
||||
replyTo: z.string().email().nullable().optional(),
|
||||
recipientUserId: z.string().nullable().optional(),
|
||||
}),
|
||||
attachments: z.array(attachmentSchema).optional(),
|
||||
providerKey: z.string().optional(),
|
||||
maxRetryCount: z.number().int().min(1).max(10).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.notificationSend,
|
||||
});
|
||||
const parsed = sendSchema.parse(await request.json());
|
||||
|
||||
const result = await sendNotification({
|
||||
organizationId: organization.id,
|
||||
actorUserId: session.user.id,
|
||||
...parsed,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Notification sent successfully',
|
||||
dispatch: result,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ message: 'Invalid send payload', issues: error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to send notification' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -255,10 +255,17 @@ export const appNotificationTemplates = pgTable(
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
eventType: text('event_type').notNull(),
|
||||
code: text('code').default('').notNull(),
|
||||
name: text('name').default('').notNull(),
|
||||
channel: text('channel').notNull(),
|
||||
subjectTemplate: text('subject_template'),
|
||||
titleTemplate: text('title_template').notNull(),
|
||||
bodyTemplate: text('body_template').notNull(),
|
||||
bodyFormat: text('body_format').default('plain').notNull(),
|
||||
linkTemplate: text('link_template'),
|
||||
language: text('language').default('th').notNull(),
|
||||
variables: jsonb('variables'),
|
||||
version: integer('version').default(1).notNull(),
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
@@ -276,6 +283,45 @@ export const appNotificationTemplates = pgTable(
|
||||
})
|
||||
);
|
||||
|
||||
export const appNotificationDispatches = pgTable(
|
||||
'app_notification_dispatches',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
eventId: text('event_id').notNull(),
|
||||
templateId: text('template_id'),
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: text('entity_id').notNull(),
|
||||
channel: text('channel').notNull(),
|
||||
providerKey: text('provider_key').notNull(),
|
||||
status: text('status').default('pending').notNull(),
|
||||
recipientTo: jsonb('recipient_to').notNull(),
|
||||
recipientCc: jsonb('recipient_cc'),
|
||||
recipientBcc: jsonb('recipient_bcc'),
|
||||
replyTo: text('reply_to'),
|
||||
subject: text('subject').notNull(),
|
||||
bodyText: text('body_text').notNull(),
|
||||
bodyHtml: text('body_html'),
|
||||
attachments: jsonb('attachments'),
|
||||
recipientUserId: text('recipient_user_id'),
|
||||
recipientAddress: text('recipient_address'),
|
||||
requestedBy: text('requested_by').notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
lastError: text('last_error'),
|
||||
retryCount: integer('retry_count').default(0).notNull(),
|
||||
maxRetryCount: integer('max_retry_count').default(3).notNull(),
|
||||
lastAttemptAt: timestamp('last_attempt_at', { withTimezone: true }),
|
||||
sentAt: timestamp('sent_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
organizationEventChannelRecipientIdx: uniqueIndex(
|
||||
'app_notification_dispatches_org_event_channel_recipient_idx'
|
||||
).on(table.organizationId, table.eventId, table.channel, table.recipientAddress)
|
||||
})
|
||||
);
|
||||
|
||||
export const appNotificationDeliveries = pgTable('app_notification_deliveries', {
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
134
src/features/foundation/notifications/server/email-provider.ts
Normal file
134
src/features/foundation/notifications/server/email-provider.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
733
src/features/foundation/notifications/server/service.ts
Normal file
733
src/features/foundation/notifications/server/service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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),
|
||||
]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ export const PERMISSIONS = {
|
||||
notificationsRead: 'notifications.read',
|
||||
notificationsUpdate: 'notifications.update',
|
||||
notificationsAdmin: 'notifications.admin',
|
||||
notificationSend: 'notification.send',
|
||||
notificationResend: 'notification.resend',
|
||||
notificationManageTemplate: 'notification.manage_template',
|
||||
notificationViewHistory: 'notification.view_history',
|
||||
reportRead: 'report:read',
|
||||
crmLeadRead: 'crm.lead.read',
|
||||
crmLeadCreate: 'crm.lead.create',
|
||||
@@ -615,7 +619,17 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
||||
permissions: [
|
||||
{ key: PERMISSIONS.notificationsRead, label: 'Read notifications' },
|
||||
{ key: PERMISSIONS.notificationsUpdate, label: 'Update notifications' },
|
||||
{ key: PERMISSIONS.notificationsAdmin, label: 'Administer notifications' }
|
||||
{ key: PERMISSIONS.notificationsAdmin, label: 'Administer notifications' },
|
||||
{ key: PERMISSIONS.notificationSend, label: 'Send notifications' },
|
||||
{ key: PERMISSIONS.notificationResend, label: 'Resend notifications' },
|
||||
{
|
||||
key: PERMISSIONS.notificationManageTemplate,
|
||||
label: 'Manage notification templates',
|
||||
},
|
||||
{
|
||||
key: PERMISSIONS.notificationViewHistory,
|
||||
label: 'View notification history',
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -709,11 +723,20 @@ export function getMembershipBasePermissions(role: MembershipRole): Permission[]
|
||||
PERMISSIONS.usersManage,
|
||||
PERMISSIONS.notificationsRead,
|
||||
PERMISSIONS.notificationsUpdate,
|
||||
PERMISSIONS.notificationsAdmin
|
||||
PERMISSIONS.notificationsAdmin,
|
||||
PERMISSIONS.notificationSend,
|
||||
PERMISSIONS.notificationResend,
|
||||
PERMISSIONS.notificationManageTemplate,
|
||||
PERMISSIONS.notificationViewHistory
|
||||
];
|
||||
}
|
||||
|
||||
return [PERMISSIONS.productsRead, PERMISSIONS.notificationsRead, PERMISSIONS.notificationsUpdate];
|
||||
return [
|
||||
PERMISSIONS.productsRead,
|
||||
PERMISSIONS.notificationsRead,
|
||||
PERMISSIONS.notificationsUpdate,
|
||||
PERMISSIONS.notificationSend,
|
||||
];
|
||||
}
|
||||
|
||||
export function getRoleProfileDefinition(roleCode: string): CrmRoleProfileDefinition | null {
|
||||
|
||||
Reference in New Issue
Block a user