diff --git a/docs/implementation/task-f4-notification-framework-foundation.md b/docs/implementation/task-f4-notification-framework-foundation.md new file mode 100644 index 0000000..3304690 --- /dev/null +++ b/docs/implementation/task-f4-notification-framework-foundation.md @@ -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 diff --git a/drizzle/0004_unusual_hairball.sql b/drizzle/0004_unusual_hairball.sql new file mode 100644 index 0000000..89dbf68 --- /dev/null +++ b/drizzle/0004_unusual_hairball.sql @@ -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"); diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..4c2596c --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,4909 @@ +{ + "id": "d0a45ce4-f9df-4afd-9020-c23d19e9d39f", + "prevId": "76a80d29-837a-4056-874e-600b1e297b11", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_notification_deliveries": { + "name": "app_notification_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_id": { + "name": "notification_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_address": { + "name": "recipient_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_notification_events": { + "name": "app_notification_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_notification_events_org_dedupe_idx": { + "name": "app_notification_events_org_dedupe_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_notification_templates": { + "name": "app_notification_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_template": { + "name": "title_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_template": { + "name": "body_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link_template": { + "name": "link_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "app_notification_templates_org_event_channel_idx": { + "name": "app_notification_templates_org_event_channel_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_notifications": { + "name": "app_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link_url": { + "name": "link_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unread'" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_notifications_recipient_event_idx": { + "name": "app_notifications_recipient_event_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_actions": { + "name": "crm_approval_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approval_request_id": { + "name": "approval_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acted_by": { + "name": "acted_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acted_at": { + "name": "acted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_matrices": { + "name": "crm_approval_matrices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "min_amount": { + "name": "min_amount", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_amount": { + "name": "max_amount", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_requests": { + "name": "crm_approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_steps": { + "name": "crm_approval_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role_code": { + "name": "role_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approval_mode": { + "name": "approval_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sequential'" + }, + "is_required": { + "name": "is_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_steps_workflow_step_idx": { + "name": "crm_approval_steps_workflow_step_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_workflows": { + "name": "crm_approval_workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_workflows_org_code_idx": { + "name": "crm_approval_workflows_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_contact_shares": { + "name": "crm_contact_shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_to_user_id": { + "name": "shared_to_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_by_user_id": { + "name": "shared_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_contact_shares_org_contact_shared_user_idx": { + "name": "crm_contact_shares_org_contact_shared_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shared_to_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customer_contacts": { + "name": "crm_customer_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "department": { + "name": "department", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mobile": { + "name": "mobile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customer_owner_history": { + "name": "crm_customer_owner_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_owner_user_id": { + "name": "old_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_owner_user_id": { + "name": "new_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changed_by": { + "name": "changed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changed_at": { + "name": "changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customers": { + "name": "crm_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "abbr": { + "name": "abbr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_id": { + "name": "tax_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_status": { + "name": "customer_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "province": { + "name": "province", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sub_district": { + "name": "sub_district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fax": { + "name": "fax", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_group": { + "name": "customer_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_sub_group": { + "name": "customer_sub_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_assigned_at": { + "name": "owner_assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_assigned_by": { + "name": "owner_assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_customers_org_code_idx": { + "name": "crm_customers_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_artifacts": { + "name": "crm_document_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artifact_type": { + "name": "artifact_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_version_id": { + "name": "template_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "generated_by": { + "name": "generated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voided_at": { + "name": "voided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "voided_by": { + "name": "voided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "void_reason": { + "name": "void_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_mappings": { + "name": "crm_document_template_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_version_id": { + "name": "template_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "placeholder_key": { + "name": "placeholder_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_type": { + "name": "data_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sheet_name": { + "name": "sheet_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_value": { + "name": "default_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format_mask": { + "name": "format_mask", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_document_template_mappings_version_placeholder_idx": { + "name": "crm_document_template_mappings_version_placeholder_idx", + "columns": [ + { + "expression": "template_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "placeholder_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_table_columns": { + "name": "crm_document_template_table_columns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mapping_id": { + "name": "mapping_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "column_name": { + "name": "column_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_field": { + "name": "source_field", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "column_letter": { + "name": "column_letter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "format_mask": { + "name": "format_mask", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_document_template_table_columns_mapping_name_idx": { + "name": "crm_document_template_table_columns_mapping_name_idx", + "columns": [ + { + "expression": "mapping_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "column_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_versions": { + "name": "crm_document_template_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_json": { + "name": "schema_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview_image_url": { + "name": "preview_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_document_template_versions_template_version_idx": { + "name": "crm_document_template_versions_template_version_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_templates": { + "name": "crm_document_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_name": { + "name": "template_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_document_templates_org_doc_product_file_name_idx": { + "name": "crm_document_templates_org_doc_product_file_name_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_leads": { + "name": "crm_leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_value": { + "name": "estimated_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "awareness_id": { + "name": "awareness_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new_job'" + }, + "followup_status": { + "name": "followup_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_reason": { + "name": "lost_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "owner_marketing_user_id": { + "name": "owner_marketing_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_sales_owner_id": { + "name": "assigned_sales_owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignment_remark": { + "name": "assignment_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_leads_org_code_idx": { + "name": "crm_leads_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunities": { + "name": "crm_opportunities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requirement": { + "name": "requirement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome_status": { + "name": "outcome_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_value": { + "name": "estimated_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "chance_percent": { + "name": "chance_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expected_close_date": { + "name": "expected_close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "competitor": { + "name": "competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_hot_project": { + "name": "is_hot_project", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "pipeline_stage": { + "name": "pipeline_stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'lead'" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_won_at": { + "name": "closed_won_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_lost_at": { + "name": "closed_lost_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_by_user_id": { + "name": "closed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "po_number": { + "name": "po_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "po_date": { + "name": "po_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "po_amount": { + "name": "po_amount", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "po_currency": { + "name": "po_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_reason": { + "name": "lost_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_detail": { + "name": "lost_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_competitor": { + "name": "lost_competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_remark": { + "name": "lost_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "no_quotation_reason": { + "name": "no_quotation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_to_user_id": { + "name": "assigned_to_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignment_remark": { + "name": "assignment_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_opportunities_org_code_idx": { + "name": "crm_opportunities_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunity_attachments": { + "name": "crm_opportunity_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunity_customers": { + "name": "crm_opportunity_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunity_followups": { + "name": "crm_opportunity_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_attachments": { + "name": "crm_quotation_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_customers": { + "name": "crm_quotation_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_followups": { + "name": "crm_quotation_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_items": { + "name": "crm_quotation_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_number": { + "name": "item_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_price": { + "name": "unit_price", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount": { + "name": "discount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_rate": { + "name": "tax_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_price": { + "name": "total_price", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_topic_items": { + "name": "crm_quotation_topic_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_topics": { + "name": "crm_quotation_topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_type": { + "name": "topic_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotations": { + "name": "crm_quotations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quotation_date": { + "name": "quotation_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quotation_type": { + "name": "quotation_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attention": { + "name": "attention", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_quotation_id": { + "name": "parent_quotation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision_remark": { + "name": "revision_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exchange_rate": { + "name": "exchange_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "subtotal": { + "name": "subtotal", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount": { + "name": "discount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_rate": { + "name": "tax_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tax_amount": { + "name": "tax_amount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_amount": { + "name": "total_amount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "chance_percent": { + "name": "chance_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_hot_project": { + "name": "is_hot_project", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "competitor": { + "name": "competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "salesman_id": { + "name": "salesman_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_sent": { + "name": "is_sent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_via": { + "name": "sent_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_artifact_id": { + "name": "approved_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_pdf_url": { + "name": "approved_pdf_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_snapshot": { + "name": "approved_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "approved_template_version_id": { + "name": "approved_template_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_quotations_org_code_idx": { + "name": "crm_quotations_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_report_definitions": { + "name": "crm_report_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "crm_report_definitions_org_code_idx": { + "name": "crm_report_definitions_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_role_profiles": { + "name": "crm_role_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "branch_scope_mode": { + "name": "branch_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'assigned'" + }, + "product_scope_mode": { + "name": "product_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'assigned'" + }, + "ownership_scope": { + "name": "ownership_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'own'" + }, + "approval_authority": { + "name": "approval_authority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_role_profiles_org_code_idx": { + "name": "crm_role_profiles_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_user_role_assignments": { + "name": "crm_user_role_assignments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_profile_id": { + "name": "role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_scope_mode": { + "name": "branch_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + }, + "branch_scope_ids": { + "name": "branch_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "product_type_scope_mode": { + "name": "product_type_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + }, + "product_type_scope_ids": { + "name": "product_type_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_user_role_assignments_org_user_role_idx": { + "name": "crm_user_role_assignments_org_user_role_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_sequences": { + "name": "document_sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_number": { + "name": "current_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "padding_length": { + "name": "padding_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_sequences_org_doc_period_branch_idx": { + "name": "document_sequences_org_doc_period_branch_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "business_role": { + "name": "business_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sales_support'" + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "branch_scope_ids": { + "name": "branch_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "product_type_scope_ids": { + "name": "product_type_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ms_options": { + "name": "ms_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ms_options_org_category_code_idx": { + "name": "ms_options_org_category_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "products_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tr_audit_logs": { + "name": "tr_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_data": { + "name": "before_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_data": { + "name": "after_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "system_role": { + "name": "system_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3c37a29..532a84e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1782691200000, "tag": "0003_workflow_builder_foundation", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1782455991734, + "tag": "0004_unusual_hairball", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/plans/task-f.4.md b/plans/task-f.4.md new file mode 100644 index 0000000..1fe73ad --- /dev/null +++ b/plans/task-f.4.md @@ -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; + 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 +``` diff --git a/src/app/api/notifications/[id]/archive/route.ts b/src/app/api/notifications/[id]/archive/route.ts new file mode 100644 index 0000000..33a2ae3 --- /dev/null +++ b/src/app/api/notifications/[id]/archive/route.ts @@ -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 }); + } +} diff --git a/src/app/api/notifications/[id]/read/route.ts b/src/app/api/notifications/[id]/read/route.ts new file mode 100644 index 0000000..4f20072 --- /dev/null +++ b/src/app/api/notifications/[id]/read/route.ts @@ -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 }); + } +} diff --git a/src/app/api/notifications/read-all/route.ts b/src/app/api/notifications/read-all/route.ts new file mode 100644 index 0000000..c4547e6 --- /dev/null +++ b/src/app/api/notifications/read-all/route.ts @@ -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 }); + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..72f4a13 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -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 }); + } +} diff --git a/src/app/api/notifications/unread-count/route.ts b/src/app/api/notifications/unread-count/route.ts new file mode 100644 index 0000000..659e2d9 --- /dev/null +++ b/src/app/api/notifications/unread-count/route.ts @@ -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 } + ); + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 87baa16..65a2ebf 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -188,6 +188,99 @@ export const trAuditLogs = pgTable('tr_audit_logs', { createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() }); +export const appNotificationEvents = pgTable( + 'app_notification_events', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + eventType: text('event_type').notNull(), + entityType: text('entity_type').notNull(), + entityId: text('entity_id').notNull(), + actorUserId: text('actor_user_id'), + payload: jsonb('payload'), + dedupeKey: text('dedupe_key'), + status: text('status').default('pending').notNull(), + lastError: text('last_error'), + publishedAt: timestamp('published_at', { withTimezone: true }).defaultNow().notNull(), + processedAt: timestamp('processed_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + organizationDedupeIdx: uniqueIndex('app_notification_events_org_dedupe_idx').on( + table.organizationId, + table.dedupeKey + ) + }) +); + +export const appNotifications = pgTable( + 'app_notifications', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + recipientUserId: text('recipient_user_id').notNull(), + eventId: text('event_id').notNull(), + title: text('title').notNull(), + body: text('body').notNull(), + linkUrl: text('link_url'), + severity: text('severity').default('info').notNull(), + status: text('status').default('unread').notNull(), + readAt: timestamp('read_at', { withTimezone: true }), + archivedAt: timestamp('archived_at', { withTimezone: true }), + metadata: jsonb('metadata'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + recipientEventIdx: uniqueIndex('app_notifications_recipient_event_idx').on( + table.recipientUserId, + table.eventId + ) + }) +); + +export const appNotificationTemplates = pgTable( + 'app_notification_templates', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + eventType: text('event_type').notNull(), + channel: text('channel').notNull(), + titleTemplate: text('title_template').notNull(), + bodyTemplate: text('body_template').notNull(), + linkTemplate: text('link_template'), + isActive: boolean('is_active').default(true).notNull(), + metadata: jsonb('metadata'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + createdBy: text('created_by').notNull(), + updatedBy: text('updated_by').notNull() + }, + (table) => ({ + organizationEventChannelIdx: uniqueIndex('app_notification_templates_org_event_channel_idx').on( + table.organizationId, + table.eventType, + table.channel + ) + }) +); + +export const appNotificationDeliveries = pgTable('app_notification_deliveries', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + notificationId: text('notification_id').notNull(), + channel: text('channel').notNull(), + recipientAddress: text('recipient_address'), + status: text('status').default('pending').notNull(), + attemptCount: integer('attempt_count').default(0).notNull(), + lastError: text('last_error'), + sentAt: timestamp('sent_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() +}); + export const crmCustomers = pgTable( 'crm_customers', { diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index 25d93c3..0cec9cd 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -1439,6 +1439,93 @@ async function upsertReportDefinitionsForOrganization( } } +async function upsertNotificationTemplatesForOrganization( + sql: SqlClient, + organization: { id: string; createdBy: string } +) { + const templates = [ + { + eventType: 'approval.requested', + titleTemplate: 'Approval Required: {{quotationCode}}', + bodyTemplate: + '{{actorName}} submitted {{quotationCode}} for approval in {{workflowName}}.', + linkTemplate: '{{entityLink}}' + }, + { + eventType: 'approval.step.approved', + titleTemplate: 'Approval Updated: {{quotationCode}}', + bodyTemplate: '{{actorName}} approved {{quotationCode}}. Next step: {{currentStepRoleName}}.', + linkTemplate: '{{entityLink}}' + }, + { + eventType: 'approval.step.rejected', + titleTemplate: 'Approval Rejected: {{quotationCode}}', + bodyTemplate: '{{actorName}} rejected {{quotationCode}}. Reason: {{remark}}', + linkTemplate: '{{entityLink}}' + }, + { + eventType: 'approval.completed', + titleTemplate: 'Approval Completed: {{quotationCode}}', + bodyTemplate: '{{quotationCode}} has been fully approved in {{workflowName}}.', + linkTemplate: '{{entityLink}}' + }, + { + eventType: 'approval.cancelled', + titleTemplate: 'Approval Cancelled: {{quotationCode}}', + bodyTemplate: '{{actorName}} cancelled the approval request for {{quotationCode}}.', + linkTemplate: '{{entityLink}}' + }, + { + eventType: 'approval.returned', + titleTemplate: 'Approval Returned: {{quotationCode}}', + bodyTemplate: '{{actorName}} returned {{quotationCode}} for revision. Reason: {{remark}}', + linkTemplate: '{{entityLink}}' + } + ]; + + for (const template of templates) { + await sql` + insert into app_notification_templates ( + id, + organization_id, + event_type, + channel, + title_template, + body_template, + link_template, + is_active, + metadata, + deleted_at, + created_by, + updated_by + ) values ( + ${crypto.randomUUID()}, + ${organization.id}, + ${template.eventType}, + ${'in_app'}, + ${template.titleTemplate}, + ${template.bodyTemplate}, + ${template.linkTemplate}, + ${true}, + ${JSON.stringify({ seededBy: 'foundation.seed' })}, + ${null}, + ${organization.createdBy}, + ${organization.createdBy} + ) + on conflict (organization_id, event_type, channel) do update + set + title_template = excluded.title_template, + body_template = excluded.body_template, + link_template = excluded.link_template, + is_active = excluded.is_active, + metadata = excluded.metadata, + deleted_at = excluded.deleted_at, + updated_by = excluded.updated_by, + updated_at = now() + `; + } +} + async function main() { loadLocalEnv(); @@ -1464,9 +1551,10 @@ async function main() { await upsertApprovalWorkflowsForOrganization(sql, organization.id); await upsertApprovalMatricesForOrganization(sql, organization); await upsertDocumentTemplatesForOrganization(sql, organization); + await upsertNotificationTemplatesForOrganization(sql, organization); await upsertReportDefinitionsForOrganization(sql, organization); - console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`); - } + console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`); + } } finally { await sql.end(); } diff --git a/src/features/foundation/approval/server/service.ts b/src/features/foundation/approval/server/service.ts index bf1a8fd..f1968e1 100644 --- a/src/features/foundation/approval/server/service.ts +++ b/src/features/foundation/approval/server/service.ts @@ -15,6 +15,7 @@ import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access'; import { type SystemRole } from '@/lib/auth/rbac'; import { AuthError } from '@/lib/auth/session'; import { auditAction } from '@/features/foundation/audit-log/service'; +import { publishNotificationEvent } from '@/features/foundation/notifications/server/event-service'; import { type CrmSecurityContext, canAccessScopedCrmRecord @@ -124,6 +125,87 @@ function mapActionRecord(row: typeof crmApprovalActions.$inferSelect): ApprovalA }; } +async function buildApprovalNotificationPayload(input: { + organizationId: string; + request: typeof crmApprovalRequests.$inferSelect; + workflowName: string; + actorUserId: string; + remark?: string | null; + currentStep?: { roleCode: string; roleName: string } | null; +}) { + const [actorRows, quotationRows] = await Promise.all([ + db.select({ name: users.name }).from(users).where(eq(users.id, input.actorUserId)).limit(1), + input.request.entityType === 'quotation' + ? db + .select({ id: crmQuotations.id, code: crmQuotations.code }) + .from(crmQuotations) + .where( + and( + eq(crmQuotations.id, input.request.entityId), + eq(crmQuotations.organizationId, input.organizationId), + isNull(crmQuotations.deletedAt) + ) + ) + .limit(1) + : Promise.resolve([]) + ]); + + const quotation = quotationRows[0] ?? null; + + return { + approvalRequestId: input.request.id, + requestedByUserId: input.request.requestedBy, + workflowId: input.request.workflowId, + workflowName: input.workflowName, + entityType: input.request.entityType, + entityId: input.request.entityId, + quotationId: quotation?.id ?? input.request.entityId, + quotationCode: quotation?.code ?? input.request.entityId, + actorName: actorRows[0]?.name ?? '-', + remark: input.remark?.trim() || '-', + currentStepRoleCode: input.currentStep?.roleCode ?? null, + currentStepRoleName: input.currentStep?.roleName ?? null, + entityLink: + quotation?.id + ? `/dashboard/crm/quotations/${quotation.id}` + : `/dashboard/crm/approvals/${input.request.id}` + }; +} + +async function publishApprovalNotificationSafely(input: { + organizationId: string; + actorUserId: string; + eventType: string; + request: typeof crmApprovalRequests.$inferSelect; + workflowName: string; + remark?: string | null; + currentStep?: { roleCode: string; roleName: string } | null; + dedupeKey: string; +}) { + try { + const payload = await buildApprovalNotificationPayload({ + organizationId: input.organizationId, + request: input.request, + workflowName: input.workflowName, + actorUserId: input.actorUserId, + remark: input.remark, + currentStep: input.currentStep ?? null + }); + + await publishNotificationEvent({ + organizationId: input.organizationId, + eventType: input.eventType, + entityType: input.request.entityType, + entityId: input.request.entityId, + actorUserId: input.actorUserId, + payload, + dedupeKey: input.dedupeKey + }); + } catch { + // Notification failures are recorded by the notification foundation and must not block approval actions. + } +} + function parseSort(sort?: string) { if (!sort) { return desc(crmApprovalRequests.updatedAt); @@ -1083,9 +1165,9 @@ export async function submitForApproval( APPROVAL_REQUEST_STATUSES.pending ); - await Promise.all([ - auditAction({ - organizationId, +await Promise.all([ + auditAction({ + organizationId, userId, entityType: 'crm_approval_request', entityId: createdRequest.id, @@ -1099,10 +1181,21 @@ export async function submitForApproval( entityId: createdAction.id, action: APPROVAL_ACTIONS.submit, afterData: createdAction - }) - ]); + }) +]); - return createdRequest; +await publishApprovalNotificationSafely({ + organizationId, + actorUserId: userId, + eventType: 'approval.requested', + request: createdRequest, + workflowName: workflow.name, + remark: payload.remark ?? null, + currentStep: steps[0] ? { roleCode: steps[0].roleCode, roleName: steps[0].roleName } : null, + dedupeKey: `approval.requested:${createdRequest.id}` +}); + +return createdRequest; } export async function approveApproval( @@ -1113,11 +1206,12 @@ export async function approveApproval( ) { const request = await assertApprovalRequest(approvalRequestId, organizationId); - if (request.status !== APPROVAL_REQUEST_STATUSES.pending) { - throw new AuthError('Only pending approvals can be approved', 400); - } +if (request.status !== APPROVAL_REQUEST_STATUSES.pending) { + throw new AuthError('Only pending approvals can be approved', 400); +} - const steps = await listWorkflowSteps(request.workflowId, organizationId); +const workflow = await assertWorkflowById(request.workflowId, organizationId); +const steps = await listWorkflowSteps(request.workflowId, organizationId); const currentStep = steps.find((step) => step.stepNumber === request.currentStep); if (!currentStep) { @@ -1161,8 +1255,8 @@ export async function approveApproval( ); } - await Promise.all([ - auditAction({ +await Promise.all([ + auditAction({ organizationId, userId, entityType: 'crm_approval_request', @@ -1178,10 +1272,34 @@ export async function approveApproval( entityId: createdAction.id, action: APPROVAL_ACTIONS.approve, afterData: createdAction - }) - ]); + }) +]); - return updatedRequest; +if (nextStep) { + await publishApprovalNotificationSafely({ + organizationId, + actorUserId: userId, + eventType: 'approval.step.approved', + request: updatedRequest, + workflowName: workflow.name, + remark: remark ?? null, + currentStep: { roleCode: nextStep.roleCode, roleName: nextStep.roleName }, + dedupeKey: `approval.step.approved:${updatedRequest.id}:${createdAction.id}` + }); +} else { + await publishApprovalNotificationSafely({ + organizationId, + actorUserId: userId, + eventType: 'approval.completed', + request: updatedRequest, + workflowName: workflow.name, + remark: remark ?? null, + currentStep: null, + dedupeKey: `approval.completed:${updatedRequest.id}:${createdAction.id}` + }); +} + +return updatedRequest; } export async function rejectApproval( @@ -1192,11 +1310,12 @@ export async function rejectApproval( ) { const request = await assertApprovalRequest(approvalRequestId, organizationId); - if (request.status !== APPROVAL_REQUEST_STATUSES.pending) { - throw new AuthError('Only pending approvals can be rejected', 400); - } +if (request.status !== APPROVAL_REQUEST_STATUSES.pending) { + throw new AuthError('Only pending approvals can be rejected', 400); +} - const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId); +const workflow = await assertWorkflowById(request.workflowId, organizationId); +const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId); if (!currentStep) { throw new AuthError('Current approval step not found', 404); @@ -1235,8 +1354,8 @@ export async function rejectApproval( APPROVAL_REQUEST_STATUSES.rejected ); - await Promise.all([ - auditAction({ +await Promise.all([ + auditAction({ organizationId, userId, entityType: 'crm_approval_request', @@ -1252,10 +1371,21 @@ export async function rejectApproval( entityId: createdAction.id, action: APPROVAL_ACTIONS.reject, afterData: createdAction - }) - ]); + }) +]); - return updatedRequest; +await publishApprovalNotificationSafely({ + organizationId, + actorUserId: userId, + eventType: 'approval.step.rejected', + request: updatedRequest, + workflowName: workflow.name, + remark: remark ?? null, + currentStep: { roleCode: currentStep.roleCode, roleName: currentStep.roleName }, + dedupeKey: `approval.step.rejected:${updatedRequest.id}:${createdAction.id}` +}); + +return updatedRequest; } export async function returnApproval( @@ -1266,11 +1396,12 @@ export async function returnApproval( ) { const request = await assertApprovalRequest(approvalRequestId, organizationId); - if (request.status !== APPROVAL_REQUEST_STATUSES.pending) { - throw new AuthError('Only pending approvals can be returned', 400); - } +if (request.status !== APPROVAL_REQUEST_STATUSES.pending) { + throw new AuthError('Only pending approvals can be returned', 400); +} - const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId); +const workflow = await assertWorkflowById(request.workflowId, organizationId); +const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId); if (!currentStep) { throw new AuthError('Current approval step not found', 404); @@ -1309,8 +1440,8 @@ export async function returnApproval( APPROVAL_REQUEST_STATUSES.returned ); - await Promise.all([ - auditAction({ +await Promise.all([ + auditAction({ organizationId, userId, entityType: 'crm_approval_request', @@ -1326,10 +1457,21 @@ export async function returnApproval( entityId: createdAction.id, action: APPROVAL_ACTIONS.return, afterData: createdAction - }) - ]); + }) +]); - return updatedRequest; +await publishApprovalNotificationSafely({ + organizationId, + actorUserId: userId, + eventType: 'approval.returned', + request: updatedRequest, + workflowName: workflow.name, + remark: remark ?? null, + currentStep: { roleCode: currentStep.roleCode, roleName: currentStep.roleName }, + dedupeKey: `approval.returned:${updatedRequest.id}:${createdAction.id}` +}); + +return updatedRequest; } export async function cancelApproval( @@ -1339,13 +1481,16 @@ export async function cancelApproval( ) { const request = await assertApprovalRequest(approvalRequestId, organizationId); - if (request.status !== APPROVAL_REQUEST_STATUSES.pending) { - throw new AuthError('Only pending approvals can be cancelled', 400); - } +if (request.status !== APPROVAL_REQUEST_STATUSES.pending) { + throw new AuthError('Only pending approvals can be cancelled', 400); +} - if (request.requestedBy !== userId) { - await assertActorCanHandleStep(organizationId, userId, 'sales_manager'); - } +const workflow = await assertWorkflowById(request.workflowId, organizationId); +const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId); + +if (request.requestedBy !== userId) { + await assertActorCanHandleStep(organizationId, userId, 'sales_manager'); +} const [createdAction] = await db .insert(crmApprovalActions) @@ -1378,8 +1523,8 @@ export async function cancelApproval( APPROVAL_REQUEST_STATUSES.returned ); - await Promise.all([ - auditAction({ +await Promise.all([ + auditAction({ organizationId, userId, entityType: 'crm_approval_request', @@ -1395,8 +1540,20 @@ export async function cancelApproval( entityId: createdAction.id, action: APPROVAL_ACTIONS.cancel, afterData: createdAction - }) - ]); + }) +]); - return updatedRequest; +await publishApprovalNotificationSafely({ + organizationId, + actorUserId: userId, + eventType: 'approval.cancelled', + request: updatedRequest, + workflowName: workflow.name, + currentStep: currentStep + ? { roleCode: currentStep.roleCode, roleName: currentStep.roleName } + : null, + dedupeKey: `approval.cancelled:${updatedRequest.id}:${createdAction.id}` +}); + +return updatedRequest; } diff --git a/src/features/foundation/notifications/server/event-service.ts b/src/features/foundation/notifications/server/event-service.ts new file mode 100644 index 0000000..d2511b1 --- /dev/null +++ b/src/features/foundation/notifications/server/event-service.ts @@ -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 = { + '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 | 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 | 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); +} diff --git a/src/features/foundation/notifications/server/inbox-service.ts b/src/features/foundation/notifications/server/inbox-service.ts new file mode 100644 index 0000000..f126a6e --- /dev/null +++ b/src/features/foundation/notifications/server/inbox-service.ts @@ -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 | 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); +} diff --git a/src/features/foundation/notifications/server/recipient-resolver.ts b/src/features/foundation/notifications/server/recipient-resolver.ts new file mode 100644 index 0000000..1319945 --- /dev/null +++ b/src/features/foundation/notifications/server/recipient-resolver.ts @@ -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; +}; + +function uniqueUserIds(values: Array) { + return [...new Set(values.filter((value): value is string => typeof value === 'string' && value.length > 0))]; +} + +function getPayloadUserIds(payload: Record, 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 { + 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; +} diff --git a/src/features/foundation/notifications/server/template-renderer.ts b/src/features/foundation/notifications/server/template-renderer.ts new file mode 100644 index 0000000..ef7d0a2 --- /dev/null +++ b/src/features/foundation/notifications/server/template-renderer.ts @@ -0,0 +1,46 @@ +import type { NotificationTemplateRecord } from '../types'; + +const TOKEN_PATTERN = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g; + +function resolveTokenValue(payload: Record, token: string): string { + const value = token.split('.').reduce((current, part) => { + if (!current || typeof current !== 'object') { + return undefined; + } + + return (current as Record)[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 | null { + if (!template) { + return null; + } + + return template.replace(TOKEN_PATTERN, (_match, token: string) => resolveTokenValue(payload, token)); +} + +export function renderNotificationTemplate( + template: Pick, + payload: Record +) { + return { + title: renderString(template.titleTemplate, payload) ?? '-', + body: renderString(template.bodyTemplate, payload) ?? '-', + linkUrl: renderString(template.linkTemplate, payload) + }; +} diff --git a/src/features/foundation/notifications/types.ts b/src/features/foundation/notifications/types.ts new file mode 100644 index 0000000..7c2dc29 --- /dev/null +++ b/src/features/foundation/notifications/types.ts @@ -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 | 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 | 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 | 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; + dedupeKey?: string; +}; + +export type NotificationListFilters = { + page?: number; + limit?: number; + status?: NotificationStatus | 'all'; +}; diff --git a/src/features/notifications/api/mutations.ts b/src/features/notifications/api/mutations.ts new file mode 100644 index 0000000..546bc5f --- /dev/null +++ b/src/features/notifications/api/mutations.ts @@ -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(); + } + } +}); diff --git a/src/features/notifications/api/queries.ts b/src/features/notifications/api/queries.ts new file mode 100644 index 0000000..fc016b2 --- /dev/null +++ b/src/features/notifications/api/queries.ts @@ -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() + }); diff --git a/src/features/notifications/api/service.ts b/src/features/notifications/api/service.ts new file mode 100644 index 0000000..6d22bf7 --- /dev/null +++ b/src/features/notifications/api/service.ts @@ -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(`/notifications${search ? `?${search}` : ''}`); +} + +export async function getUnreadNotificationCount() { + return apiClient('/notifications/unread-count'); +} + +export async function markNotificationRead(id: string) { + return apiClient(`/notifications/${id}/read`, { + method: 'POST' + }); +} + +export async function markAllNotificationsRead() { + return apiClient('/notifications/read-all', { + method: 'POST' + }); +} + +export async function archiveNotificationItem(id: string) { + return apiClient(`/notifications/${id}/archive`, { + method: 'POST' + }); +} diff --git a/src/features/notifications/api/types.ts b/src/features/notifications/api/types.ts new file mode 100644 index 0000000..5c46cb4 --- /dev/null +++ b/src/features/notifications/api/types.ts @@ -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 | 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; +} diff --git a/src/features/notifications/components/notification-center.tsx b/src/features/notifications/components/notification-center.tsx index afbf95f..278f49a 100644 --- a/src/features/notifications/components/notification-center.tsx +++ b/src/features/notifications/components/notification-center.tsx @@ -1,39 +1,47 @@ 'use client'; -import { Icons } from '@/components/icons'; +import { useMutation, useQuery } from '@tanstack/react-query'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { Icons } from '@/components/icons'; import { Button } from '@/components/ui/button'; +import { NotificationCard, type NotificationAction } from '@/components/ui/notification-card'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; -import { NotificationCard } from '@/components/ui/notification-card'; -import { useNotificationStore } from '../utils/store'; -import { useRouter } from 'next/navigation'; +import { + markAllNotificationsReadMutation, + markNotificationReadMutation +} from '../api/mutations'; +import { notificationsQueryOptions } from '../api/queries'; const MAX_VISIBLE = 5; -const actionRoutes: Record = { - view: '/dashboard/workspaces', - 'view-product': '/dashboard/product', - billing: '/dashboard/billing', - open: '/dashboard/kanban', - 'open-chat': '/dashboard/chat' -}; +function buildNotificationActions(linkUrl: string | null): NotificationAction[] { + if (!linkUrl) { + return []; + } + + return [{ id: `open:${linkUrl}`, label: 'Open', type: 'redirect', style: 'primary' }]; +} export function NotificationCenter() { - const { notifications, markAsRead, markAllAsRead, unreadCount } = useNotificationStore(); const router = useRouter(); - const count = unreadCount(); - const visibleNotifications = notifications.slice(0, MAX_VISIBLE); + const notificationsQuery = useQuery(notificationsQueryOptions({ page: 1, limit: MAX_VISIBLE })); + const markRead = useMutation(markNotificationReadMutation); + const markAllRead = useMutation(markAllNotificationsReadMutation); + + const notifications = notificationsQuery.data?.items ?? []; + const unreadCount = notificationsQuery.data?.unreadCount ?? 0; return ( @@ -65,14 +74,25 @@ export function NotificationCenter() { - {notifications.length === 0 ? ( + {notificationsQuery.isError ? ( +
+ +

Unable to load notifications

+
+ ) : notificationsQuery.isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+ ) : notifications.length === 0 ? (

No notifications yet

) : (
- {visibleNotifications.map((notification) => ( + {notifications.map((notification) => ( { - const route = actionRoutes[actionId]; - if (route) { - markAsRead(notifId); - router.push(route); + actions={buildNotificationActions(notification.linkUrl)} + onMarkAsRead={(id) => markRead.mutate(id)} + onAction={async (notificationId, actionId) => { + if (actionId.startsWith('open:')) { + await markRead.mutateAsync(notificationId); + router.push(actionId.replace('open:', '')); } }} /> diff --git a/src/features/notifications/components/notifications-page.tsx b/src/features/notifications/components/notifications-page.tsx index 6b33b1c..caa8262 100644 --- a/src/features/notifications/components/notifications-page.tsx +++ b/src/features/notifications/components/notifications-page.tsx @@ -1,30 +1,79 @@ 'use client'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; import { Icons } from '@/components/icons'; import PageContainer from '@/components/layout/page-container'; import { Button } from '@/components/ui/button'; -import { NotificationCard } from '@/components/ui/notification-card'; +import { NotificationCard, type NotificationAction } from '@/components/ui/notification-card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useRouter } from 'next/navigation'; -import { useNotificationStore } from '../utils/store'; +import { + archiveNotificationMutation, + markAllNotificationsReadMutation, + markNotificationReadMutation +} from '../api/mutations'; +import { notificationsQueryOptions } from '../api/queries'; +import type { NotificationListItem } from '../api/types'; -const actionRoutes: Record = { - view: '/dashboard/workspaces', - 'view-product': '/dashboard/product', - billing: '/dashboard/billing', - open: '/dashboard/kanban', - 'open-chat': '/dashboard/chat' -}; +function buildNotificationActions(notification: NotificationListItem): NotificationAction[] { + const actions: NotificationAction[] = []; + + if (notification.linkUrl) { + actions.push({ + id: `open:${notification.linkUrl}`, + label: 'Open', + type: 'redirect', + style: 'primary' + }); + } + + if (notification.status !== 'archived') { + actions.push({ + id: `archive:${notification.id}`, + label: 'Archive', + type: 'api_call', + style: 'default' + }); + } + + return actions; +} export default function NotificationsPage() { - const { notifications, markAsRead, markAllAsRead, unreadCount } = useNotificationStore(); const router = useRouter(); - const count = unreadCount(); + const notificationsQuery = useQuery(notificationsQueryOptions({ page: 1, limit: 50, status: 'all' })); + const markRead = useMutation(markNotificationReadMutation); + const markAllRead = useMutation(markAllNotificationsReadMutation); + const archive = useMutation(archiveNotificationMutation); - const unreadNotifications = notifications.filter((n) => n.status === 'unread'); - const readNotifications = notifications.filter((n) => n.status === 'read'); + const notifications = notificationsQuery.data?.items ?? []; + const unreadCount = notificationsQuery.data?.unreadCount ?? 0; + const unreadNotifications = notifications.filter((notification) => notification.status === 'unread'); + const readNotifications = notifications.filter((notification) => notification.status === 'read'); + const archivedNotifications = notifications.filter( + (notification) => notification.status === 'archived' + ); + + function renderList(items: NotificationListItem[]) { + if (notificationsQuery.isError) { + return ( +
+ +

Unable to load notifications

+
+ ); + } + + if (notificationsQuery.isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ ); + } - const renderList = (items: typeof notifications) => { if (items.length === 0) { return (
@@ -44,28 +93,37 @@ export default function NotificationsPage() { body={notification.body} status={notification.status} createdAt={notification.createdAt} - actions={notification.actions} - onMarkAsRead={markAsRead} - onAction={(notifId, actionId) => { - const route = actionRoutes[actionId]; - if (route) { - markAsRead(notifId); - router.push(route); + actions={buildNotificationActions(notification)} + onMarkAsRead={(id) => markRead.mutate(id)} + onAction={async (notificationId, actionId) => { + if (actionId.startsWith('open:')) { + await markRead.mutateAsync(notificationId); + router.push(actionId.replace('open:', '')); + return; + } + + if (actionId.startsWith('archive:')) { + await archive.mutateAsync(notificationId); } }} /> ))}
); - }; + } return ( 0 ? ( - ) : undefined @@ -76,6 +134,7 @@ export default function NotificationsPage() { All ({notifications.length}) Unread ({unreadNotifications.length}) Read ({readNotifications.length}) + Archived ({archivedNotifications.length}) {renderList(notifications)} @@ -86,6 +145,9 @@ export default function NotificationsPage() { {renderList(readNotifications)} + + {renderList(archivedNotifications)} + ); diff --git a/src/features/notifications/utils/store.ts b/src/features/notifications/utils/store.ts deleted file mode 100644 index c127a64..0000000 --- a/src/features/notifications/utils/store.ts +++ /dev/null @@ -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) => 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()( - // 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' } - // ) -); diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index 2540d1e..89317bf 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -28,6 +28,9 @@ export const PERMISSIONS = { productsWrite: 'products:write', organizationManage: 'organization:manage', usersManage: 'users:manage', + notificationsRead: 'notifications.read', + notificationsUpdate: 'notifications.update', + notificationsAdmin: 'notifications.admin', reportRead: 'report:read', crmLeadRead: 'crm.lead.read', crmLeadCreate: 'crm.lead.create', @@ -582,6 +585,15 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [ { key: PERMISSIONS.crmDashboardExport, label: 'Export dashboard' } ] }, + { + key: 'notifications', + label: 'Notifications', + permissions: [ + { key: PERMISSIONS.notificationsRead, label: 'Read notifications' }, + { key: PERMISSIONS.notificationsUpdate, label: 'Update notifications' }, + { key: PERMISSIONS.notificationsAdmin, label: 'Administer notifications' } + ] + }, { key: 'report', label: 'Reports', @@ -658,11 +670,14 @@ export function getMembershipBasePermissions(role: MembershipRole): Permission[] PERMISSIONS.productsRead, PERMISSIONS.productsWrite, PERMISSIONS.organizationManage, - PERMISSIONS.usersManage + PERMISSIONS.usersManage, + PERMISSIONS.notificationsRead, + PERMISSIONS.notificationsUpdate, + PERMISSIONS.notificationsAdmin ]; } - return [PERMISSIONS.productsRead]; + return [PERMISSIONS.productsRead, PERMISSIONS.notificationsRead, PERMISSIONS.notificationsUpdate]; } export function getRoleProfileDefinition(roleCode: string): CrmRoleProfileDefinition | null {