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

View File

@@ -0,0 +1,116 @@
# Task F4: Notification Framework Foundation
## Scope Delivered
- Added notification foundation schema for events, inbox items, templates, and deliveries.
- Added reusable notification services for event publishing, recipient resolution, template rendering, and inbox mutations.
- Integrated approval lifecycle notifications for submit, approve-complete, reject, return, and cancel flows.
- Replaced the old mock/Zustand notification UI with API-backed React Query notifications in the header bell and notifications page.
## Schema
New tables:
- `app_notification_events`
- `app_notifications`
- `app_notification_templates`
- `app_notification_deliveries`
Key behavior:
- organization-scoped rows throughout
- `dedupe_key` support on events
- inbox rows track `read_at`, `archived_at`, and `status`
- delivery rows prepare future multi-channel expansion
## Event Flow
Current flow:
1. Approval service completes its main action.
2. Approval service calls `publishNotificationEvent()` with a safe wrapper.
3. Notification foundation persists `app_notification_events`.
4. Notification foundation resolves recipients and renders the matching template.
5. In-app notifications are created in `app_notifications`.
6. Header bell and `/dashboard/notifications` read from the inbox APIs.
Notification failures are intentionally non-blocking for approval actions. Failures are stored on the notification event row and audited.
## Recipient Resolution
Implemented resolvers:
- `explicit_user`
- `approval_current_step_approvers`
- `approval_requester`
Current approval approver resolution uses:
- active CRM role assignments mapped to the current step role code
- membership `business_role` as a fallback path
Recipients are deduplicated and actor exclusion is supported per event rule.
## Template Syntax
Supported placeholders use simple token replacement:
- `{{quotationCode}}`
- `{{workflowName}}`
- `{{actorName}}`
- `{{currentStepRoleName}}`
- `{{entityLink}}`
- `{{remark}}`
Rules:
- missing values render as `-`
- plain text only
- no code execution
## Approval Integration
Integrated events:
- `approval.requested`
- `approval.step.approved` for step-to-step progression
- `approval.completed` for final approval completion
- `approval.step.rejected`
- `approval.returned`
- `approval.cancelled`
Notes:
- final approval emits `approval.completed` instead of duplicating both requester notifications
- notification delivery is intentionally best-effort and does not roll back approvals
## Inbox APIs
Added routes:
- `GET /api/notifications`
- `GET /api/notifications/unread-count`
- `POST /api/notifications/[id]/read`
- `POST /api/notifications/read-all`
- `POST /api/notifications/[id]/archive`
Access model:
- `notifications.read`
- `notifications.update`
- `notifications.admin`
Normal users can operate only on their own notifications because server-side filtering always scopes by `recipient_user_id`.
## Default Templates
Foundation seed now upserts default `in_app` templates for all approval events. Seed remains idempotent and organization-scoped.
## Current Limitations
- no SMTP sending yet
- no LINE delivery yet
- no webhook delivery yet
- no reminder/escalation scheduler yet
- no admin template editor UI yet
- no real-time push/SSE yet

View File

@@ -0,0 +1,73 @@
CREATE TABLE "app_notification_deliveries" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"notification_id" text NOT NULL,
"channel" text NOT NULL,
"recipient_address" text,
"status" text DEFAULT 'pending' NOT NULL,
"attempt_count" integer DEFAULT 0 NOT NULL,
"last_error" text,
"sent_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "app_notification_events" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"event_type" text NOT NULL,
"entity_type" text NOT NULL,
"entity_id" text NOT NULL,
"actor_user_id" text,
"payload" jsonb,
"dedupe_key" text,
"status" text DEFAULT 'pending' NOT NULL,
"last_error" text,
"published_at" timestamp with time zone DEFAULT now() NOT NULL,
"processed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "app_notification_templates" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"event_type" text NOT NULL,
"channel" text NOT NULL,
"title_template" text NOT NULL,
"body_template" text NOT NULL,
"link_template" text,
"is_active" boolean DEFAULT true NOT NULL,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"created_by" text NOT NULL,
"updated_by" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "app_notifications" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"recipient_user_id" text NOT NULL,
"event_id" text NOT NULL,
"title" text NOT NULL,
"body" text NOT NULL,
"link_url" text,
"severity" text DEFAULT 'info' NOT NULL,
"status" text DEFAULT 'unread' NOT NULL,
"read_at" timestamp with time zone,
"archived_at" timestamp with time zone,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "app_notification_events_org_dedupe_idx"
ON "app_notification_events" USING btree ("organization_id", "dedupe_key");
--> statement-breakpoint
CREATE UNIQUE INDEX "app_notification_templates_org_event_channel_idx"
ON "app_notification_templates" USING btree ("organization_id", "event_type", "channel");
--> statement-breakpoint
CREATE UNIQUE INDEX "app_notifications_recipient_event_idx"
ON "app_notifications" USING btree ("recipient_user_id", "event_id");

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,13 @@
"when": 1782691200000,
"tag": "0003_workflow_builder_foundation",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1782455991734,
"tag": "0004_unusual_hairball",
"breakpoints": true
}
]
}

