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

@@ -15,6 +15,7 @@ import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access';
import { type SystemRole } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { auditAction } from '@/features/foundation/audit-log/service';
import { publishNotificationEvent } from '@/features/foundation/notifications/server/event-service';
import {
type CrmSecurityContext,
canAccessScopedCrmRecord
@@ -124,6 +125,87 @@ function mapActionRecord(row: typeof crmApprovalActions.$inferSelect): ApprovalA
};
}
async function buildApprovalNotificationPayload(input: {
organizationId: string;
request: typeof crmApprovalRequests.$inferSelect;
workflowName: string;
actorUserId: string;
remark?: string | null;
currentStep?: { roleCode: string; roleName: string } | null;
}) {
const [actorRows, quotationRows] = await Promise.all([
db.select({ name: users.name }).from(users).where(eq(users.id, input.actorUserId)).limit(1),
input.request.entityType === 'quotation'
? db
.select({ id: crmQuotations.id, code: crmQuotations.code })
.from(crmQuotations)
.where(
and(
eq(crmQuotations.id, input.request.entityId),
eq(crmQuotations.organizationId, input.organizationId),
isNull(crmQuotations.deletedAt)
)
)
.limit(1)
: Promise.resolve([])
]);
const quotation = quotationRows[0] ?? null;
return {
approvalRequestId: input.request.id,
requestedByUserId: input.request.requestedBy,
workflowId: input.request.workflowId,
workflowName: input.workflowName,
entityType: input.request.entityType,
entityId: input.request.entityId,
quotationId: quotation?.id ?? input.request.entityId,
quotationCode: quotation?.code ?? input.request.entityId,
actorName: actorRows[0]?.name ?? '-',
remark: input.remark?.trim() || '-',
currentStepRoleCode: input.currentStep?.roleCode ?? null,
currentStepRoleName: input.currentStep?.roleName ?? null,
entityLink:
quotation?.id
? `/dashboard/crm/quotations/${quotation.id}`
: `/dashboard/crm/approvals/${input.request.id}`
};
}
async function publishApprovalNotificationSafely(input: {
organizationId: string;
actorUserId: string;
eventType: string;
request: typeof crmApprovalRequests.$inferSelect;
workflowName: string;
remark?: string | null;
currentStep?: { roleCode: string; roleName: string } | null;
dedupeKey: string;
}) {
try {
const payload = await buildApprovalNotificationPayload({
organizationId: input.organizationId,
request: input.request,
workflowName: input.workflowName,
actorUserId: input.actorUserId,
remark: input.remark,
currentStep: input.currentStep ?? null
});
await publishNotificationEvent({
organizationId: input.organizationId,
eventType: input.eventType,
entityType: input.request.entityType,
entityId: input.request.entityId,
actorUserId: input.actorUserId,
payload,
dedupeKey: input.dedupeKey
});
} catch {
// Notification failures are recorded by the notification foundation and must not block approval actions.
}
}
function parseSort(sort?: string) {
if (!sort) {
return desc(crmApprovalRequests.updatedAt);
@@ -1083,9 +1165,9 @@ export async function submitForApproval(
APPROVAL_REQUEST_STATUSES.pending
);
await Promise.all([
auditAction({
organizationId,
await Promise.all([
auditAction({
organizationId,
userId,
entityType: 'crm_approval_request',
entityId: createdRequest.id,
@@ -1099,10 +1181,21 @@ export async function submitForApproval(
entityId: createdAction.id,
action: APPROVAL_ACTIONS.submit,
afterData: createdAction
})
]);
})
]);
return createdRequest;
await publishApprovalNotificationSafely({
organizationId,
actorUserId: userId,
eventType: 'approval.requested',
request: createdRequest,
workflowName: workflow.name,
remark: payload.remark ?? null,
currentStep: steps[0] ? { roleCode: steps[0].roleCode, roleName: steps[0].roleName } : null,
dedupeKey: `approval.requested:${createdRequest.id}`
});
return createdRequest;
}
export async function approveApproval(
@@ -1113,11 +1206,12 @@ export async function approveApproval(
) {
const request = await assertApprovalRequest(approvalRequestId, organizationId);
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
throw new AuthError('Only pending approvals can be approved', 400);
}
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
throw new AuthError('Only pending approvals can be approved', 400);
}
const steps = await listWorkflowSteps(request.workflowId, organizationId);
const workflow = await assertWorkflowById(request.workflowId, organizationId);
const steps = await listWorkflowSteps(request.workflowId, organizationId);
const currentStep = steps.find((step) => step.stepNumber === request.currentStep);
if (!currentStep) {
@@ -1161,8 +1255,8 @@ export async function approveApproval(
);
}
await Promise.all([
auditAction({
await Promise.all([
auditAction({
organizationId,
userId,
entityType: 'crm_approval_request',
@@ -1178,10 +1272,34 @@ export async function approveApproval(
entityId: createdAction.id,
action: APPROVAL_ACTIONS.approve,
afterData: createdAction
})
]);
})
]);
return updatedRequest;
if (nextStep) {
await publishApprovalNotificationSafely({
organizationId,
actorUserId: userId,
eventType: 'approval.step.approved',
request: updatedRequest,
workflowName: workflow.name,
remark: remark ?? null,
currentStep: { roleCode: nextStep.roleCode, roleName: nextStep.roleName },
dedupeKey: `approval.step.approved:${updatedRequest.id}:${createdAction.id}`
});
} else {
await publishApprovalNotificationSafely({
organizationId,
actorUserId: userId,
eventType: 'approval.completed',
request: updatedRequest,
workflowName: workflow.name,
remark: remark ?? null,
currentStep: null,
dedupeKey: `approval.completed:${updatedRequest.id}:${createdAction.id}`
});
}
return updatedRequest;
}
export async function rejectApproval(
@@ -1192,11 +1310,12 @@ export async function rejectApproval(
) {
const request = await assertApprovalRequest(approvalRequestId, organizationId);
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
throw new AuthError('Only pending approvals can be rejected', 400);
}
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
throw new AuthError('Only pending approvals can be rejected', 400);
}
const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId);
const workflow = await assertWorkflowById(request.workflowId, organizationId);
const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId);
if (!currentStep) {
throw new AuthError('Current approval step not found', 404);
@@ -1235,8 +1354,8 @@ export async function rejectApproval(
APPROVAL_REQUEST_STATUSES.rejected
);
await Promise.all([
auditAction({
await Promise.all([
auditAction({
organizationId,
userId,
entityType: 'crm_approval_request',
@@ -1252,10 +1371,21 @@ export async function rejectApproval(
entityId: createdAction.id,
action: APPROVAL_ACTIONS.reject,
afterData: createdAction
})
]);
})
]);
return updatedRequest;
await publishApprovalNotificationSafely({
organizationId,
actorUserId: userId,
eventType: 'approval.step.rejected',
request: updatedRequest,
workflowName: workflow.name,
remark: remark ?? null,
currentStep: { roleCode: currentStep.roleCode, roleName: currentStep.roleName },
dedupeKey: `approval.step.rejected:${updatedRequest.id}:${createdAction.id}`
});
return updatedRequest;
}
export async function returnApproval(
@@ -1266,11 +1396,12 @@ export async function returnApproval(
) {
const request = await assertApprovalRequest(approvalRequestId, organizationId);
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
throw new AuthError('Only pending approvals can be returned', 400);
}
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
throw new AuthError('Only pending approvals can be returned', 400);
}
const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId);
const workflow = await assertWorkflowById(request.workflowId, organizationId);
const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId);
if (!currentStep) {
throw new AuthError('Current approval step not found', 404);
@@ -1309,8 +1440,8 @@ export async function returnApproval(
APPROVAL_REQUEST_STATUSES.returned
);
await Promise.all([
auditAction({
await Promise.all([
auditAction({
organizationId,
userId,
entityType: 'crm_approval_request',
@@ -1326,10 +1457,21 @@ export async function returnApproval(
entityId: createdAction.id,
action: APPROVAL_ACTIONS.return,
afterData: createdAction
})
]);
})
]);
return updatedRequest;
await publishApprovalNotificationSafely({
organizationId,
actorUserId: userId,
eventType: 'approval.returned',
request: updatedRequest,
workflowName: workflow.name,
remark: remark ?? null,
currentStep: { roleCode: currentStep.roleCode, roleName: currentStep.roleName },
dedupeKey: `approval.returned:${updatedRequest.id}:${createdAction.id}`
});
return updatedRequest;
}
export async function cancelApproval(
@@ -1339,13 +1481,16 @@ export async function cancelApproval(
) {
const request = await assertApprovalRequest(approvalRequestId, organizationId);
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
throw new AuthError('Only pending approvals can be cancelled', 400);
}
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
throw new AuthError('Only pending approvals can be cancelled', 400);
}
if (request.requestedBy !== userId) {
await assertActorCanHandleStep(organizationId, userId, 'sales_manager');
}
const workflow = await assertWorkflowById(request.workflowId, organizationId);
const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId);
if (request.requestedBy !== userId) {
await assertActorCanHandleStep(organizationId, userId, 'sales_manager');
}
const [createdAction] = await db
.insert(crmApprovalActions)
@@ -1378,8 +1523,8 @@ export async function cancelApproval(
APPROVAL_REQUEST_STATUSES.returned
);
await Promise.all([
auditAction({
await Promise.all([
auditAction({
organizationId,
userId,
entityType: 'crm_approval_request',
@@ -1395,8 +1540,20 @@ export async function cancelApproval(
entityId: createdAction.id,
action: APPROVAL_ACTIONS.cancel,
afterData: createdAction
})
]);
})
]);
return updatedRequest;
await publishApprovalNotificationSafely({
organizationId,
actorUserId: userId,
eventType: 'approval.cancelled',
request: updatedRequest,
workflowName: workflow.name,
currentStep: currentStep
? { roleCode: currentStep.roleCode, roleName: currentStep.roleName }
: null,
dedupeKey: `approval.cancelled:${updatedRequest.id}:${createdAction.id}`
});
return updatedRequest;
}

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';
};

