This commit is contained in:
phaichayon
2026-06-26 15:27:05 +07:00
parent 2ad57f0ad9
commit 2ace5dbb83
26 changed files with 7463 additions and 240 deletions

View File

@@ -0,0 +1,358 @@
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
import {
appNotificationDeliveries,
appNotificationEvents,
appNotificationTemplates,
appNotifications
} from '@/db/schema';
import { auditAction } from '@/features/foundation/audit-log/service';
import { db } from '@/lib/db';
import { resolveNotificationRecipients } from './recipient-resolver';
import { renderNotificationTemplate } from './template-renderer';
import type {
NotificationChannel,
NotificationEventRecord,
NotificationRecipientTarget,
NotificationSeverity,
NotificationTemplateRecord,
PublishNotificationEventInput
} from '../types';
type EventRule = {
severity: NotificationSeverity;
recipients: NotificationRecipientTarget[];
};
const IN_APP_CHANNEL: NotificationChannel = 'in_app';
const EVENT_RULES: Record<string, EventRule> = {
'approval.requested': {
severity: 'warning',
recipients: [{ type: 'approval_current_step_approvers', excludeActor: true }]
},
'approval.step.approved': {
severity: 'success',
recipients: [
{ type: 'approval_requester' },
{ type: 'approval_current_step_approvers', excludeActor: true }
]
},
'approval.step.rejected': {
severity: 'error',
recipients: [{ type: 'approval_requester' }]
},
'approval.completed': {
severity: 'success',
recipients: [{ type: 'approval_requester' }]
},
'approval.cancelled': {
severity: 'info',
recipients: [
{ type: 'approval_requester' },
{ type: 'approval_current_step_approvers', excludeActor: true }
]
},
'approval.returned': {
severity: 'warning',
recipients: [{ type: 'approval_requester' }]
}
};
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,
channel: row.channel as NotificationTemplateRecord['channel'],
titleTemplate: row.titleTemplate,
bodyTemplate: row.bodyTemplate,
linkTemplate: row.linkTemplate ?? null,
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 normalizeError(error: unknown) {
if (error instanceof Error) {
return error.message.slice(0, 500);
}
return 'Unknown notification processing error';
}
async function loadEventById(eventId: string) {
const [row] = await db
.select()
.from(appNotificationEvents)
.where(eq(appNotificationEvents.id, eventId))
.limit(1);
return row ? mapEventRecord(row) : null;
}
async function loadTemplate(organizationId: string, eventType: string) {
const [row] = await db
.select()
.from(appNotificationTemplates)
.where(
and(
eq(appNotificationTemplates.organizationId, organizationId),
eq(appNotificationTemplates.eventType, eventType),
eq(appNotificationTemplates.channel, IN_APP_CHANNEL),
eq(appNotificationTemplates.isActive, true),
isNull(appNotificationTemplates.deletedAt)
)
)
.limit(1);
return row ? mapTemplateRecord(row) : null;
}
async function markEventFailure(
eventId: string,
organizationId: string,
actorUserId: string | null,
error: unknown
) {
const message = normalizeError(error);
await db
.update(appNotificationEvents)
.set({
status: 'failed',
lastError: message,
updatedAt: new Date()
})
.where(eq(appNotificationEvents.id, eventId));
await auditAction({
organizationId,
userId: actorUserId ?? 'system',
entityType: 'app_notification_event',
entityId: eventId,
action: 'notification_event_failed',
afterData: { error: message }
});
}
export async function processNotificationEvent(eventId: string) {
const event = await loadEventById(eventId);
if (!event) {
throw new Error('Notification event not found');
}
if (event.status === 'processed') {
return event;
}
try {
const rule = EVENT_RULES[event.eventType];
if (!rule) {
throw new Error(`Unsupported notification event type: ${event.eventType}`);
}
const template = await loadTemplate(event.organizationId, event.eventType);
if (!template) {
throw new Error(`Notification template not found for ${event.eventType}`);
}
const payload = event.payload ?? {};
const recipientLists = await Promise.all(
rule.recipients.map((recipient) =>
resolveNotificationRecipients({
organizationId: event.organizationId,
actorUserId: event.actorUserId,
recipient,
payload
})
)
);
const recipients = [...new Set(recipientLists.flat())];
const existingRows = recipients.length
? await db
.select({
recipientUserId: appNotifications.recipientUserId
})
.from(appNotifications)
.where(
and(
eq(appNotifications.eventId, event.id),
inArray(appNotifications.recipientUserId, recipients)
)
)
: [];
const existingRecipientIds = new Set(existingRows.map((row) => row.recipientUserId));
const recipientsToCreate = recipients.filter((recipientUserId) => !existingRecipientIds.has(recipientUserId));
const createdIds: string[] = [];
for (const recipientUserId of recipientsToCreate) {
const rendered = renderNotificationTemplate(template, payload);
const notificationId = crypto.randomUUID();
await db.insert(appNotifications).values({
id: notificationId,
organizationId: event.organizationId,
recipientUserId,
eventId: event.id,
title: rendered.title,
body: rendered.body,
linkUrl: rendered.linkUrl,
severity: rule.severity,
status: 'unread',
metadata: payload,
createdAt: new Date(),
updatedAt: new Date()
});
await db.insert(appNotificationDeliveries).values({
id: crypto.randomUUID(),
organizationId: event.organizationId,
notificationId,
channel: IN_APP_CHANNEL,
recipientAddress: recipientUserId,
status: 'sent',
attemptCount: 1,
sentAt: new Date(),
createdAt: new Date(),
updatedAt: new Date()
});
createdIds.push(notificationId);
await auditAction({
organizationId: event.organizationId,
userId: event.actorUserId ?? recipientUserId,
entityType: 'app_notification',
entityId: notificationId,
action: 'notification_created',
afterData: { recipientUserId, eventId: event.id, eventType: event.eventType }
});
}
await db
.update(appNotificationEvents)
.set({
status: 'processed',
processedAt: new Date(),
lastError: null,
updatedAt: new Date()
})
.where(eq(appNotificationEvents.id, event.id));
await auditAction({
organizationId: event.organizationId,
userId: event.actorUserId ?? 'system',
entityType: 'app_notification_event',
entityId: event.id,
action: 'notification_event_processed',
afterData: { createdNotificationIds: createdIds, recipientCount: recipients.length }
});
return await loadEventById(event.id);
} catch (error) {
await markEventFailure(event.id, event.organizationId, event.actorUserId, error);
throw error;
}
}
export async function processPendingNotificationEvents() {
const rows = await db
.select({ id: appNotificationEvents.id })
.from(appNotificationEvents)
.where(eq(appNotificationEvents.status, 'pending'))
.orderBy(asc(appNotificationEvents.publishedAt))
.limit(100);
for (const row of rows) {
try {
await processNotificationEvent(row.id);
} catch {
// Errors are recorded on the event row for later inspection.
}
}
}
export async function publishNotificationEvent(input: PublishNotificationEventInput) {
const existing =
input.dedupeKey
? await db
.select()
.from(appNotificationEvents)
.where(
and(
eq(appNotificationEvents.organizationId, input.organizationId),
eq(appNotificationEvents.dedupeKey, input.dedupeKey)
)
)
.limit(1)
: [];
const row =
existing[0] ??
(
await db
.insert(appNotificationEvents)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
eventType: input.eventType,
entityType: input.entityType,
entityId: input.entityId,
actorUserId: input.actorUserId ?? null,
payload: input.payload ?? null,
dedupeKey: input.dedupeKey ?? null,
status: 'pending',
publishedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date()
})
.returning()
)[0];
await auditAction({
organizationId: row.organizationId,
userId: row.actorUserId ?? 'system',
entityType: 'app_notification_event',
entityId: row.id,
action: 'notification_event_published',
afterData: {
eventType: row.eventType,
entityType: row.entityType,
entityId: row.entityId,
dedupeKey: row.dedupeKey
}
});
try {
await processNotificationEvent(row.id);
} catch {
// Event failures are persisted on the event row and must not block the caller.
}
return mapEventRecord(row);
}