675
plans/task-f.4.md Normal file
View File

@@ -0,0 +1,675 @@
# Task F.4: Notification Framework Foundation
## Status
Planned
## Objective
Build a reusable Notification Framework for CRM events, starting with Approval events.
This task introduces a centralized notification foundation so Approval, Lead, Opportunity, Quotation, Follow-up, and future modules can publish events without each feature directly sending email or UI notifications.
The goal is to support:
* event-driven notification creation
* in-app notification inbox / bell foundation
* notification recipient resolution
* notification templates
* read/unread state
* approval event integration
* future channel support for email, LINE, webhook, and reminder/escalation
This task does not implement full email delivery, LINE integration, or escalation scheduling yet.
---
## Mandatory Review
Review before implementation:
* `AGENTS.md`
* `docs/standards/project-foundations.md`
* `docs/standards/task-catalog.md`
* `docs/implementation/task-f2-approval-matrix-foundation.md`
* `docs/implementation/task-f3-approval-workflow-builder-foundation.md`
* existing Approval submit/approve/reject/cancel flow
* `src/db/schema.ts`
* `src/features/crm/approval/**`
* `src/features/foundation/approval/**`
* `src/features/foundation/audit-log/**`
* `src/lib/auth/rbac.ts`
* `docs/security/crm-authorization-boundaries.md`
---
## Current Foundation
Already available:
```txt
Approval Matrix
Approval Workflow Builder
Approval Requests
Approval Actions
Audit Log Foundation
CRM Authorization Foundation
```
Current limitation:
```txt
Approval actions do not yet publish reusable notifications.
```
---
## Business Requirement
When approval activity happens, related users should be notified.
Examples:
```txt
Quotation submitted for approval
→ notify current approver(s)
Quotation approved
→ notify requester and next approver if any
Quotation rejected
→ notify requester
Quotation returned / cancelled
→ notify requester or related actors
```
The notification system should be generic enough for future events:
```txt
Lead Assigned
Opportunity Assigned
Follow-up Due
Quotation Expiring
Opportunity Won
Opportunity Lost
PO Received
```
---
## Scope F.4.1 Notification Schema
Add tables:
```txt
app_notifications
app_notification_events
app_notification_templates
app_notification_deliveries
```
### app_notification_events
Stores source events published by modules.
Recommended columns:
```txt
id
organization_id
event_type
entity_type
entity_id
actor_user_id
payload jsonb
dedupe_key
status
published_at
processed_at
created_at
```
Purpose:
```txt
Event source stream
```
### app_notifications
Stores user-facing in-app notifications.
Recommended columns:
```txt
id
organization_id
recipient_user_id
event_id
title
body
link_url
severity
status
read_at
archived_at
metadata jsonb
created_at
updated_at
```
Purpose:
```txt
Notification inbox / bell
```
### app_notification_templates
Stores reusable message templates.
Recommended columns:
```txt
id
organization_id
event_type
channel
title_template
body_template
link_template
is_active
metadata jsonb
created_at
updated_at
deleted_at
created_by
updated_by
```
Purpose:
```txt
Event message rendering
```
### app_notification_deliveries
Tracks channel delivery attempts.
Recommended columns:
```txt
id
organization_id
notification_id
channel
recipient_address
status
attempt_count
last_error
sent_at
created_at
updated_at
```
Purpose:
```txt
Future email / LINE / webhook delivery tracking
```
Rules:
* In-app notification is implemented first
* Email/LINE delivery can remain queued or pending
* Use organization scope everywhere
* Use soft delete where appropriate
---
## Scope F.4.2 Notification Event Service
Create:
```txt
src/features/foundation/notifications/server/event-service.ts
```
Functions:
```ts
publishNotificationEvent(input)
processNotificationEvent(eventId)
processPendingNotificationEvents()
```
Input example:
```ts
{
organizationId: string;
eventType: string;
entityType: string;
entityId: string;
actorUserId?: string | null;
payload?: Record<string, unknown>;
dedupeKey?: string;
}
```
Rules:
* publishing an event does not send email directly
* event creates one or more in-app notifications
* duplicate event with same `dedupeKey` should be ignored or safely reused
* processing should be idempotent
* failed processing should leave inspectable state
---
## Scope F.4.3 Recipient Resolver
Create:
```txt
src/features/foundation/notifications/server/recipient-resolver.ts
```
Support recipient types:
```txt
explicit_user
approval_current_step_approvers
approval_requester
approval_previous_actor
entity_owner
sales_owner
```
For F.4 required recipient resolvers:
```txt
approval_current_step_approvers
approval_requester
explicit_user
```
Rules:
* return user IDs only
* never notify deleted/inactive users if such state exists
* avoid notifying the actor if event rules say actor should be excluded
* de-duplicate recipients
---
## Scope F.4.4 Template Renderer
Create:
```txt
src/features/foundation/notifications/server/template-renderer.ts
```
Functions:
```ts
renderNotificationTemplate(template, payload)
```
Supported syntax can be simple:
```txt
{{quotationCode}}
{{workflowName}}
{{actorName}}
{{stepName}}
{{entityLink}}
```
Rules:
* missing variables render as `-` or empty string
* no arbitrary code execution
* no unsafe HTML injection
* plain text first
---
## Scope F.4.5 Notification Inbox APIs
Create:
```txt
GET /api/notifications
GET /api/notifications/unread-count
POST /api/notifications/[id]/read
POST /api/notifications/read-all
POST /api/notifications/[id]/archive
```
Rules:
* users can only see their own notifications
* organization scope must apply
* unread count must be efficient
* support pagination
* default ordering: newest first
---
## Scope F.4.6 Notification UI Foundation
Add or integrate notification bell.
Required UI:
```txt
Notification Bell
Unread Count Badge
Notification Dropdown / Sheet
Mark as Read
Mark All as Read
Open Link
```
Recommended placement:
```txt
top nav / header
```
Rules:
* in-app only in F.4
* no real-time requirement yet
* polling or manual refetch is acceptable
* empty state required
* error state required
---
## Scope F.4.7 Approval Event Integration
Publish notification events from approval lifecycle.
Required events:
```txt
approval.requested
approval.step.approved
approval.step.rejected
approval.completed
approval.cancelled
approval.returned
```
Required recipients:
### approval.requested
Notify:
```txt
current step approvers
```
### approval.step.approved
Notify:
```txt
requester
next step approvers if any
```
### approval.step.rejected
Notify:
```txt
requester
```
### approval.completed
Notify:
```txt
requester
```
### approval.cancelled
Notify:
```txt
requester
current approvers if needed
```
### approval.returned
Notify:
```txt
requester
```
Rules:
* publish events from approval service layer, not route handlers
* do not block approval transaction on notification delivery failure if possible
* notification failure must be logged/inspectable
* approval action should still succeed if notification creation fails unless strict mode is explicitly required
---
## Scope F.4.8 Default Notification Templates
Seed default templates for approval events.
Example:
```txt
approval.requested
Title: Approval Required: {{quotationCode}}
Body: {{actorName}} submitted {{quotationCode}} for approval.
Link: /dashboard/crm/quotations/{{quotationId}}
```
```txt
approval.step.rejected
Title: Approval Rejected: {{quotationCode}}
Body: {{actorName}} rejected {{quotationCode}}. Reason: {{remark}}
Link: /dashboard/crm/quotations/{{quotationId}}
```
Rules:
* templates should be organization-scoped
* seed should be idempotent
* no customer-sensitive data beyond document code/title in default body
---
## Scope F.4.9 Delivery Channel Foundation
Support channel enum:
```txt
in_app
email
line
webhook
```
F.4 implementation:
```txt
in_app = active
email = delivery record only / pending
line = future
webhook = future
```
Do not implement real SMTP/LINE/Webhook sending yet.
---
## Scope F.4.10 Audit / Observability
Audit or log:
```txt
notification_event_published
notification_event_processed
notification_created
notification_read
notification_archived
```
Rules:
* user read/archive actions should be auditable if current audit policy requires it
* processing failures must store error detail safely
* do not expose stack traces to users
---
## Scope F.4.11 Permissions
Add or verify permissions:
```txt
notifications.read
notifications.update
notifications.admin
```
Rules:
* normal users can read/update only their own notifications
* admin can inspect templates/events only if admin API is added later
* notification bell should not require CRM module-specific permission
---
## Scope F.4.12 Documentation
Create:
```txt
docs/implementation/task-f4-notification-framework-foundation.md
```
Document:
```txt
schema
event publishing flow
recipient resolver
template syntax
approval event integration
delivery channel limitations
known limitations
```
---
## Event Flow
Recommended architecture:
```txt
Approval Service
publishNotificationEvent()
app_notification_events
processNotificationEvent()
resolveRecipients()
renderTemplate()
app_notifications
Notification Bell / Inbox
```
---
## Explicit Non-Scope
Do not implement:
* SMTP email sending
* LINE OA integration
* webhook delivery
* real-time websocket / SSE
* reminder scheduling
* escalation rules
* notification preference center
* admin template editor UI
* full notification analytics
* mobile push notifications
These belong to later F tasks.
---
## Verification
Run:
```txt
npm exec tsc --noEmit
npm run build
```
Manual verification:
```txt
Submit quotation for approval
Current approver receives notification
Approve step
Requester receives notification
Next approver receives notification if next step exists
Reject step
Requester receives notification
Unread count updates
Mark one notification as read
Mark all notifications as read
Archive notification
Open notification link
Approval still succeeds if notification processing fails gracefully
```
---
## Definition of Done
Task is complete when:
* Notification event tables exist
* Notification event service exists
* Recipient resolver exists
* Template renderer exists
* In-app notification records are created from approval events
* Notification inbox APIs exist
* Notification bell UI exists
* Approval submit/approve/reject events publish notifications
* Notification read/unread workflow works
* Existing approval flow remains operational
Result:
```txt
Notification Framework Foundation = Established
Approval Notifications = Operational In-App
Ready for F.5 Reminder / Escalation
```

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 }
);
}
}

View File

@@ -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',
{

View File

@@ -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();
}

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

View File

@@ -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 {