View File

@@ -0,0 +1,42 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
archiveNotificationItem,
markAllNotificationsRead,
markNotificationRead
} from './service';
import { notificationKeys } from './queries';
async function invalidateNotifications() {
await Promise.all([
getQueryClient().invalidateQueries({ queryKey: notificationKeys.lists() }),
getQueryClient().invalidateQueries({ queryKey: notificationKeys.unreadCount() })
]);
}
export const markNotificationReadMutation = mutationOptions({
mutationFn: (id: string) => markNotificationRead(id),
onSettled: async (_data, error) => {
if (!error) {
await invalidateNotifications();
}
}
});
export const markAllNotificationsReadMutation = mutationOptions({
mutationFn: () => markAllNotificationsRead(),
onSettled: async (_data, error) => {
if (!error) {
await invalidateNotifications();
}
}
});
export const archiveNotificationMutation = mutationOptions({
mutationFn: (id: string) => archiveNotificationItem(id),
onSettled: async (_data, error) => {
if (!error) {
await invalidateNotifications();
}
}
});

View File

@@ -0,0 +1,22 @@
import { queryOptions } from '@tanstack/react-query';
import { getNotifications, getUnreadNotificationCount } from './service';
import type { NotificationFilters } from './types';
export const notificationKeys = {
all: ['notifications'] as const,
lists: () => [...notificationKeys.all, 'list'] as const,
list: (filters: NotificationFilters) => [...notificationKeys.lists(), filters] as const,
unreadCount: () => [...notificationKeys.all, 'unread-count'] as const
};
export const notificationsQueryOptions = (filters: NotificationFilters = {}) =>
queryOptions({
queryKey: notificationKeys.list(filters),
queryFn: () => getNotifications(filters)
});
export const notificationUnreadCountQueryOptions = () =>
queryOptions({
queryKey: notificationKeys.unreadCount(),
queryFn: () => getUnreadNotificationCount()
});

