task-f.4
This commit is contained in:
116
docs/implementation/task-f4-notification-framework-foundation.md
Normal file
116
docs/implementation/task-f4-notification-framework-foundation.md
Normal 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
|
||||||
73
drizzle/0004_unusual_hairball.sql
Normal file
73
drizzle/0004_unusual_hairball.sql
Normal 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");
|
||||||
4909
drizzle/meta/0004_snapshot.json
Normal file
4909
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
|||||||
"when": 1782691200000,
|
"when": 1782691200000,
|
||||||
"tag": "0003_workflow_builder_foundation",
|
"tag": "0003_workflow_builder_foundation",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782455991734,
|
||||||
|
"tag": "0004_unusual_hairball",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
675
plans/task-f.4.md
Normal file
675
plans/task-f.4.md
Normal 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
|
||||||
|
```
|
||||||
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()
|
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(
|
export const crmCustomers = pgTable(
|
||||||
'crm_customers',
|
'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() {
|
async function main() {
|
||||||
loadLocalEnv();
|
loadLocalEnv();
|
||||||
|
|
||||||
@@ -1464,6 +1551,7 @@ async function main() {
|
|||||||
await upsertApprovalWorkflowsForOrganization(sql, organization.id);
|
await upsertApprovalWorkflowsForOrganization(sql, organization.id);
|
||||||
await upsertApprovalMatricesForOrganization(sql, organization);
|
await upsertApprovalMatricesForOrganization(sql, organization);
|
||||||
await upsertDocumentTemplatesForOrganization(sql, organization);
|
await upsertDocumentTemplatesForOrganization(sql, organization);
|
||||||
|
await upsertNotificationTemplatesForOrganization(sql, organization);
|
||||||
await upsertReportDefinitionsForOrganization(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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access';
|
|||||||
import { type SystemRole } from '@/lib/auth/rbac';
|
import { type SystemRole } from '@/lib/auth/rbac';
|
||||||
import { AuthError } from '@/lib/auth/session';
|
import { AuthError } from '@/lib/auth/session';
|
||||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import { publishNotificationEvent } from '@/features/foundation/notifications/server/event-service';
|
||||||
import {
|
import {
|
||||||
type CrmSecurityContext,
|
type CrmSecurityContext,
|
||||||
canAccessScopedCrmRecord
|
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) {
|
function parseSort(sort?: string) {
|
||||||
if (!sort) {
|
if (!sort) {
|
||||||
return desc(crmApprovalRequests.updatedAt);
|
return desc(crmApprovalRequests.updatedAt);
|
||||||
@@ -1102,6 +1184,17 @@ export async function submitForApproval(
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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;
|
return createdRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1117,6 +1210,7 @@ export async function approveApproval(
|
|||||||
throw new AuthError('Only pending approvals can be approved', 400);
|
throw new AuthError('Only pending approvals can be approved', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workflow = await assertWorkflowById(request.workflowId, organizationId);
|
||||||
const steps = await listWorkflowSteps(request.workflowId, organizationId);
|
const steps = await listWorkflowSteps(request.workflowId, organizationId);
|
||||||
const currentStep = steps.find((step) => step.stepNumber === request.currentStep);
|
const currentStep = steps.find((step) => step.stepNumber === request.currentStep);
|
||||||
|
|
||||||
@@ -1181,6 +1275,30 @@ export async function approveApproval(
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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;
|
return updatedRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1196,6 +1314,7 @@ export async function rejectApproval(
|
|||||||
throw new AuthError('Only pending approvals can be rejected', 400);
|
throw new AuthError('Only pending approvals can be rejected', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workflow = await assertWorkflowById(request.workflowId, organizationId);
|
||||||
const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId);
|
const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId);
|
||||||
|
|
||||||
if (!currentStep) {
|
if (!currentStep) {
|
||||||
@@ -1255,6 +1374,17 @@ export async function rejectApproval(
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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;
|
return updatedRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1270,6 +1400,7 @@ export async function returnApproval(
|
|||||||
throw new AuthError('Only pending approvals can be returned', 400);
|
throw new AuthError('Only pending approvals can be returned', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workflow = await assertWorkflowById(request.workflowId, organizationId);
|
||||||
const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId);
|
const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId);
|
||||||
|
|
||||||
if (!currentStep) {
|
if (!currentStep) {
|
||||||
@@ -1329,6 +1460,17 @@ export async function returnApproval(
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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;
|
return updatedRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1343,6 +1485,9 @@ export async function cancelApproval(
|
|||||||
throw new AuthError('Only pending approvals can be cancelled', 400);
|
throw new AuthError('Only pending approvals can be cancelled', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workflow = await assertWorkflowById(request.workflowId, organizationId);
|
||||||
|
const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId);
|
||||||
|
|
||||||
if (request.requestedBy !== userId) {
|
if (request.requestedBy !== userId) {
|
||||||
await assertActorCanHandleStep(organizationId, userId, 'sales_manager');
|
await assertActorCanHandleStep(organizationId, userId, 'sales_manager');
|
||||||
}
|
}
|
||||||
@@ -1398,5 +1543,17 @@ export async function cancelApproval(
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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;
|
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';
|
'use client';
|
||||||
|
|
||||||
import { Icons } from '@/components/icons';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { NotificationCard, type NotificationAction } from '@/components/ui/notification-card';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { NotificationCard } from '@/components/ui/notification-card';
|
import {
|
||||||
import { useNotificationStore } from '../utils/store';
|
markAllNotificationsReadMutation,
|
||||||
import { useRouter } from 'next/navigation';
|
markNotificationReadMutation
|
||||||
|
} from '../api/mutations';
|
||||||
|
import { notificationsQueryOptions } from '../api/queries';
|
||||||
|
|
||||||
const MAX_VISIBLE = 5;
|
const MAX_VISIBLE = 5;
|
||||||
|
|
||||||
const actionRoutes: Record<string, string> = {
|
function buildNotificationActions(linkUrl: string | null): NotificationAction[] {
|
||||||
view: '/dashboard/workspaces',
|
if (!linkUrl) {
|
||||||
'view-product': '/dashboard/product',
|
return [];
|
||||||
billing: '/dashboard/billing',
|
}
|
||||||
open: '/dashboard/kanban',
|
|
||||||
'open-chat': '/dashboard/chat'
|
return [{ id: `open:${linkUrl}`, label: 'Open', type: 'redirect', style: 'primary' }];
|
||||||
};
|
}
|
||||||
|
|
||||||
export function NotificationCenter() {
|
export function NotificationCenter() {
|
||||||
const { notifications, markAsRead, markAllAsRead, unreadCount } = useNotificationStore();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const count = unreadCount();
|
const notificationsQuery = useQuery(notificationsQueryOptions({ page: 1, limit: MAX_VISIBLE }));
|
||||||
const visibleNotifications = notifications.slice(0, MAX_VISIBLE);
|
const markRead = useMutation(markNotificationReadMutation);
|
||||||
|
const markAllRead = useMutation(markAllNotificationsReadMutation);
|
||||||
|
|
||||||
|
const notifications = notificationsQuery.data?.items ?? [];
|
||||||
|
const unreadCount = notificationsQuery.data?.unreadCount ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant='ghost' size='icon' className='relative h-8 w-8'>
|
<Button variant='ghost' size='icon' className='relative h-8 w-8'>
|
||||||
<Icons.notification className='h-4 w-4' />
|
<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'>
|
<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>
|
||||||
)}
|
)}
|
||||||
<span className='sr-only'>Notifications</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' />
|
<Icons.chevronRight className='text-muted-foreground h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5' />
|
||||||
</Link>
|
</Link>
|
||||||
<div className='flex items-center gap-2'>
|
<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'>
|
<span className='bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs'>
|
||||||
{count} new
|
{unreadCount} new
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{count > 0 && (
|
{unreadCount > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
size='sm'
|
size='sm'
|
||||||
className='text-muted-foreground h-auto px-2 py-1 text-xs'
|
className='text-muted-foreground h-auto px-2 py-1 text-xs'
|
||||||
onClick={markAllAsRead}
|
disabled={markAllRead.isPending}
|
||||||
|
onClick={() => markAllRead.mutate()}
|
||||||
>
|
>
|
||||||
Mark all as read
|
Mark all as read
|
||||||
</Button>
|
</Button>
|
||||||
@@ -65,14 +74,25 @@ export function NotificationCenter() {
|
|||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<ScrollArea className='h-[400px]'>
|
<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'>
|
<div className='flex flex-col items-center justify-center py-12'>
|
||||||
<Icons.notification className='text-muted-foreground/40 mb-2 h-8 w-8' />
|
<Icons.notification className='text-muted-foreground/40 mb-2 h-8 w-8' />
|
||||||
<p className='text-muted-foreground text-sm'>No notifications yet</p>
|
<p className='text-muted-foreground text-sm'>No notifications yet</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='flex flex-col gap-1 p-2'>
|
<div className='flex flex-col gap-1 p-2'>
|
||||||
{visibleNotifications.map((notification) => (
|
{notifications.map((notification) => (
|
||||||
<NotificationCard
|
<NotificationCard
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
id={notification.id}
|
id={notification.id}
|
||||||
@@ -80,13 +100,12 @@ export function NotificationCenter() {
|
|||||||
body={notification.body}
|
body={notification.body}
|
||||||
status={notification.status}
|
status={notification.status}
|
||||||
createdAt={notification.createdAt}
|
createdAt={notification.createdAt}
|
||||||
actions={notification.actions}
|
actions={buildNotificationActions(notification.linkUrl)}
|
||||||
onMarkAsRead={markAsRead}
|
onMarkAsRead={(id) => markRead.mutate(id)}
|
||||||
onAction={(notifId, actionId) => {
|
onAction={async (notificationId, actionId) => {
|
||||||
const route = actionRoutes[actionId];
|
if (actionId.startsWith('open:')) {
|
||||||
if (route) {
|
await markRead.mutateAsync(notificationId);
|
||||||
markAsRead(notifId);
|
router.push(actionId.replace('open:', ''));
|
||||||
router.push(route);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,30 +1,79 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { Icons } from '@/components/icons';
|
import { Icons } from '@/components/icons';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { useRouter } from 'next/navigation';
|
import {
|
||||||
import { useNotificationStore } from '../utils/store';
|
archiveNotificationMutation,
|
||||||
|
markAllNotificationsReadMutation,
|
||||||
|
markNotificationReadMutation
|
||||||
|
} from '../api/mutations';
|
||||||
|
import { notificationsQueryOptions } from '../api/queries';
|
||||||
|
import type { NotificationListItem } from '../api/types';
|
||||||
|
|
||||||
const actionRoutes: Record<string, string> = {
|
function buildNotificationActions(notification: NotificationListItem): NotificationAction[] {
|
||||||
view: '/dashboard/workspaces',
|
const actions: NotificationAction[] = [];
|
||||||
'view-product': '/dashboard/product',
|
|
||||||
billing: '/dashboard/billing',
|
if (notification.linkUrl) {
|
||||||
open: '/dashboard/kanban',
|
actions.push({
|
||||||
'open-chat': '/dashboard/chat'
|
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() {
|
export default function NotificationsPage() {
|
||||||
const { notifications, markAsRead, markAllAsRead, unreadCount } = useNotificationStore();
|
|
||||||
const router = useRouter();
|
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 notifications = notificationsQuery.data?.items ?? [];
|
||||||
const readNotifications = notifications.filter((n) => n.status === 'read');
|
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) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col items-center justify-center py-16'>
|
<div className='flex flex-col items-center justify-center py-16'>
|
||||||
@@ -44,28 +93,37 @@ export default function NotificationsPage() {
|
|||||||
body={notification.body}
|
body={notification.body}
|
||||||
status={notification.status}
|
status={notification.status}
|
||||||
createdAt={notification.createdAt}
|
createdAt={notification.createdAt}
|
||||||
actions={notification.actions}
|
actions={buildNotificationActions(notification)}
|
||||||
onMarkAsRead={markAsRead}
|
onMarkAsRead={(id) => markRead.mutate(id)}
|
||||||
onAction={(notifId, actionId) => {
|
onAction={async (notificationId, actionId) => {
|
||||||
const route = actionRoutes[actionId];
|
if (actionId.startsWith('open:')) {
|
||||||
if (route) {
|
await markRead.mutateAsync(notificationId);
|
||||||
markAsRead(notifId);
|
router.push(actionId.replace('open:', ''));
|
||||||
router.push(route);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionId.startsWith('archive:')) {
|
||||||
|
await archive.mutateAsync(notificationId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Notifications'
|
pageTitle='Notifications'
|
||||||
pageDescription='View and manage all your notifications.'
|
pageDescription='View and manage all your notifications.'
|
||||||
pageHeaderAction={
|
pageHeaderAction={
|
||||||
count > 0 ? (
|
unreadCount > 0 ? (
|
||||||
<Button variant='outline' size='sm' onClick={markAllAsRead}>
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={markAllRead.isPending}
|
||||||
|
onClick={() => markAllRead.mutate()}
|
||||||
|
>
|
||||||
Mark all as read
|
Mark all as read
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -76,6 +134,7 @@ export default function NotificationsPage() {
|
|||||||
<TabsTrigger value='all'>All ({notifications.length})</TabsTrigger>
|
<TabsTrigger value='all'>All ({notifications.length})</TabsTrigger>
|
||||||
<TabsTrigger value='unread'>Unread ({unreadNotifications.length})</TabsTrigger>
|
<TabsTrigger value='unread'>Unread ({unreadNotifications.length})</TabsTrigger>
|
||||||
<TabsTrigger value='read'>Read ({readNotifications.length})</TabsTrigger>
|
<TabsTrigger value='read'>Read ({readNotifications.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value='archived'>Archived ({archivedNotifications.length})</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value='all' className='mt-4'>
|
<TabsContent value='all' className='mt-4'>
|
||||||
{renderList(notifications)}
|
{renderList(notifications)}
|
||||||
@@ -86,6 +145,9 @@ export default function NotificationsPage() {
|
|||||||
<TabsContent value='read' className='mt-4'>
|
<TabsContent value='read' className='mt-4'>
|
||||||
{renderList(readNotifications)}
|
{renderList(readNotifications)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value='archived' className='mt-4'>
|
||||||
|
{renderList(archivedNotifications)}
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</PageContainer>
|
</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',
|
productsWrite: 'products:write',
|
||||||
organizationManage: 'organization:manage',
|
organizationManage: 'organization:manage',
|
||||||
usersManage: 'users:manage',
|
usersManage: 'users:manage',
|
||||||
|
notificationsRead: 'notifications.read',
|
||||||
|
notificationsUpdate: 'notifications.update',
|
||||||
|
notificationsAdmin: 'notifications.admin',
|
||||||
reportRead: 'report:read',
|
reportRead: 'report:read',
|
||||||
crmLeadRead: 'crm.lead.read',
|
crmLeadRead: 'crm.lead.read',
|
||||||
crmLeadCreate: 'crm.lead.create',
|
crmLeadCreate: 'crm.lead.create',
|
||||||
@@ -582,6 +585,15 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
|||||||
{ key: PERMISSIONS.crmDashboardExport, label: 'Export dashboard' }
|
{ 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',
|
key: 'report',
|
||||||
label: 'Reports',
|
label: 'Reports',
|
||||||
@@ -658,11 +670,14 @@ export function getMembershipBasePermissions(role: MembershipRole): Permission[]
|
|||||||
PERMISSIONS.productsRead,
|
PERMISSIONS.productsRead,
|
||||||
PERMISSIONS.productsWrite,
|
PERMISSIONS.productsWrite,
|
||||||
PERMISSIONS.organizationManage,
|
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 {
|
export function getRoleProfileDefinition(roleCode: string): CrmRoleProfileDefinition | null {
|
||||||
|
|||||||
Reference in New Issue
Block a user