View File

@@ -0,0 +1,222 @@
import { and, count, desc, eq, inArray, isNull } from 'drizzle-orm';
import { appNotifications } from '@/db/schema';
import { auditAction } from '@/features/foundation/audit-log/service';
import { db } from '@/lib/db';
import type { AppNotificationRecord, NotificationListFilters } from '../types';
function mapNotificationRecord(row: typeof appNotifications.$inferSelect): AppNotificationRecord {
return {
id: row.id,
organizationId: row.organizationId,
recipientUserId: row.recipientUserId,
eventId: row.eventId,
title: row.title,
body: row.body,
linkUrl: row.linkUrl ?? null,
severity: row.severity as AppNotificationRecord['severity'],
status: row.status as AppNotificationRecord['status'],
readAt: row.readAt?.toISOString() ?? null,
archivedAt: row.archivedAt?.toISOString() ?? null,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString()
};
}
export async function listNotifications(
organizationId: string,
userId: string,
filters: NotificationListFilters
) {
const page = Math.max(filters.page ?? 1, 1);
const limit = Math.min(Math.max(filters.limit ?? 20, 1), 100);
const where = and(
eq(appNotifications.organizationId, organizationId),
eq(appNotifications.recipientUserId, userId),
...(filters.status && filters.status !== 'all'
? [
filters.status === 'archived'
? eq(appNotifications.status, 'archived')
: and(eq(appNotifications.status, filters.status), isNull(appNotifications.archivedAt))!
]
: [])
);
const [rows, totalRow, unreadRow] = await Promise.all([
db
.select()
.from(appNotifications)
.where(where)
.orderBy(desc(appNotifications.createdAt))
.limit(limit)
.offset((page - 1) * limit),
db.select({ value: count() }).from(appNotifications).where(where),
db
.select({ value: count() })
.from(appNotifications)
.where(
and(
eq(appNotifications.organizationId, organizationId),
eq(appNotifications.recipientUserId, userId),
eq(appNotifications.status, 'unread'),
isNull(appNotifications.archivedAt)
)
)
]);
return {
items: rows.map(mapNotificationRecord),
totalItems: totalRow[0]?.value ?? 0,
unreadCount: unreadRow[0]?.value ?? 0,
page,
limit
};
}
export async function getUnreadNotificationCount(organizationId: string, userId: string) {
const [row] = await db
.select({ value: count() })
.from(appNotifications)
.where(
and(
eq(appNotifications.organizationId, organizationId),
eq(appNotifications.recipientUserId, userId),
eq(appNotifications.status, 'unread'),
isNull(appNotifications.archivedAt)
)
);
return row?.value ?? 0;
}
export async function markNotificationAsRead(
organizationId: string,
userId: string,
notificationId: string
) {
const [current] = await db
.select()
.from(appNotifications)
.where(
and(
eq(appNotifications.id, notificationId),
eq(appNotifications.organizationId, organizationId),
eq(appNotifications.recipientUserId, userId)
)
)
.limit(1);
if (!current) {
return null;
}
if (current.status === 'read') {
return mapNotificationRecord(current);
}
const [updated] = await db
.update(appNotifications)
.set({
status: 'read',
readAt: current.readAt ?? new Date(),
updatedAt: new Date()
})
.where(eq(appNotifications.id, notificationId))
.returning();
await auditAction({
organizationId,
userId,
entityType: 'app_notification',
entityId: notificationId,
action: 'notification_read',
beforeData: current,
afterData: updated
});
return mapNotificationRecord(updated);
}
export async function markAllNotificationsAsRead(organizationId: string, userId: string) {
const rows = await db
.select({ id: appNotifications.id })
.from(appNotifications)
.where(
and(
eq(appNotifications.organizationId, organizationId),
eq(appNotifications.recipientUserId, userId),
eq(appNotifications.status, 'unread'),
isNull(appNotifications.archivedAt)
)
);
const ids = rows.map((row) => row.id);
if (!ids.length) {
return 0;
}
await db
.update(appNotifications)
.set({
status: 'read',
readAt: new Date(),
updatedAt: new Date()
})
.where(inArray(appNotifications.id, ids));
await auditAction({
organizationId,
userId,
entityType: 'app_notification',
entityId: `bulk:${ids.length}`,
action: 'notification_read_all',
afterData: { notificationIds: ids }
});
return ids.length;
}
export async function archiveNotification(
organizationId: string,
userId: string,
notificationId: string
) {
const [current] = await db
.select()
.from(appNotifications)
.where(
and(
eq(appNotifications.id, notificationId),
eq(appNotifications.organizationId, organizationId),
eq(appNotifications.recipientUserId, userId)
)
)
.limit(1);
if (!current) {
return null;
}
const [updated] = await db
.update(appNotifications)
.set({
status: 'archived',
archivedAt: current.archivedAt ?? new Date(),
readAt: current.readAt ?? new Date(),
updatedAt: new Date()
})
.where(eq(appNotifications.id, notificationId))
.returning();
await auditAction({
organizationId,
userId,
entityType: 'app_notification',
entityId: notificationId,
action: 'notification_archived',
beforeData: current,
afterData: updated
});
return mapNotificationRecord(updated);
}

