From 3fdd681dd0a18d0570deb935a446671560266186 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Sat, 27 Jun 2026 10:04:43 +0700 Subject: [PATCH] task-f.5 --- .../task-f5-approval-automation-foundation.md | 173 + drizzle/0005_furry_maelstrom.sql | 38 + drizzle/meta/0005_snapshot.json | 5157 +++++++++++++++++ drizzle/meta/_journal.json | 7 + plans/task-f.5.md | 474 ++ .../crm/approval/automation/pending/route.ts | 33 + .../api/crm/approval/automation/run/route.ts | 29 + .../escalation-policies/[id]/route.ts | 76 + .../crm/approval/escalation-policies/route.ts | 78 + .../approval/reminder-policies/[id]/route.ts | 76 + .../crm/approval/reminder-policies/route.ts | 75 + .../approval-escalation-policies/page.tsx | 40 + .../approval-reminder-policies/page.tsx | 44 + src/config/nav-config.ts | 16 + src/db/schema.ts | 48 + src/db/seeds/foundation.seed.ts | 21 + .../approval-automation/api/mutations.ts | 87 + .../approval-automation/api/queries.ts | 31 + .../approval-automation/api/service.ts | 70 + .../approval-automation/api/types.ts | 112 + .../components/escalation-policy-settings.tsx | 331 ++ .../components/reminder-policy-settings.tsx | 369 ++ .../foundation/approval-automation/schemas.ts | 28 + .../approval-automation/server/service.ts | 1025 ++++ .../foundation/approval-automation/types.ts | 95 + .../foundation/approval/server/service.ts | 8 +- src/features/foundation/approval/types.ts | 4 + .../notifications/server/event-service.ts | 39 +- src/lib/auth/rbac.ts | 24 +- 29 files changed, 8593 insertions(+), 15 deletions(-) create mode 100644 docs/implementation/task-f5-approval-automation-foundation.md create mode 100644 drizzle/0005_furry_maelstrom.sql create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 plans/task-f.5.md create mode 100644 src/app/api/crm/approval/automation/pending/route.ts create mode 100644 src/app/api/crm/approval/automation/run/route.ts create mode 100644 src/app/api/crm/approval/escalation-policies/[id]/route.ts create mode 100644 src/app/api/crm/approval/escalation-policies/route.ts create mode 100644 src/app/api/crm/approval/reminder-policies/[id]/route.ts create mode 100644 src/app/api/crm/approval/reminder-policies/route.ts create mode 100644 src/app/dashboard/crm/settings/approval-escalation-policies/page.tsx create mode 100644 src/app/dashboard/crm/settings/approval-reminder-policies/page.tsx create mode 100644 src/features/foundation/approval-automation/api/mutations.ts create mode 100644 src/features/foundation/approval-automation/api/queries.ts create mode 100644 src/features/foundation/approval-automation/api/service.ts create mode 100644 src/features/foundation/approval-automation/api/types.ts create mode 100644 src/features/foundation/approval-automation/components/escalation-policy-settings.tsx create mode 100644 src/features/foundation/approval-automation/components/reminder-policy-settings.tsx create mode 100644 src/features/foundation/approval-automation/schemas.ts create mode 100644 src/features/foundation/approval-automation/server/service.ts create mode 100644 src/features/foundation/approval-automation/types.ts diff --git a/docs/implementation/task-f5-approval-automation-foundation.md b/docs/implementation/task-f5-approval-automation-foundation.md new file mode 100644 index 0000000..12fd9a4 --- /dev/null +++ b/docs/implementation/task-f5-approval-automation-foundation.md @@ -0,0 +1,173 @@ +# Task F5: Approval Automation Foundation + +## Scope Delivered + +- Added approval automation schema for reminder policies and escalation policies. +- Added step timing state to approval requests so automation can reason about the current step safely. +- Added automation scheduler and pending queue monitor on top of the existing approval and notification foundations. +- Added admin APIs and settings pages for reminder and escalation policy management. + +## Data Model + +### Approval request timing + +`crm_approval_requests` now stores: + +- `current_step_started_at` + +This becomes the source of truth for waiting-time, reminder, escalation, and timeout calculations. + +### Reminder policies + +`crm_approval_reminder_policies` + +One policy per workflow step: + +- `workflow_id` +- `step_number` +- `sla_hours` +- `reminder_offsets_hours` +- `calendar_mode` +- `timeout_action` +- `is_active` + +This lets each step define SLA, reminder schedule, and timeout behavior without changing runtime approval ownership. + +### Escalation policies + +`crm_approval_escalation_policies` + +Supports one or more escalation rules per workflow step: + +- `workflow_id` +- `step_number` +- `trigger_after_hours` +- `target_type` +- `target_value` +- `is_active` + +Supported targets: + +- `manager` +- `requester` +- `explicit_user` +- `role` +- `permission_group` + +## Scheduler Model + +Core service: + +- `processApprovalAutomation()` + +Current behavior: + +1. Load pending approval requests. +2. Resolve the current workflow step and timing baseline from `current_step_started_at`. +3. Send reminder events for every configured reminder threshold crossed. +4. Send escalation events for every active escalation threshold crossed. +5. Execute timeout action when SLA is reached. + +The scheduler is safe to rerun because notification publication uses deterministic `dedupeKey` values per request, step, and threshold. + +## Timeout Model + +Supported timeout actions: + +- `none` +- `auto_reject` +- `auto_cancel` +- `auto_escalate` + +Notes: + +- `auto_reject` and `auto_cancel` create approval action history and synchronize entity status. +- `auto_escalate` keeps the approval pending and publishes a timeout notification event to resolved escalation targets. +- This foundation intentionally does not reassign approver ownership or introduce delegation. + +## Notification Integration + +F.5 reuses `publishNotificationEvent()` from the F.4 notification foundation. + +New event types: + +- `approval.reminder` +- `approval.escalated` +- `approval.timeout` + +Channels remain in-app only for now. + +## Queue Monitor + +Core service: + +- `getPendingApprovalQueue()` + +Current queue output includes: + +- pending count +- due today count +- overdue count +- escalated count +- per-request waiting hours +- SLA hours +- remaining hours +- due timestamp + +## APIs + +Added: + +- `GET /api/crm/approval/automation/pending` +- `POST /api/crm/approval/automation/run` +- `GET /api/crm/approval/reminder-policies` +- `POST /api/crm/approval/reminder-policies` +- `PATCH /api/crm/approval/reminder-policies/[id]` +- `DELETE /api/crm/approval/reminder-policies/[id]` +- `GET /api/crm/approval/escalation-policies` +- `POST /api/crm/approval/escalation-policies` +- `PATCH /api/crm/approval/escalation-policies/[id]` +- `DELETE /api/crm/approval/escalation-policies/[id]` + +## UI + +Added settings pages: + +- `/dashboard/crm/settings/approval-reminder-policies` +- `/dashboard/crm/settings/approval-escalation-policies` + +The reminder page also includes: + +- queue monitor cards +- a compact pending queue view +- a manual “Run Automation” action for verification and operations + +## Permissions + +Added: + +- `crm.approval.automation.read` +- `crm.approval.automation.run` +- `crm.approval.reminder.manage` +- `crm.approval.escalation.manage` + +These are grouped under the approval permission section and added to the default CRM admin configuration surface. + +## Audit Events + +F.5 records: + +- `approval_reminder_sent` +- `approval_escalated` +- `approval_timeout` +- `approval_policy_created` +- `approval_policy_updated` +- `approval_policy_deleted` + +## Current Limitations + +- calendar support is `calendar_days` only +- no holiday calendar yet +- no working-hours window yet +- no delegation or vacation replacement yet +- no background cron orchestration yet; manual run endpoint is the current operator entrypoint diff --git a/drizzle/0005_furry_maelstrom.sql b/drizzle/0005_furry_maelstrom.sql new file mode 100644 index 0000000..a154b2e --- /dev/null +++ b/drizzle/0005_furry_maelstrom.sql @@ -0,0 +1,38 @@ +CREATE TABLE "crm_approval_escalation_policies" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "workflow_id" text NOT NULL, + "step_number" integer NOT NULL, + "trigger_after_hours" integer NOT NULL, + "target_type" text NOT NULL, + "target_value" text, + "is_active" boolean DEFAULT true NOT NULL, + "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 "crm_approval_reminder_policies" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "workflow_id" text NOT NULL, + "step_number" integer NOT NULL, + "sla_hours" integer DEFAULT 24 NOT NULL, + "reminder_offsets_hours" integer[] DEFAULT '{}' NOT NULL, + "calendar_mode" text DEFAULT 'calendar_days' NOT NULL, + "timeout_action" text DEFAULT 'none' NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "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 +ALTER TABLE "crm_approval_requests" ADD COLUMN "current_step_started_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint +ALTER TABLE "crm_approval_steps" ADD COLUMN "sla_hours" integer DEFAULT 24 NOT NULL;--> statement-breakpoint +ALTER TABLE "crm_approval_steps" ADD COLUMN "calendar_mode" text DEFAULT 'calendar_days' NOT NULL;--> statement-breakpoint +ALTER TABLE "crm_approval_steps" ADD COLUMN "timeout_action" text DEFAULT 'none' NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "crm_approval_reminder_policies_workflow_step_idx" ON "crm_approval_reminder_policies" USING btree ("workflow_id","step_number"); \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..ba2f651 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,5157 @@ +{ + "id": "6b9c3902-feb2-4170-a430-092e68140a0f", + "prevId": "d0a45ce4-f9df-4afd-9020-c23d19e9d39f", + "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_escalation_policies": { + "name": "crm_approval_escalation_policies", + "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 + }, + "trigger_after_hours": { + "name": "trigger_after_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_value": { + "name": "target_value", + "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_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_reminder_policies": { + "name": "crm_approval_reminder_policies", + "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 + }, + "sla_hours": { + "name": "sla_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "reminder_offsets_hours": { + "name": "reminder_offsets_hours", + "type": "integer[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "calendar_mode": { + "name": "calendar_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'calendar_days'" + }, + "timeout_action": { + "name": "timeout_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "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_approval_reminder_policies_workflow_step_idx": { + "name": "crm_approval_reminder_policies_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_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 + }, + "current_step_started_at": { + "name": "current_step_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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 + }, + "sla_hours": { + "name": "sla_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "calendar_mode": { + "name": "calendar_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'calendar_days'" + }, + "timeout_action": { + "name": "timeout_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "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 532a84e..fb7afd2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1782455991734, "tag": "0004_unusual_hairball", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1782464526434, + "tag": "0005_furry_maelstrom", + "breakpoints": true } ] } \ No newline at end of file diff --git a/plans/task-f.5.md b/plans/task-f.5.md new file mode 100644 index 0000000..6b05f1f --- /dev/null +++ b/plans/task-f.5.md @@ -0,0 +1,474 @@ +# Task F.5: Approval Automation (Reminder & Escalation) Foundation + +## Status + +Planned + +## Objective + +Build the Approval Automation Foundation that manages reminder scheduling, SLA monitoring, escalation policies, timeout handling, and automatic notification for approval workflows. + +This task extends the Notification Framework (F.4) by introducing time-based automation while keeping approval business logic unchanged. + +The goal is to support: + +* Approval SLA +* Reminder policies +* Escalation policies +* Approval timeout handling +* Automation scheduler +* Approval queue monitoring +* Notification integration + +This task does not implement Email, LINE OA, Webhook delivery, Delegation, Vacation replacement, or Analytics Dashboard. + +--- + +# Mandatory Review + +Review: + +* `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` +* `docs/implementation/task-f4-notification-framework-foundation.md` +* Approval Runtime +* Notification Framework +* CRM Authorization Foundation +* Audit Foundation + +--- + +# Current Foundation + +Already available: + +```txt +Approval Matrix +Approval Workflow Builder +Approval Runtime +Notification Framework +Audit Foundation +``` + +Current limitation: + +```txt +Approval waits forever until someone approves. +``` + +--- + +# Scope F.5.1 Approval SLA + +Each workflow step may define: + +```txt +SLA Hours +Due Date +Reminder Policy +Escalation Policy +Timeout Policy +``` + +Example: + +```txt +Sales Manager + +24 Hours + +Department Manager + +48 Hours + +CEO + +72 Hours +``` + +--- + +# Scope F.5.2 Reminder Policy + +Create: + +```txt +crm_approval_reminder_policies +``` + +Example: + +```txt +Workflow A + +Reminder + +After 1 day + +After 3 days + +After 5 days +``` + +Rules: + +* Multiple reminder points +* Prevent duplicate reminders +* Configurable per workflow + +--- + +# Scope F.5.3 Escalation Policy + +Create: + +```txt +crm_approval_escalation_policies +``` + +Supported targets: + +```txt +Manager +Requester +Explicit User +Role +Permission Group +``` + +Example: + +```txt +After 3 days + +↓ + +Notify Manager + +After 5 days + +↓ + +Escalate Director +``` + +--- + +# Scope F.5.4 Timeout Policy + +Supported actions: + +```txt +None + +Auto Reject + +Auto Cancel + +Auto Escalate +``` + +Timeout actions must be configurable per workflow. + +--- + +# Scope F.5.5 Automation Scheduler + +Create: + +```txt +processApprovalAutomation() +``` + +Responsibilities: + +```txt +Find pending approvals + +↓ + +Calculate SLA + +↓ + +Send reminders + +↓ + +Run escalation + +↓ + +Execute timeout actions + +↓ + +Publish notification events +``` + +Rules: + +* Idempotent +* Safe to execute repeatedly +* Transactional where required + +--- + +# Scope F.5.6 Approval Queue Monitor + +Add service: + +```txt +getPendingApprovalQueue() +``` + +Return: + +```txt +Pending + +Due Today + +Overdue + +Escalated + +Waiting Time + +Remaining SLA +``` + +--- + +# Scope F.5.7 Notification Integration + +Use existing: + +```txt +publishNotificationEvent() +``` + +Events: + +```txt +approval.reminder + +approval.escalated + +approval.timeout +``` + +No direct Email or LINE sending. + +--- + +# Scope F.5.8 Calendar Support + +Prepare business calendar support. + +For now support: + +```txt +Calendar Days +``` + +Future-ready for: + +```txt +Working Days + +Holiday Calendar + +Business Hours +``` + +--- + +# Scope F.5.9 APIs + +Create: + +```txt +GET /api/crm/approval/automation/pending + +POST /api/crm/approval/automation/run + +GET /api/crm/approval/reminder-policies + +POST /api/crm/approval/reminder-policies + +GET /api/crm/approval/escalation-policies + +POST /api/crm/approval/escalation-policies +``` + +Admin only. + +--- + +# Scope F.5.10 UI + +Add settings pages: + +```txt +Approval Reminder Policies + +Approval Escalation Policies +``` + +Features: + +* CRUD policies +* SLA configuration +* Reminder schedule +* Escalation target +* Timeout action + +Use existing design system. + +--- + +# Scope F.5.11 Audit + +Audit actions: + +```txt +approval_reminder_sent + +approval_escalated + +approval_timeout + +approval_policy_created + +approval_policy_updated + +approval_policy_deleted +``` + +--- + +# Scope F.5.12 Permissions + +Verify or add: + +```txt +crm.approval.automation.read + +crm.approval.automation.run + +crm.approval.reminder.manage + +crm.approval.escalation.manage +``` + +--- + +# Documentation + +Create: + +```txt +docs/implementation/task-f5-approval-automation-foundation.md +``` + +Document: + +```txt +SLA model + +Reminder model + +Escalation model + +Timeout model + +Automation scheduler + +Notification integration +``` + +--- + +# Explicit Non-Scope + +Do not implement: + +* SMTP Email +* LINE OA +* Webhook delivery +* Vacation replacement +* Delegate approval +* AI reminder suggestions +* Approval Analytics Dashboard +* Real-time push notifications + +These belong to later tasks. + +--- + +# Verification + +Run: + +```txt +npm exec tsc --noEmit + +npm run build +``` + +Manual verification: + +```txt +Create reminder policy + +Create escalation policy + +Submit approval + +Reminder generated after SLA + +Escalation generated after configured threshold + +Timeout action executes correctly + +Notification created + +Approval remains consistent + +Audit logs created + +Scheduler safe to rerun +``` + +--- + +# Definition of Done + +Task is complete when: + +* Approval SLA exists +* Reminder policies exist +* Escalation policies exist +* Timeout policies exist +* Automation scheduler exists +* Reminder notifications are generated +* Escalation notifications are generated +* Notification Framework is reused +* Audit logs capture automation +* Existing approval flow remains operational + +Result: + +```txt +Approval Automation Foundation = Established + +Reminder Engine = Operational + +Escalation Engine = Operational + +Ready for F.6 Approval Analytics & Dashboard +``` diff --git a/src/app/api/crm/approval/automation/pending/route.ts b/src/app/api/crm/approval/automation/pending/route.ts new file mode 100644 index 0000000..b978dac --- /dev/null +++ b/src/app/api/crm/approval/automation/pending/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { getPendingApprovalQueue } from '@/features/foundation/approval-automation/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET() { + try { + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalAutomationRead + }); + const result = await getPendingApprovalQueue(organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Pending approval automation queue 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 pending approval queue' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/approval/automation/run/route.ts b/src/app/api/crm/approval/automation/run/route.ts new file mode 100644 index 0000000..1318b34 --- /dev/null +++ b/src/app/api/crm/approval/automation/run/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { processApprovalAutomation } from '@/features/foundation/approval-automation/server/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.crmApprovalAutomationRun + }); + const result = await processApprovalAutomation(organization.id, session.user.id); + + return NextResponse.json({ + success: true, + message: 'Approval automation completed 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 run approval automation' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/approval/escalation-policies/[id]/route.ts b/src/app/api/crm/approval/escalation-policies/[id]/route.ts new file mode 100644 index 0000000..2faf642 --- /dev/null +++ b/src/app/api/crm/approval/escalation-policies/[id]/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + deleteApprovalEscalationPolicy, + updateApprovalEscalationPolicy +} from '@/features/foundation/approval-automation/server/service'; +import { approvalEscalationPolicySchema } from '@/features/foundation/approval-automation/schemas'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function PATCH( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalEscalationManage + }); + const { id } = await context.params; + const payload = approvalEscalationPolicySchema.parse(await request.json()); + await updateApprovalEscalationPolicy(id, organization.id, session.user.id, payload); + + return NextResponse.json({ + success: true, + message: 'Approval escalation policy updated successfully' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to update approval escalation policy' }, + { status: 500 } + ); + } +} + +export async function DELETE( + _request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalEscalationManage + }); + const { id } = await context.params; + await deleteApprovalEscalationPolicy(id, organization.id, session.user.id); + + return NextResponse.json({ + success: true, + message: 'Approval escalation policy deleted successfully' + }); + } 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 delete approval escalation policy' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/approval/escalation-policies/route.ts b/src/app/api/crm/approval/escalation-policies/route.ts new file mode 100644 index 0000000..0aca60a --- /dev/null +++ b/src/app/api/crm/approval/escalation-policies/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + createApprovalEscalationPolicy, + listApprovalEscalationPolicies +} from '@/features/foundation/approval-automation/server/service'; +import { approvalEscalationPolicySchema } from '@/features/foundation/approval-automation/schemas'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET() { + try { + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalEscalationManage + }); + const items = await listApprovalEscalationPolicies(organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Approval escalation policies loaded successfully', + items + }); + } 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 approval escalation policies' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalEscalationManage + }); + const payload = approvalEscalationPolicySchema.parse(await request.json()); + const created = await createApprovalEscalationPolicy( + organization.id, + session.user.id, + payload + ); + + return NextResponse.json( + { + success: true, + message: 'Approval escalation policy created successfully', + item: created + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to create approval escalation policy' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/approval/reminder-policies/[id]/route.ts b/src/app/api/crm/approval/reminder-policies/[id]/route.ts new file mode 100644 index 0000000..1441315 --- /dev/null +++ b/src/app/api/crm/approval/reminder-policies/[id]/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + deleteApprovalReminderPolicy, + updateApprovalReminderPolicy +} from '@/features/foundation/approval-automation/server/service'; +import { approvalReminderPolicySchema } from '@/features/foundation/approval-automation/schemas'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function PATCH( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalReminderManage + }); + const { id } = await context.params; + const payload = approvalReminderPolicySchema.parse(await request.json()); + await updateApprovalReminderPolicy(id, organization.id, session.user.id, payload); + + return NextResponse.json({ + success: true, + message: 'Approval reminder policy updated successfully' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to update approval reminder policy' }, + { status: 500 } + ); + } +} + +export async function DELETE( + _request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalReminderManage + }); + const { id } = await context.params; + await deleteApprovalReminderPolicy(id, organization.id, session.user.id); + + return NextResponse.json({ + success: true, + message: 'Approval reminder policy deleted successfully' + }); + } 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 delete approval reminder policy' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/approval/reminder-policies/route.ts b/src/app/api/crm/approval/reminder-policies/route.ts new file mode 100644 index 0000000..78abe06 --- /dev/null +++ b/src/app/api/crm/approval/reminder-policies/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + createApprovalReminderPolicy, + listApprovalReminderPolicies +} from '@/features/foundation/approval-automation/server/service'; +import { approvalReminderPolicySchema } from '@/features/foundation/approval-automation/schemas'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET() { + try { + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalReminderManage + }); + const items = await listApprovalReminderPolicies(organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Approval reminder policies loaded successfully', + items + }); + } 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 approval reminder policies' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalReminderManage + }); + const payload = approvalReminderPolicySchema.parse(await request.json()); + const created = await createApprovalReminderPolicy(organization.id, session.user.id, payload); + + return NextResponse.json( + { + success: true, + message: 'Approval reminder policy created successfully', + item: created + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to create approval reminder policy' }, + { status: 500 } + ); + } +} diff --git a/src/app/dashboard/crm/settings/approval-escalation-policies/page.tsx b/src/app/dashboard/crm/settings/approval-escalation-policies/page.tsx new file mode 100644 index 0000000..dfc3813 --- /dev/null +++ b/src/app/dashboard/crm/settings/approval-escalation-policies/page.tsx @@ -0,0 +1,40 @@ +import { auth } from '@/auth'; +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import PageContainer from '@/components/layout/page-container'; +import { EscalationPolicySettings } from '@/features/foundation/approval-automation/components/escalation-policy-settings'; +import { approvalEscalationPoliciesOptions } from '@/features/foundation/approval-automation/api/queries'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { getQueryClient } from '@/lib/query-client'; + +export default async function ApprovalEscalationPoliciesPage() { + const session = await auth(); + const canRead = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmApprovalEscalationManage))); + + const queryClient = getQueryClient(); + if (canRead) { + void queryClient.prefetchQuery(approvalEscalationPoliciesOptions()); + } + + return ( + + You do not have access to approval escalation policies. + + } + > + {canRead ? ( + + + + ) : null} + + ); +} diff --git a/src/app/dashboard/crm/settings/approval-reminder-policies/page.tsx b/src/app/dashboard/crm/settings/approval-reminder-policies/page.tsx new file mode 100644 index 0000000..792f592 --- /dev/null +++ b/src/app/dashboard/crm/settings/approval-reminder-policies/page.tsx @@ -0,0 +1,44 @@ +import { auth } from '@/auth'; +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import PageContainer from '@/components/layout/page-container'; +import { ReminderPolicySettings } from '@/features/foundation/approval-automation/components/reminder-policy-settings'; +import { + approvalReminderPoliciesOptions, + pendingApprovalAutomationQueueOptions +} from '@/features/foundation/approval-automation/api/queries'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { getQueryClient } from '@/lib/query-client'; + +export default async function ApprovalReminderPoliciesPage() { + const session = await auth(); + const canRead = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmApprovalReminderManage))); + + const queryClient = getQueryClient(); + if (canRead) { + void queryClient.prefetchQuery(approvalReminderPoliciesOptions()); + void queryClient.prefetchQuery(pendingApprovalAutomationQueueOptions()); + } + + return ( + + You do not have access to approval reminder policies. + + } + > + {canRead ? ( + + + + ) : null} + + ); +} diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index bade8a6..ab1e0bd 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -123,6 +123,22 @@ import { NavGroup } from "@/types"; permission: "crm.approval.workflow.read", }, }, + { + title: "Approval Reminder Policies", + url: "/dashboard/crm/settings/approval-reminder-policies", + access: { + requireOrg: true, + permission: "crm.approval.reminder.manage", + }, + }, + { + title: "Approval Escalation Policies", + url: "/dashboard/crm/settings/approval-escalation-policies", + access: { + requireOrg: true, + permission: "crm.approval.escalation.manage", + }, + }, { title: "Templates", url: "/dashboard/crm/settings/templates", diff --git a/src/db/schema.ts b/src/db/schema.ts index 65a2ebf..5632808 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -766,6 +766,9 @@ export const crmApprovalSteps = pgTable( roleName: text('role_name').notNull(), approvalMode: text('approval_mode').default('sequential').notNull(), isRequired: boolean('is_required').default(true).notNull(), + slaHours: integer('sla_hours').default(24).notNull(), + calendarMode: text('calendar_mode').default('calendar_days').notNull(), + timeoutAction: text('timeout_action').default('none').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), deletedAt: timestamp('deleted_at', { withTimezone: true }) @@ -806,6 +809,9 @@ export const crmApprovalRequests = pgTable('crm_approval_requests', { entityType: text('entity_type').notNull(), entityId: text('entity_id').notNull(), currentStep: integer('current_step').default(1).notNull(), + currentStepStartedAt: timestamp('current_step_started_at', { withTimezone: true }) + .defaultNow() + .notNull(), status: text('status').notNull(), requestedBy: text('requested_by').notNull(), requestedAt: timestamp('requested_at', { withTimezone: true }).defaultNow().notNull(), @@ -815,6 +821,48 @@ export const crmApprovalRequests = pgTable('crm_approval_requests', { deletedAt: timestamp('deleted_at', { withTimezone: true }) }); +export const crmApprovalReminderPolicies = pgTable( + 'crm_approval_reminder_policies', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + workflowId: text('workflow_id').notNull(), + stepNumber: integer('step_number').notNull(), + slaHours: integer('sla_hours').default(24).notNull(), + reminderOffsetsHours: integer('reminder_offsets_hours').array().default([]).notNull(), + calendarMode: text('calendar_mode').default('calendar_days').notNull(), + timeoutAction: text('timeout_action').default('none').notNull(), + isActive: boolean('is_active').default(true).notNull(), + 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) => ({ + workflowStepIdx: uniqueIndex('crm_approval_reminder_policies_workflow_step_idx').on( + table.workflowId, + table.stepNumber + ) + }) +); + +export const crmApprovalEscalationPolicies = pgTable('crm_approval_escalation_policies', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + workflowId: text('workflow_id').notNull(), + stepNumber: integer('step_number').notNull(), + triggerAfterHours: integer('trigger_after_hours').notNull(), + targetType: text('target_type').notNull(), + targetValue: text('target_value'), + isActive: boolean('is_active').default(true).notNull(), + 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() +}); + export const crmApprovalActions = pgTable('crm_approval_actions', { id: text('id').primaryKey(), organizationId: text('organization_id').notNull(), diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index 0cec9cd..66fc63a 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -1480,6 +1480,27 @@ async function upsertNotificationTemplatesForOrganization( titleTemplate: 'Approval Returned: {{quotationCode}}', bodyTemplate: '{{actorName}} returned {{quotationCode}} for revision. Reason: {{remark}}', linkTemplate: '{{entityLink}}' + }, + { + eventType: 'approval.reminder', + titleTemplate: 'Approval Reminder: {{workflowName}}', + bodyTemplate: + '{{quotationCode}} is still waiting on {{currentStepRoleName}}. Please review it.', + linkTemplate: '{{entityLink}}' + }, + { + eventType: 'approval.escalated', + titleTemplate: 'Approval Escalated: {{workflowName}}', + bodyTemplate: + '{{quotationCode}} exceeded its escalation threshold at {{currentStepRoleName}}.', + linkTemplate: '{{entityLink}}' + }, + { + eventType: 'approval.timeout', + titleTemplate: 'Approval Timeout: {{workflowName}}', + bodyTemplate: + '{{quotationCode}} reached timeout handling at {{currentStepRoleName}}. {{remark}}', + linkTemplate: '{{entityLink}}' } ]; diff --git a/src/features/foundation/approval-automation/api/mutations.ts b/src/features/foundation/approval-automation/api/mutations.ts new file mode 100644 index 0000000..e130ba2 --- /dev/null +++ b/src/features/foundation/approval-automation/api/mutations.ts @@ -0,0 +1,87 @@ +import { mutationOptions } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; +import { + createApprovalEscalationPolicy, + createApprovalReminderPolicy, + deleteApprovalEscalationPolicy, + deleteApprovalReminderPolicy, + runApprovalAutomation, + updateApprovalEscalationPolicy, + updateApprovalReminderPolicy +} from './service'; +import { approvalAutomationKeys } from './queries'; +import type { EscalationPolicyPayload, ReminderPolicyPayload } from './types'; + +async function invalidateAutomationQueries() { + const queryClient = getQueryClient(); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: approvalAutomationKeys.pendingQueue() }), + queryClient.invalidateQueries({ queryKey: approvalAutomationKeys.reminderPolicies() }), + queryClient.invalidateQueries({ queryKey: approvalAutomationKeys.escalationPolicies() }) + ]); +} + +export const runApprovalAutomationMutation = mutationOptions({ + mutationFn: () => runApprovalAutomation(), + onSettled: async (_data, error) => { + if (!error) { + await invalidateAutomationQueries(); + } + } +}); + +export const createApprovalReminderPolicyMutation = mutationOptions({ + mutationFn: (data: ReminderPolicyPayload) => createApprovalReminderPolicy(data), + onSettled: async (_data, error) => { + if (!error) { + await invalidateAutomationQueries(); + } + } +}); + +export const updateApprovalReminderPolicyMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: ReminderPolicyPayload }) => + updateApprovalReminderPolicy(id, values), + onSettled: async (_data, error) => { + if (!error) { + await invalidateAutomationQueries(); + } + } +}); + +export const deleteApprovalReminderPolicyMutation = mutationOptions({ + mutationFn: (id: string) => deleteApprovalReminderPolicy(id), + onSettled: async (_data, error) => { + if (!error) { + await invalidateAutomationQueries(); + } + } +}); + +export const createApprovalEscalationPolicyMutation = mutationOptions({ + mutationFn: (data: EscalationPolicyPayload) => createApprovalEscalationPolicy(data), + onSettled: async (_data, error) => { + if (!error) { + await invalidateAutomationQueries(); + } + } +}); + +export const updateApprovalEscalationPolicyMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: EscalationPolicyPayload }) => + updateApprovalEscalationPolicy(id, values), + onSettled: async (_data, error) => { + if (!error) { + await invalidateAutomationQueries(); + } + } +}); + +export const deleteApprovalEscalationPolicyMutation = mutationOptions({ + mutationFn: (id: string) => deleteApprovalEscalationPolicy(id), + onSettled: async (_data, error) => { + if (!error) { + await invalidateAutomationQueries(); + } + } +}); diff --git a/src/features/foundation/approval-automation/api/queries.ts b/src/features/foundation/approval-automation/api/queries.ts new file mode 100644 index 0000000..31c128e --- /dev/null +++ b/src/features/foundation/approval-automation/api/queries.ts @@ -0,0 +1,31 @@ +import { queryOptions } from '@tanstack/react-query'; +import { + getApprovalEscalationPolicies, + getApprovalReminderPolicies, + getPendingApprovalAutomationQueue +} from './service'; + +export const approvalAutomationKeys = { + all: ['crm-approval-automation'] as const, + pendingQueue: () => [...approvalAutomationKeys.all, 'pending-queue'] as const, + reminderPolicies: () => [...approvalAutomationKeys.all, 'reminder-policies'] as const, + escalationPolicies: () => [...approvalAutomationKeys.all, 'escalation-policies'] as const +}; + +export const pendingApprovalAutomationQueueOptions = () => + queryOptions({ + queryKey: approvalAutomationKeys.pendingQueue(), + queryFn: () => getPendingApprovalAutomationQueue() + }); + +export const approvalReminderPoliciesOptions = () => + queryOptions({ + queryKey: approvalAutomationKeys.reminderPolicies(), + queryFn: () => getApprovalReminderPolicies() + }); + +export const approvalEscalationPoliciesOptions = () => + queryOptions({ + queryKey: approvalAutomationKeys.escalationPolicies(), + queryFn: () => getApprovalEscalationPolicies() + }); diff --git a/src/features/foundation/approval-automation/api/service.ts b/src/features/foundation/approval-automation/api/service.ts new file mode 100644 index 0000000..92ce7ed --- /dev/null +++ b/src/features/foundation/approval-automation/api/service.ts @@ -0,0 +1,70 @@ +import { apiClient } from '@/lib/api-client'; +import type { + ApprovalAutomationRunResponse, + EscalationPoliciesResponse, + EscalationPolicyPayload, + MutationSuccessResponse, + PendingApprovalQueueResponse, + ReminderPoliciesResponse, + ReminderPolicyPayload +} from './types'; + +const BASE = '/crm/approval'; + +export async function getPendingApprovalAutomationQueue() { + return apiClient(`${BASE}/automation/pending`); +} + +export async function runApprovalAutomation() { + return apiClient(`${BASE}/automation/run`, { + method: 'POST' + }); +} + +export async function getApprovalReminderPolicies() { + return apiClient(`${BASE}/reminder-policies`); +} + +export async function createApprovalReminderPolicy(data: ReminderPolicyPayload) { + return apiClient(`${BASE}/reminder-policies`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateApprovalReminderPolicy(id: string, data: ReminderPolicyPayload) { + return apiClient(`${BASE}/reminder-policies/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteApprovalReminderPolicy(id: string) { + return apiClient(`${BASE}/reminder-policies/${id}`, { + method: 'DELETE' + }); +} + +export async function getApprovalEscalationPolicies() { + return apiClient(`${BASE}/escalation-policies`); +} + +export async function createApprovalEscalationPolicy(data: EscalationPolicyPayload) { + return apiClient(`${BASE}/escalation-policies`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateApprovalEscalationPolicy(id: string, data: EscalationPolicyPayload) { + return apiClient(`${BASE}/escalation-policies/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteApprovalEscalationPolicy(id: string) { + return apiClient(`${BASE}/escalation-policies/${id}`, { + method: 'DELETE' + }); +} diff --git a/src/features/foundation/approval-automation/api/types.ts b/src/features/foundation/approval-automation/api/types.ts new file mode 100644 index 0000000..78ab7aa --- /dev/null +++ b/src/features/foundation/approval-automation/api/types.ts @@ -0,0 +1,112 @@ +import type { + ApprovalEscalationTargetType, + ApprovalTimeoutAction +} from '../types'; + +export interface ReminderPolicyItem { + id: string; + organizationId: string; + workflowId: string; + workflowName: string; + stepNumber: number; + slaHours: number; + reminderOffsetsHours: number[]; + calendarMode: 'calendar_days'; + timeoutAction: ApprovalTimeoutAction; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface EscalationPolicyItem { + id: string; + organizationId: string; + workflowId: string; + workflowName: string; + stepNumber: number; + triggerAfterHours: number; + targetType: ApprovalEscalationTargetType; + targetValue: string | null; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface PendingApprovalQueueItem { + approvalRequestId: string; + workflowId: string; + workflowName: string; + entityType: string; + entityId: string; + requestedBy: string; + requestedAt: string; + currentStep: number; + currentStepRoleCode: string | null; + currentStepRoleName: string | null; + currentStepStartedAt: string; + waitingHours: number; + slaHours: number | null; + remainingHours: number | null; + dueAt: string | null; + isDueToday: boolean; + isOverdue: boolean; + isEscalated: boolean; +} + +export interface PendingApprovalQueueResponse { + success: boolean; + time: string; + message: string; + items: PendingApprovalQueueItem[]; + summary: { + pending: number; + dueToday: number; + overdue: number; + escalated: number; + }; +} + +export interface ReminderPoliciesResponse { + success: boolean; + time: string; + message: string; + items: ReminderPolicyItem[]; +} + +export interface EscalationPoliciesResponse { + success: boolean; + time: string; + message: string; + items: EscalationPolicyItem[]; +} + +export interface ReminderPolicyPayload { + workflowId: string; + stepNumber: number; + slaHours: number; + reminderOffsetsHours: number[]; + calendarMode?: 'calendar_days'; + timeoutAction?: ApprovalTimeoutAction; + isActive?: boolean; +} + +export interface EscalationPolicyPayload { + workflowId: string; + stepNumber: number; + triggerAfterHours: number; + targetType: ApprovalEscalationTargetType; + targetValue?: string | null; + isActive?: boolean; +} + +export interface ApprovalAutomationRunResponse { + success: boolean; + message: string; + processedCount: number; + processedAt: string; +} + +export interface MutationSuccessResponse { + success: boolean; + message: string; +} diff --git a/src/features/foundation/approval-automation/components/escalation-policy-settings.tsx b/src/features/foundation/approval-automation/components/escalation-policy-settings.tsx new file mode 100644 index 0000000..2f89c87 --- /dev/null +++ b/src/features/foundation/approval-automation/components/escalation-policy-settings.tsx @@ -0,0 +1,331 @@ +'use client'; + +import * as React from 'react'; +import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { BUSINESS_ROLES, PERMISSIONS } from '@/lib/auth/rbac'; +import { Icons } from '@/components/icons'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { approvalWorkflowsQueryOptions } from '@/features/foundation/approval/queries'; +import { + createApprovalEscalationPolicyMutation, + deleteApprovalEscalationPolicyMutation, + updateApprovalEscalationPolicyMutation +} from '../api/mutations'; +import { approvalEscalationPoliciesOptions } from '../api/queries'; +import type { EscalationPolicyItem, EscalationPolicyPayload } from '../api/types'; + +type EscalationFormState = { + workflowId: string; + stepNumber: string; + triggerAfterHours: string; + targetType: EscalationPolicyPayload['targetType']; + targetValue: string; + isActive: boolean; +}; + +function toEscalationState(policy?: EscalationPolicyItem): EscalationFormState { + return { + workflowId: policy?.workflowId ?? '', + stepNumber: String(policy?.stepNumber ?? 1), + triggerAfterHours: String(policy?.triggerAfterHours ?? 24), + targetType: policy?.targetType ?? 'manager', + targetValue: policy?.targetValue ?? '', + isActive: policy?.isActive ?? true + }; +} + +function EscalationPolicyDialog({ + open, + onOpenChange, + policy +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + policy?: EscalationPolicyItem; +}) { + const workflowsQuery = useSuspenseQuery(approvalWorkflowsQueryOptions()); + const [state, setState] = React.useState(toEscalationState(policy)); + const createMutation = useMutation({ + ...createApprovalEscalationPolicyMutation, + onSuccess: () => { + toast.success('Escalation policy created'); + onOpenChange(false); + }, + onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed') + }); + const updateMutation = useMutation({ + ...updateApprovalEscalationPolicyMutation, + onSuccess: () => { + toast.success('Escalation policy updated'); + onOpenChange(false); + }, + onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed') + }); + + React.useEffect(() => { + if (open) { + const workflowId = policy?.workflowId ?? workflowsQuery.data.items[0]?.id ?? ''; + setState({ ...toEscalationState(policy), workflowId }); + } + }, [open, policy, workflowsQuery.data.items]); + + const isPending = createMutation.isPending || updateMutation.isPending; + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + const payload: EscalationPolicyPayload = { + workflowId: state.workflowId, + stepNumber: Number(state.stepNumber), + triggerAfterHours: Number(state.triggerAfterHours), + targetType: state.targetType, + targetValue: state.targetValue.trim() || null, + isActive: state.isActive + }; + + if (policy) { + await updateMutation.mutateAsync({ id: policy.id, values: payload }); + return; + } + + await createMutation.mutateAsync(payload); + } + + return ( + + + + {policy ? 'Edit Escalation Policy' : 'Create Escalation Policy'} + +
+
+
+
Workflow
+ +
+
+
Step Number
+ + setState((current) => ({ ...current, stepNumber: event.target.value })) + } + /> +
+
+
+
+
Trigger After Hours
+ + setState((current) => ({ + ...current, + triggerAfterHours: event.target.value + })) + } + /> +
+
+
Target Type
+ +
+
+
+
Target Value
+ {state.targetType === 'role' ? ( + + ) : state.targetType === 'permission_group' ? ( + + ) : ( + + setState((current) => ({ ...current, targetValue: event.target.value })) + } + placeholder={ + state.targetType === 'manager' + ? 'Optional role code, otherwise organization admins' + : state.targetType === 'explicit_user' + ? 'User id' + : 'Optional' + } + /> + )} +
+
+
+
Active Policy
+
+ Inactive escalation policies are ignored by the scheduler. +
+
+ + setState((current) => ({ ...current, isActive: checked })) + } + /> +
+ + + + +
+
+
+ ); +} + +export function EscalationPolicySettings() { + const escalationPoliciesQuery = useSuspenseQuery(approvalEscalationPoliciesOptions()); + const [createOpen, setCreateOpen] = React.useState(false); + const [editingPolicy, setEditingPolicy] = React.useState(null); + const deleteMutation = useMutation({ + ...deleteApprovalEscalationPolicyMutation, + onSuccess: () => toast.success('Escalation policy deleted'), + onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed') + }); + + return ( +
+
+
+
Approval Escalation Policies
+
+ Define who gets notified when a pending step waits too long. +
+
+ +
+ +
+ {escalationPoliciesQuery.data.items.map((policy) => ( +
+
+
+
+ {policy.workflowName} | Step {policy.stepNumber} +
+
+ Escalate after {policy.triggerAfterHours} hours to {policy.targetType} + {policy.targetValue ? `: ${policy.targetValue}` : ''} +
+
+ {policy.targetType} + {policy.triggerAfterHours}h + + {policy.isActive ? 'Active' : 'Inactive'} + +
+
+
+ + +
+
+
+ ))} + {escalationPoliciesQuery.data.items.length === 0 ? ( +
+ No escalation policies configured yet. +
+ ) : null} +
+ + + { + if (!open) { + setEditingPolicy(null); + } + }} + policy={editingPolicy ?? undefined} + /> +
+ ); +} diff --git a/src/features/foundation/approval-automation/components/reminder-policy-settings.tsx b/src/features/foundation/approval-automation/components/reminder-policy-settings.tsx new file mode 100644 index 0000000..e576438 --- /dev/null +++ b/src/features/foundation/approval-automation/components/reminder-policy-settings.tsx @@ -0,0 +1,369 @@ +'use client'; + +import * as React from 'react'; +import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Icons } from '@/components/icons'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { approvalWorkflowsQueryOptions } from '@/features/foundation/approval/queries'; +import { + createApprovalReminderPolicyMutation, + deleteApprovalReminderPolicyMutation, + runApprovalAutomationMutation, + updateApprovalReminderPolicyMutation +} from '../api/mutations'; +import { + approvalReminderPoliciesOptions, + pendingApprovalAutomationQueueOptions +} from '../api/queries'; +import type { ReminderPolicyItem, ReminderPolicyPayload } from '../api/types'; + +type ReminderFormState = { + workflowId: string; + stepNumber: string; + slaHours: string; + reminderOffsets: string; + timeoutAction: ReminderPolicyPayload['timeoutAction']; + isActive: boolean; +}; + +function toReminderState(policy?: ReminderPolicyItem): ReminderFormState { + return { + workflowId: policy?.workflowId ?? '', + stepNumber: String(policy?.stepNumber ?? 1), + slaHours: String(policy?.slaHours ?? 24), + reminderOffsets: policy?.reminderOffsetsHours.join(', ') ?? '24, 48', + timeoutAction: policy?.timeoutAction ?? 'none', + isActive: policy?.isActive ?? true + }; +} + +function parseOffsets(value: string) { + return value + .split(',') + .map((item) => Number(item.trim())) + .filter((item) => Number.isFinite(item) && item > 0); +} + +function ReminderPolicyDialog({ + open, + onOpenChange, + policy +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + policy?: ReminderPolicyItem; +}) { + const workflowsQuery = useSuspenseQuery(approvalWorkflowsQueryOptions()); + const [state, setState] = React.useState(toReminderState(policy)); + const createMutation = useMutation({ + ...createApprovalReminderPolicyMutation, + onSuccess: () => { + toast.success('Reminder policy created'); + onOpenChange(false); + }, + onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed') + }); + const updateMutation = useMutation({ + ...updateApprovalReminderPolicyMutation, + onSuccess: () => { + toast.success('Reminder policy updated'); + onOpenChange(false); + }, + onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed') + }); + + React.useEffect(() => { + if (open) { + const workflowId = policy?.workflowId ?? workflowsQuery.data.items[0]?.id ?? ''; + setState({ ...toReminderState(policy), workflowId }); + } + }, [open, policy, workflowsQuery.data.items]); + + const isPending = createMutation.isPending || updateMutation.isPending; + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + const payload: ReminderPolicyPayload = { + workflowId: state.workflowId, + stepNumber: Number(state.stepNumber), + slaHours: Number(state.slaHours), + reminderOffsetsHours: parseOffsets(state.reminderOffsets), + timeoutAction: state.timeoutAction, + isActive: state.isActive, + calendarMode: 'calendar_days' + }; + + if (policy) { + await updateMutation.mutateAsync({ id: policy.id, values: payload }); + return; + } + + await createMutation.mutateAsync(payload); + } + + return ( + + + + {policy ? 'Edit Reminder Policy' : 'Create Reminder Policy'} + +
+
+
+
Workflow
+ +
+
+
Step Number
+ + setState((current) => ({ ...current, stepNumber: event.target.value })) + } + /> +
+
+
+
+
SLA Hours
+ + setState((current) => ({ ...current, slaHours: event.target.value })) + } + /> +
+
+
Timeout Action
+ +
+
+
+
Reminder Offsets (hours)
+