View File

@@ -0,0 +1,49 @@
import { apiClient } from '@/lib/api-client';
import type {
NotificationFilters,
NotificationListResponse,
NotificationMutationResponse,
NotificationUnreadCountResponse
} from './types';
function buildNotificationSearchParams(filters: NotificationFilters) {
const searchParams = new URLSearchParams();
if (filters.page) {
searchParams.set('page', String(filters.page));
}
if (filters.limit) {
searchParams.set('limit', String(filters.limit));
}
if (filters.status) {
searchParams.set('status', filters.status);
}
return searchParams.toString();
}
export async function getNotifications(filters: NotificationFilters = {}) {
const search = buildNotificationSearchParams(filters);
return apiClient<NotificationListResponse>(`/notifications${search ? `?${search}` : ''}`);
}
export async function getUnreadNotificationCount() {
return apiClient<NotificationUnreadCountResponse>('/notifications/unread-count');
}
export async function markNotificationRead(id: string) {
return apiClient<NotificationMutationResponse>(`/notifications/${id}/read`, {
method: 'POST'
});
}
export async function markAllNotificationsRead() {
return apiClient<NotificationMutationResponse>('/notifications/read-all', {
method: 'POST'
});
}
export async function archiveNotificationItem(id: string) {
return apiClient<NotificationMutationResponse>(`/notifications/${id}/archive`, {
method: 'POST'
});
}