View File

@@ -0,0 +1,102 @@
import { and, eq, inArray, isNull } from 'drizzle-orm';
import { crmRoleProfiles, crmUserRoleAssignments, memberships } from '@/db/schema';
import { db } from '@/lib/db';
import type { NotificationRecipientTarget } from '../types';
type ResolveRecipientsInput = {
organizationId: string;
actorUserId?: string | null;
recipient: NotificationRecipientTarget;
payload: Record<string, unknown>;
};
function uniqueUserIds(values: Array<string | null | undefined>) {
return [...new Set(values.filter((value): value is string => typeof value === 'string' && value.length > 0))];
}
function getPayloadUserIds(payload: Record<string, unknown>, key: string) {
const value = payload[key];
if (!Array.isArray(value)) {
return [];
}
return uniqueUserIds(value.filter((item): item is string => typeof item === 'string'));
}
async function resolveApprovalCurrentStepApprovers(
organizationId: string,
roleCode: string
) {
const assignmentRows = await db
.select({ userId: crmUserRoleAssignments.userId })
.from(crmUserRoleAssignments)
.innerJoin(
crmRoleProfiles,
and(
eq(crmRoleProfiles.id, crmUserRoleAssignments.roleProfileId),
eq(crmRoleProfiles.organizationId, organizationId),
eq(crmRoleProfiles.code, roleCode),
eq(crmRoleProfiles.isActive, true),
isNull(crmRoleProfiles.deletedAt)
)
)
.where(
and(
eq(crmUserRoleAssignments.organizationId, organizationId),
eq(crmUserRoleAssignments.isActive, true),
isNull(crmUserRoleAssignments.deletedAt)
)
);
const fallbackRows = await db
.select({ userId: memberships.userId })
.from(memberships)
.where(
and(eq(memberships.organizationId, organizationId), eq(memberships.businessRole, roleCode))
);
return uniqueUserIds([
...assignmentRows.map((row) => row.userId),
...fallbackRows.map((row) => row.userId)
]);
}
export async function resolveNotificationRecipients(input: ResolveRecipientsInput): Promise<string[]> {
let recipients: string[] = [];
switch (input.recipient.type) {
case 'explicit_user':
recipients = uniqueUserIds([...(input.recipient.userIds ?? []), ...getPayloadUserIds(input.payload, 'explicitUserIds')]);
break;
case 'approval_requester':
recipients = uniqueUserIds([input.payload.requestedByUserId as string | undefined]);
break;
case 'approval_current_step_approvers': {
const roleCode =
(input.payload.currentStepRoleCode as string | undefined) ??
(input.payload.recipientRoleCode as string | undefined) ??
null;
recipients = roleCode
? await resolveApprovalCurrentStepApprovers(input.organizationId, roleCode)
: [];
break;
}
case 'approval_previous_actor':
recipients = uniqueUserIds([input.payload.previousActorUserId as string | undefined]);
break;
case 'entity_owner':
recipients = uniqueUserIds([input.payload.entityOwnerUserId as string | undefined]);
break;
case 'sales_owner':
recipients = uniqueUserIds([input.payload.salesOwnerUserId as string | undefined]);
break;
default:
recipients = [];
}
if (input.recipient.excludeActor && input.actorUserId) {
return recipients.filter((userId) => userId !== input.actorUserId);
}
return recipients;
}

