task-f.4
This commit is contained in:
358
src/features/foundation/notifications/server/event-service.ts
Normal file
358
src/features/foundation/notifications/server/event-service.ts
Normal 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);
|
||||
}
|
||||
222
src/features/foundation/notifications/server/inbox-service.ts
Normal file
222
src/features/foundation/notifications/server/inbox-service.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
85
src/features/foundation/notifications/types.ts
Normal file
85
src/features/foundation/notifications/types.ts
Normal 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';
|
||||
};
|
||||
Reference in New Issue
Block a user