View File

@@ -0,0 +1,48 @@
export type NotificationSeverity = 'info' | 'success' | 'warning' | 'error';
export type NotificationStatus = 'unread' | 'read' | 'archived';
export interface NotificationListItem {
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 interface NotificationFilters {
page?: number;
limit?: number;
status?: NotificationStatus | 'all';
}
export interface NotificationListResponse {
success: boolean;
time: string;
message: string;
items: NotificationListItem[];
totalItems: number;
unreadCount: number;
page: number;
limit: number;
}
export interface NotificationUnreadCountResponse {
success: boolean;
time: string;
message: string;
unreadCount: number;
}
export interface NotificationMutationResponse {
success: boolean;
message: string;
}

View File

@@ -1,39 +1,47 @@
'use client';
import { Icons } from '@/components/icons';
import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { NotificationCard, type NotificationAction } from '@/components/ui/notification-card';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { NotificationCard } from '@/components/ui/notification-card';
import { useNotificationStore } from '../utils/store';
import { useRouter } from 'next/navigation';
import {
markAllNotificationsReadMutation,
markNotificationReadMutation
} from '../api/mutations';
import { notificationsQueryOptions } from '../api/queries';
const MAX_VISIBLE = 5;
const actionRoutes: Record<string, string> = {
view: '/dashboard/workspaces',
'view-product': '/dashboard/product',
billing: '/dashboard/billing',
open: '/dashboard/kanban',
'open-chat': '/dashboard/chat'
};
function buildNotificationActions(linkUrl: string | null): NotificationAction[] {
if (!linkUrl) {
return [];
}
return [{ id: `open:${linkUrl}`, label: 'Open', type: 'redirect', style: 'primary' }];
}
export function NotificationCenter() {
const { notifications, markAsRead, markAllAsRead, unreadCount } = useNotificationStore();
const router = useRouter();
const count = unreadCount();
const visibleNotifications = notifications.slice(0, MAX_VISIBLE);
const notificationsQuery = useQuery(notificationsQueryOptions({ page: 1, limit: MAX_VISIBLE }));
const markRead = useMutation(markNotificationReadMutation);
const markAllRead = useMutation(markAllNotificationsReadMutation);
const notifications = notificationsQuery.data?.items ?? [];
const unreadCount = notificationsQuery.data?.unreadCount ?? 0;
return (
<Popover>
<PopoverTrigger asChild>
<Button variant='ghost' size='icon' className='relative h-8 w-8'>
<Icons.notification className='h-4 w-4' />
{count > 0 && (
{unreadCount > 0 && (
<span className='bg-destructive text-destructive-foreground absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium'>
{count > 9 ? '9+' : count}
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
<span className='sr-only'>Notifications</span>
@@ -46,17 +54,18 @@ export function NotificationCenter() {
<Icons.chevronRight className='text-muted-foreground h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5' />
</Link>
<div className='flex items-center gap-2'>
{count > 0 && (
{unreadCount > 0 && (
<span className='bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs'>
{count} new
{unreadCount} new
</span>
)}
{count > 0 && (
{unreadCount > 0 && (
<Button
variant='ghost'
size='sm'
className='text-muted-foreground h-auto px-2 py-1 text-xs'
onClick={markAllAsRead}
disabled={markAllRead.isPending}
onClick={() => markAllRead.mutate()}
>
Mark all as read
</Button>
@@ -65,14 +74,25 @@ export function NotificationCenter() {
</div>
<Separator />
<ScrollArea className='h-[400px]'>
{notifications.length === 0 ? (
{notificationsQuery.isError ? (
<div className='flex flex-col items-center justify-center py-12 text-center'>
<Icons.alertCircle className='text-muted-foreground/40 mb-2 h-8 w-8' />
<p className='text-muted-foreground text-sm'>Unable to load notifications</p>
</div>
) : notificationsQuery.isLoading ? (
<div className='flex flex-col gap-2 p-2'>
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className='bg-muted h-24 animate-pulse rounded-2xl' />
))}
</div>
) : notifications.length === 0 ? (
<div className='flex flex-col items-center justify-center py-12'>
<Icons.notification className='text-muted-foreground/40 mb-2 h-8 w-8' />
<p className='text-muted-foreground text-sm'>No notifications yet</p>
</div>
) : (
<div className='flex flex-col gap-1 p-2'>
{visibleNotifications.map((notification) => (
{notifications.map((notification) => (
<NotificationCard
key={notification.id}
id={notification.id}
@@ -80,13 +100,12 @@ export function NotificationCenter() {
body={notification.body}
status={notification.status}
createdAt={notification.createdAt}
actions={notification.actions}
onMarkAsRead={markAsRead}
onAction={(notifId, actionId) => {
const route = actionRoutes[actionId];
if (route) {
markAsRead(notifId);
router.push(route);
actions={buildNotificationActions(notification.linkUrl)}
onMarkAsRead={(id) => markRead.mutate(id)}
onAction={async (notificationId, actionId) => {
if (actionId.startsWith('open:')) {
await markRead.mutateAsync(notificationId);
router.push(actionId.replace('open:', ''));
}
}}
/>

View File

@@ -1,30 +1,79 @@
'use client';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { Icons } from '@/components/icons';
import PageContainer from '@/components/layout/page-container';
import { Button } from '@/components/ui/button';
import { NotificationCard } from '@/components/ui/notification-card';
import { NotificationCard, type NotificationAction } from '@/components/ui/notification-card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useRouter } from 'next/navigation';
import { useNotificationStore } from '../utils/store';
import {
archiveNotificationMutation,
markAllNotificationsReadMutation,
markNotificationReadMutation
} from '../api/mutations';
import { notificationsQueryOptions } from '../api/queries';
import type { NotificationListItem } from '../api/types';
const actionRoutes: Record<string, string> = {
view: '/dashboard/workspaces',
'view-product': '/dashboard/product',
billing: '/dashboard/billing',
open: '/dashboard/kanban',
'open-chat': '/dashboard/chat'
};
function buildNotificationActions(notification: NotificationListItem): NotificationAction[] {
const actions: NotificationAction[] = [];
if (notification.linkUrl) {
actions.push({
id: `open:${notification.linkUrl}`,
label: 'Open',
type: 'redirect',
style: 'primary'
});
}
if (notification.status !== 'archived') {
actions.push({
id: `archive:${notification.id}`,
label: 'Archive',
type: 'api_call',
style: 'default'
});
}
return actions;
}
export default function NotificationsPage() {
const { notifications, markAsRead, markAllAsRead, unreadCount } = useNotificationStore();
const router = useRouter();
const count = unreadCount();
const notificationsQuery = useQuery(notificationsQueryOptions({ page: 1, limit: 50, status: 'all' }));
const markRead = useMutation(markNotificationReadMutation);
const markAllRead = useMutation(markAllNotificationsReadMutation);
const archive = useMutation(archiveNotificationMutation);
const unreadNotifications = notifications.filter((n) => n.status === 'unread');
const readNotifications = notifications.filter((n) => n.status === 'read');
const notifications = notificationsQuery.data?.items ?? [];
const unreadCount = notificationsQuery.data?.unreadCount ?? 0;
const unreadNotifications = notifications.filter((notification) => notification.status === 'unread');
const readNotifications = notifications.filter((notification) => notification.status === 'read');
const archivedNotifications = notifications.filter(
(notification) => notification.status === 'archived'
);
function renderList(items: NotificationListItem[]) {
if (notificationsQuery.isError) {
return (
<div className='flex flex-col items-center justify-center py-16'>
<Icons.alertCircle className='text-muted-foreground/40 mb-3 h-10 w-10' />
<p className='text-muted-foreground text-sm'>Unable to load notifications</p>
</div>
);
}
if (notificationsQuery.isLoading) {
return (
<div className='flex flex-col gap-2'>
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className='bg-muted h-24 animate-pulse rounded-2xl' />
))}
</div>
);
}
const renderList = (items: typeof notifications) => {
if (items.length === 0) {
return (
<div className='flex flex-col items-center justify-center py-16'>
@@ -44,28 +93,37 @@ export default function NotificationsPage() {
body={notification.body}
status={notification.status}
createdAt={notification.createdAt}
actions={notification.actions}
onMarkAsRead={markAsRead}
onAction={(notifId, actionId) => {
const route = actionRoutes[actionId];
if (route) {
markAsRead(notifId);
router.push(route);
actions={buildNotificationActions(notification)}
onMarkAsRead={(id) => markRead.mutate(id)}
onAction={async (notificationId, actionId) => {
if (actionId.startsWith('open:')) {
await markRead.mutateAsync(notificationId);
router.push(actionId.replace('open:', ''));
return;
}
if (actionId.startsWith('archive:')) {
await archive.mutateAsync(notificationId);
}
}}
/>
))}
</div>
);
};
}
return (
<PageContainer
pageTitle='Notifications'
pageDescription='View and manage all your notifications.'
pageHeaderAction={
count > 0 ? (
<Button variant='outline' size='sm' onClick={markAllAsRead}>
unreadCount > 0 ? (
<Button
variant='outline'
size='sm'
disabled={markAllRead.isPending}
onClick={() => markAllRead.mutate()}
>
Mark all as read
</Button>
) : undefined
@@ -76,6 +134,7 @@ export default function NotificationsPage() {
<TabsTrigger value='all'>All ({notifications.length})</TabsTrigger>
<TabsTrigger value='unread'>Unread ({unreadNotifications.length})</TabsTrigger>
<TabsTrigger value='read'>Read ({readNotifications.length})</TabsTrigger>
<TabsTrigger value='archived'>Archived ({archivedNotifications.length})</TabsTrigger>
</TabsList>
<TabsContent value='all' className='mt-4'>
{renderList(notifications)}
@@ -86,6 +145,9 @@ export default function NotificationsPage() {
<TabsContent value='read' className='mt-4'>
{renderList(readNotifications)}
</TabsContent>
<TabsContent value='archived' className='mt-4'>
{renderList(archivedNotifications)}
</TabsContent>
</Tabs>
</PageContainer>
);

View File

@@ -1,137 +0,0 @@
import { create } from 'zustand';
// import { persist } from 'zustand/middleware';
import type { NotificationStatus, NotificationAction } from '@/components/ui/notification-card';
export type Notification = {
id: string;
title: string;
body: string;
status: NotificationStatus;
createdAt: string;
actions?: NotificationAction[];
};
type NotificationState = {
notifications: Notification[];
markAsRead: (id: string) => void;
markAllAsRead: () => void;
removeNotification: (id: string) => void;
addNotification: (notification: Omit<Notification, 'status'>) => void;
unreadCount: () => number;
};
const mockNotifications: Notification[] = [
{
id: '1',
title: 'New team member joined',
body: 'Sarah Connor has joined the Engineering workspace.',
status: 'unread',
createdAt: new Date(Date.now() - 1000 * 60 * 5).toISOString(),
actions: [
{
id: 'view',
label: 'View workspace',
type: 'redirect',
style: 'primary'
}
]
},
{
id: '2',
title: 'New product added',
body: 'A new product "Dashboard Pro" has been added to the catalog.',
status: 'unread',
createdAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
actions: [
{
id: 'view-product',
label: 'View products',
type: 'redirect',
style: 'primary'
}
]
},
{
id: '3',
title: 'Billing cycle updated',
body: 'Your Pro plan has been renewed. Next invoice on April 24, 2026.',
status: 'unread',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
actions: [
{
id: 'billing',
label: 'View billing',
type: 'redirect',
style: 'primary'
}
]
},
{
id: '4',
title: 'Task assigned to you',
body: 'You have been assigned "Update dashboard analytics" on the Kanban board.',
status: 'read',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
actions: [
{
id: 'open',
label: 'Open kanban',
type: 'redirect',
style: 'primary'
}
]
},
{
id: '5',
title: 'New message from Alex',
body: 'Alex sent you a message: "Hey, can we sync on the overview dashboard?"',
status: 'read',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
actions: [
{
id: 'open-chat',
label: 'Open chat',
type: 'redirect',
style: 'primary'
}
]
}
];
export const useNotificationStore = create<NotificationState>()(
// To enable persistence across refreshes, uncomment the persist wrapper below:
// persist(
(set, get) => ({
notifications: mockNotifications,
markAsRead: (id) =>
set((state) => ({
notifications: state.notifications.map((n) =>
n.id === id ? { ...n, status: 'read' as const } : n
)
})),
markAllAsRead: () =>
set((state) => ({
notifications: state.notifications.map((n) => ({
...n,
status: 'read' as const
}))
})),
removeNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id)
})),
addNotification: (notification) =>
set((state) => ({
notifications: [{ ...notification, status: 'unread' as const }, ...state.notifications]
})),
unreadCount: () => get().notifications.filter((n) => n.status === 'unread').length
})
// ,
// { name: 'notifications' }
// )
);