task-f.4
This commit is contained in:
36
src/app/api/notifications/[id]/archive/route.ts
Normal file
36
src/app/api/notifications/[id]/archive/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { archiveNotification } from '@/features/foundation/notifications/server/inbox-service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.notificationsUpdate
|
||||
});
|
||||
const { id } = await context.params;
|
||||
const notification = await archiveNotification(organization.id, session.user.id, id);
|
||||
|
||||
if (!notification) {
|
||||
return NextResponse.json({ message: 'Notification not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Notification archived'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to archive notification' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
36
src/app/api/notifications/[id]/read/route.ts
Normal file
36
src/app/api/notifications/[id]/read/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { markNotificationAsRead } from '@/features/foundation/notifications/server/inbox-service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.notificationsUpdate
|
||||
});
|
||||
const { id } = await context.params;
|
||||
const notification = await markNotificationAsRead(organization.id, session.user.id, id);
|
||||
|
||||
if (!notification) {
|
||||
return NextResponse.json({ message: 'Notification not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Notification marked as read'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to update notification' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
28
src/app/api/notifications/read-all/route.ts
Normal file
28
src/app/api/notifications/read-all/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { markAllNotificationsAsRead } from '@/features/foundation/notifications/server/inbox-service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.notificationsUpdate
|
||||
});
|
||||
const affectedCount = await markAllNotificationsAsRead(organization.id, session.user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: affectedCount > 0 ? 'Notifications marked as read' : 'No unread notifications'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to update notifications' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
39
src/app/api/notifications/route.ts
Normal file
39
src/app/api/notifications/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { listNotifications } from '@/features/foundation/notifications/server/inbox-service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.notificationsRead
|
||||
});
|
||||
const { searchParams } = request.nextUrl;
|
||||
const page = Number(searchParams.get('page') ?? 1);
|
||||
const limit = Number(searchParams.get('limit') ?? 20);
|
||||
const status = (searchParams.get('status') ?? 'all') as 'all' | 'unread' | 'read' | 'archived';
|
||||
|
||||
const result = await listNotifications(organization.id, session.user.id, {
|
||||
page,
|
||||
limit,
|
||||
status
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'Notifications loaded successfully',
|
||||
...result
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to load notifications' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
33
src/app/api/notifications/unread-count/route.ts
Normal file
33
src/app/api/notifications/unread-count/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getUnreadNotificationCount } from '@/features/foundation/notifications/server/inbox-service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.notificationsRead
|
||||
});
|
||||
const unreadCount = await getUnreadNotificationCount(organization.id, session.user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'Unread notification count loaded successfully',
|
||||
unreadCount
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable to load unread notification count' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,99 @@ export const trAuditLogs = pgTable('tr_audit_logs', {
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const appNotificationEvents = pgTable(
|
||||
'app_notification_events',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
eventType: text('event_type').notNull(),
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: text('entity_id').notNull(),
|
||||
actorUserId: text('actor_user_id'),
|
||||
payload: jsonb('payload'),
|
||||
dedupeKey: text('dedupe_key'),
|
||||
status: text('status').default('pending').notNull(),
|
||||
lastError: text('last_error'),
|
||||
publishedAt: timestamp('published_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
processedAt: timestamp('processed_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
organizationDedupeIdx: uniqueIndex('app_notification_events_org_dedupe_idx').on(
|
||||
table.organizationId,
|
||||
table.dedupeKey
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const appNotifications = pgTable(
|
||||
'app_notifications',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
recipientUserId: text('recipient_user_id').notNull(),
|
||||
eventId: text('event_id').notNull(),
|
||||
title: text('title').notNull(),
|
||||
body: text('body').notNull(),
|
||||
linkUrl: text('link_url'),
|
||||
severity: text('severity').default('info').notNull(),
|
||||
status: text('status').default('unread').notNull(),
|
||||
readAt: timestamp('read_at', { withTimezone: true }),
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
recipientEventIdx: uniqueIndex('app_notifications_recipient_event_idx').on(
|
||||
table.recipientUserId,
|
||||
table.eventId
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const appNotificationTemplates = pgTable(
|
||||
'app_notification_templates',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
eventType: text('event_type').notNull(),
|
||||
channel: text('channel').notNull(),
|
||||
titleTemplate: text('title_template').notNull(),
|
||||
bodyTemplate: text('body_template').notNull(),
|
||||
linkTemplate: text('link_template'),
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
createdBy: text('created_by').notNull(),
|
||||
updatedBy: text('updated_by').notNull()
|
||||
},
|
||||
(table) => ({
|
||||
organizationEventChannelIdx: uniqueIndex('app_notification_templates_org_event_channel_idx').on(
|
||||
table.organizationId,
|
||||
table.eventType,
|
||||
table.channel
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const appNotificationDeliveries = pgTable('app_notification_deliveries', {
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
notificationId: text('notification_id').notNull(),
|
||||
channel: text('channel').notNull(),
|
||||
recipientAddress: text('recipient_address'),
|
||||
status: text('status').default('pending').notNull(),
|
||||
attemptCount: integer('attempt_count').default(0).notNull(),
|
||||
lastError: text('last_error'),
|
||||
sentAt: timestamp('sent_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const crmCustomers = pgTable(
|
||||
'crm_customers',
|
||||
{
|
||||
|
||||
@@ -1439,6 +1439,93 @@ async function upsertReportDefinitionsForOrganization(
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertNotificationTemplatesForOrganization(
|
||||
sql: SqlClient,
|
||||
organization: { id: string; createdBy: string }
|
||||
) {
|
||||
const templates = [
|
||||
{
|
||||
eventType: 'approval.requested',
|
||||
titleTemplate: 'Approval Required: {{quotationCode}}',
|
||||
bodyTemplate:
|
||||
'{{actorName}} submitted {{quotationCode}} for approval in {{workflowName}}.',
|
||||
linkTemplate: '{{entityLink}}'
|
||||
},
|
||||
{
|
||||
eventType: 'approval.step.approved',
|
||||
titleTemplate: 'Approval Updated: {{quotationCode}}',
|
||||
bodyTemplate: '{{actorName}} approved {{quotationCode}}. Next step: {{currentStepRoleName}}.',
|
||||
linkTemplate: '{{entityLink}}'
|
||||
},
|
||||
{
|
||||
eventType: 'approval.step.rejected',
|
||||
titleTemplate: 'Approval Rejected: {{quotationCode}}',
|
||||
bodyTemplate: '{{actorName}} rejected {{quotationCode}}. Reason: {{remark}}',
|
||||
linkTemplate: '{{entityLink}}'
|
||||
},
|
||||
{
|
||||
eventType: 'approval.completed',
|
||||
titleTemplate: 'Approval Completed: {{quotationCode}}',
|
||||
bodyTemplate: '{{quotationCode}} has been fully approved in {{workflowName}}.',
|
||||
linkTemplate: '{{entityLink}}'
|
||||
},
|
||||
{
|
||||
eventType: 'approval.cancelled',
|
||||
titleTemplate: 'Approval Cancelled: {{quotationCode}}',
|
||||
bodyTemplate: '{{actorName}} cancelled the approval request for {{quotationCode}}.',
|
||||
linkTemplate: '{{entityLink}}'
|
||||
},
|
||||
{
|
||||
eventType: 'approval.returned',
|
||||
titleTemplate: 'Approval Returned: {{quotationCode}}',
|
||||
bodyTemplate: '{{actorName}} returned {{quotationCode}} for revision. Reason: {{remark}}',
|
||||
linkTemplate: '{{entityLink}}'
|
||||
}
|
||||
];
|
||||
|
||||
for (const template of templates) {
|
||||
await sql`
|
||||
insert into app_notification_templates (
|
||||
id,
|
||||
organization_id,
|
||||
event_type,
|
||||
channel,
|
||||
title_template,
|
||||
body_template,
|
||||
link_template,
|
||||
is_active,
|
||||
metadata,
|
||||
deleted_at,
|
||||
created_by,
|
||||
updated_by
|
||||
) values (
|
||||
${crypto.randomUUID()},
|
||||
${organization.id},
|
||||
${template.eventType},
|
||||
${'in_app'},
|
||||
${template.titleTemplate},
|
||||
${template.bodyTemplate},
|
||||
${template.linkTemplate},
|
||||
${true},
|
||||
${JSON.stringify({ seededBy: 'foundation.seed' })},
|
||||
${null},
|
||||
${organization.createdBy},
|
||||
${organization.createdBy}
|
||||
)
|
||||
on conflict (organization_id, event_type, channel) do update
|
||||
set
|
||||
title_template = excluded.title_template,
|
||||
body_template = excluded.body_template,
|
||||
link_template = excluded.link_template,
|
||||
is_active = excluded.is_active,
|
||||
metadata = excluded.metadata,
|
||||
deleted_at = excluded.deleted_at,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = now()
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
loadLocalEnv();
|
||||
|
||||
@@ -1464,9 +1551,10 @@ async function main() {
|
||||
await upsertApprovalWorkflowsForOrganization(sql, organization.id);
|
||||
await upsertApprovalMatricesForOrganization(sql, organization);
|
||||
await upsertDocumentTemplatesForOrganization(sql, organization);
|
||||
await upsertNotificationTemplatesForOrganization(sql, organization);
|
||||
await upsertReportDefinitionsForOrganization(sql, organization);
|
||||
console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`);
|
||||
}
|
||||
console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`);
|
||||
}
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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';
|
||||
};
|
||||
42
src/features/notifications/api/mutations.ts
Normal file
42
src/features/notifications/api/mutations.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
22
src/features/notifications/api/queries.ts
Normal file
22
src/features/notifications/api/queries.ts
Normal 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()
|
||||
});
|
||||
49
src/features/notifications/api/service.ts
Normal file
49
src/features/notifications/api/service.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
48
src/features/notifications/api/types.ts
Normal file
48
src/features/notifications/api/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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:', ''));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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' }
|
||||
// )
|
||||
);
|
||||
@@ -28,6 +28,9 @@ export const PERMISSIONS = {
|
||||
productsWrite: 'products:write',
|
||||
organizationManage: 'organization:manage',
|
||||
usersManage: 'users:manage',
|
||||
notificationsRead: 'notifications.read',
|
||||
notificationsUpdate: 'notifications.update',
|
||||
notificationsAdmin: 'notifications.admin',
|
||||
reportRead: 'report:read',
|
||||
crmLeadRead: 'crm.lead.read',
|
||||
crmLeadCreate: 'crm.lead.create',
|
||||
@@ -582,6 +585,15 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
||||
{ key: PERMISSIONS.crmDashboardExport, label: 'Export dashboard' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'notifications',
|
||||
label: 'Notifications',
|
||||
permissions: [
|
||||
{ key: PERMISSIONS.notificationsRead, label: 'Read notifications' },
|
||||
{ key: PERMISSIONS.notificationsUpdate, label: 'Update notifications' },
|
||||
{ key: PERMISSIONS.notificationsAdmin, label: 'Administer notifications' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'report',
|
||||
label: 'Reports',
|
||||
@@ -658,11 +670,14 @@ export function getMembershipBasePermissions(role: MembershipRole): Permission[]
|
||||
PERMISSIONS.productsRead,
|
||||
PERMISSIONS.productsWrite,
|
||||
PERMISSIONS.organizationManage,
|
||||
PERMISSIONS.usersManage
|
||||
PERMISSIONS.usersManage,
|
||||
PERMISSIONS.notificationsRead,
|
||||
PERMISSIONS.notificationsUpdate,
|
||||
PERMISSIONS.notificationsAdmin
|
||||
];
|
||||
}
|
||||
|
||||
return [PERMISSIONS.productsRead];
|
||||
return [PERMISSIONS.productsRead, PERMISSIONS.notificationsRead, PERMISSIONS.notificationsUpdate];
|
||||
}
|
||||
|
||||
export function getRoleProfileDefinition(roleCode: string): CrmRoleProfileDefinition | null {
|
||||
|
||||
Reference in New Issue
Block a user