View File

@@ -0,0 +1,46 @@
import type { NotificationTemplateRecord } from '../types';
const TOKEN_PATTERN = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
function resolveTokenValue(payload: Record<string, unknown>, token: string): string {
const value = token.split('.').reduce<unknown>((current, part) => {
if (!current || typeof current !== 'object') {
return undefined;
}
return (current as Record<string, unknown>)[part];
}, payload);
if (value === null || value === undefined || value === '') {
return '-';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return '-';
}
function renderString(template: string | null, payload: Record<string, unknown>): string | null {
if (!template) {
return null;
}
return template.replace(TOKEN_PATTERN, (_match, token: string) => resolveTokenValue(payload, token));
}
export function renderNotificationTemplate(
template: Pick<NotificationTemplateRecord, 'titleTemplate' | 'bodyTemplate' | 'linkTemplate'>,
payload: Record<string, unknown>
) {
return {
title: renderString(template.titleTemplate, payload) ?? '-',
body: renderString(template.bodyTemplate, payload) ?? '-',
linkUrl: renderString(template.linkTemplate, payload)
};
}

View File

@@ -0,0 +1,85 @@
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 type NotificationTemplateRecord = {
id: string;
organizationId: string;
eventType: string;
channel: NotificationChannel;
titleTemplate: string;
bodyTemplate: string;
linkTemplate: string | null;
isActive: boolean;
metadata: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
createdBy: string;
updatedBy: string;
};
export type NotificationEventRecord = {
id: string;
organizationId: string;
eventType: string;
entityType: string;
entityId: string;
actorUserId: string | null;
payload: Record<string, unknown> | null;
dedupeKey: string | null;
status: NotificationEventStatus;
lastError: string | null;
publishedAt: string;
processedAt: string | null;
createdAt: string;
updatedAt: string;
};
export type AppNotificationRecord = {
id: string;
organizationId: string;
recipientUserId: string;
eventId: string;
title: string;
body: string;
linkUrl: string | null;
severity: NotificationSeverity;
status: NotificationStatus;
readAt: string | null;
archivedAt: string | null;
metadata: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
};
export type NotificationRecipientType =
| 'explicit_user'
| 'approval_current_step_approvers'
| 'approval_requester'
| 'approval_previous_actor'
| 'entity_owner'
| 'sales_owner';
export type NotificationRecipientTarget = {
type: NotificationRecipientType;
userIds?: string[];
excludeActor?: boolean;
};
export type PublishNotificationEventInput = {
organizationId: string;
eventType: string;
entityType: string;
entityId: string;
actorUserId?: string | null;
payload?: Record<string, unknown>;
dedupeKey?: string;
};
export type NotificationListFilters = {
page?: number;
limit?: number;
status?: NotificationStatus | 'all';
};