diff --git a/docs/implementation/task-l-crm-permission-role-management.md b/docs/implementation/task-l-crm-permission-role-management.md new file mode 100644 index 0000000..2bd431e --- /dev/null +++ b/docs/implementation/task-l-crm-permission-role-management.md @@ -0,0 +1,60 @@ +# Task L: CRM Permission & Role Management + +## Summary + +Task L starts the production CRM authorization foundation by replacing template-level behavior with organization-owned role profiles, effective permission resolution, and server-side scope enforcement. + +## Implemented + +- Added CRM role profile defaults for: + - `marketing` + - `sales` + - `sales_support` + - `sales_manager` + - `department_manager` + - `top_manager` + - `crm_admin` +- Added dedicated CRM permissions for: + - quotation pricing visibility + - role management + - master option management +- Added `crmRoleProfiles` persistence model with: + - permissions + - ownership scope + - branch scope mode + - product scope mode + - approval authority + - active/system flags +- Added membership scope fields: + - `branchScopeIds` + - `productTypeScopeIds` +- Added resolved CRM access helper to combine: + - system role + - membership role + - role profile permissions + - direct membership permissions +- Added CRM Settings > Roles page and role-management APIs for: + - role listing + - role editing + - permission matrix + - role cloning + - role activation/deactivation +- Added audit logging for CRM role create/update events. +- Moved CRM dashboard access evaluation to resolved branch/product/ownership scope. +- Started server-side enquiry enforcement for: + - branch scope + - product-type scope + - ownership visibility + - assign/reassign/follow-up access consistency + +## Notes + +- This task lays the authorization foundation first; dynamic user assignment UI for cloned CRM roles can build on top of these role profiles next. +- Quotation and approval flows should continue migrating onto the same resolved access model so ownership and scope rules stay consistent across CRM. + +## Verification + +- Run: `npx tsc --noEmit` +- Confirm CRM Settings sidebar shows `Roles & Permissions` only for users with `crm.role.read` +- Confirm sales users cannot access enquiries outside assigned branch/product scope +- Confirm dashboard export and dashboard summary honor resolved scope and permissions diff --git a/drizzle/0013_great_nicolaos.sql b/drizzle/0013_great_nicolaos.sql new file mode 100644 index 0000000..a4489fc --- /dev/null +++ b/drizzle/0013_great_nicolaos.sql @@ -0,0 +1,23 @@ +CREATE TABLE "crm_role_profiles" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "code" text NOT NULL, + "name" text NOT NULL, + "description" text, + "permissions" text[] DEFAULT '{}' NOT NULL, + "branch_scope_mode" text DEFAULT 'assigned' NOT NULL, + "product_scope_mode" text DEFAULT 'assigned' NOT NULL, + "ownership_scope" text DEFAULT 'own' NOT NULL, + "approval_authority" text DEFAULT 'none' NOT NULL, + "is_system" boolean DEFAULT false 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 "memberships" ADD COLUMN "branch_scope_ids" text[] DEFAULT '{}' NOT NULL;--> statement-breakpoint +ALTER TABLE "memberships" ADD COLUMN "product_type_scope_ids" text[] DEFAULT '{}' NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "crm_role_profiles_org_code_idx" ON "crm_role_profiles" USING btree ("organization_id","code"); \ No newline at end of file diff --git a/drizzle/meta/0013_snapshot.json b/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..a3b2cdc --- /dev/null +++ b/drizzle/meta/0013_snapshot.json @@ -0,0 +1,3440 @@ +{ + "id": "6f765853-49cf-4310-ba12-414adc1e05dc", + "prevId": "0295d780-7a89-4886-914b-61fa8f6c59b7", + "version": "7", + "dialect": "postgresql", + "tables": { + "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_requests": { + "name": "crm_approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_steps": { + "name": "crm_approval_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role_code": { + "name": "role_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_required": { + "name": "is_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_steps_workflow_step_idx": { + "name": "crm_approval_steps_workflow_step_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_workflows": { + "name": "crm_approval_workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + } + }, + "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_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_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 + }, + "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_enquiries": { + "name": "crm_enquiries", + "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": 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 + }, + "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'" + }, + "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_enquiries_org_code_idx": { + "name": "crm_enquiries_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_enquiry_customers": { + "name": "crm_enquiry_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enquiry_id": { + "name": "enquiry_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_enquiry_followups": { + "name": "crm_enquiry_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enquiry_id": { + "name": "enquiry_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 + }, + "enquiry_id": { + "name": "enquiry_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_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.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 ca7f0d6..794846c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1781836261952, "tag": "0012_simple_bucky", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1782097963376, + "tag": "0013_great_nicolaos", + "breakpoints": true } ] } \ No newline at end of file diff --git a/plans/task-l.md b/plans/task-l.md new file mode 100644 index 0000000..0422973 --- /dev/null +++ b/plans/task-l.md @@ -0,0 +1,507 @@ +# Task L: CRM Permission & Role Management Center + +## Objective + +Replace remaining template-level role behavior with a production CRM authorization model. + +Task L establishes: + +* CRM business roles +* role-based permissions +* branch scope +* product-type scope +* ownership visibility +* approval authority +* menu visibility +* configuration security + +This task becomes the authorization foundation for all future CRM modules. + +--- + +# Current Problems + +The system currently has: + +```txt +Users +Memberships +Roles +Permissions +``` + +but still relies partly on template behavior. + +Examples: + +* users cannot manage permissions directly +* role assignment is incomplete +* branch visibility is not enforced everywhere +* product-type visibility is not enforced everywhere +* dashboard visibility still depends on broad organization access +* approval authority is role-driven but not centrally configurable + +--- + +# Business Roles (Frozen) + +## Marketing + +Responsibilities: + +```txt +Lead creation +Lead follow-up +Lead assignment +Lead monitoring +``` + +Can: + +```txt +View Leads +Create Leads +Assign Leads +View Enquiries +View Follow-Ups +``` + +Cannot: + +```txt +View quotation pricing +View revenue dashboard +Approve quotations +Edit quotations +``` + +--- + +## Sales + +Responsibilities: + +```txt +Manage enquiries +Manage quotations +Manage follow-ups +``` + +Can: + +```txt +View assigned enquiries +Create quotations +Edit own quotations +Manage own follow-ups +``` + +Cannot: + +```txt +View other sales data +Approve quotations +Manage CRM settings +``` + +--- + +## Sales Support + +Can: + +```txt +Create quotations +Edit quotations +Support sales operations +``` + +Cannot: + +```txt +Approve quotations +View management analytics +``` + +--- + +## Sales Manager + +Can: + +```txt +View team enquiries +Assign sales +Approve quotations +View team dashboard +``` + +Visibility: + +```txt +Branch scope +Product-type scope +``` + +--- + +## Department Manager + +Can: + +```txt +Approve quotations +View department analytics +View department pipeline +``` + +--- + +## Top Manager + +Can: + +```txt +Approve final quotations +View all CRM analytics +``` + +--- + +## CRM Admin + +Can: + +```txt +Manage templates +Manage workflows +Manage document sequences +Manage master options +Manage CRM configuration +``` + +Cannot: + +```txt +Override system security +``` + +--- + +## System Admin + +Can: + +```txt +Everything +``` + +--- + +# Scope L.1 Role Management UI + +Add: + +```txt +CRM Settings + └─ Roles & Permissions +``` + +Features: + +```txt +Role list +Role detail +Permission matrix +Role cloning +Role activation +Role deactivation +``` + +--- + +# Scope L.2 Permission Assignment + +Allow administrators to assign permissions to roles. + +Support: + +```txt +read +create +update +delete +approve +assign +export +manage +``` + +Examples: + +```txt +crm.lead.read +crm.lead.create + +crm.enquiry.read +crm.enquiry.assign + +crm.quotation.approve + +crm.dashboard.export +``` + +--- + +# Scope L.3 User Permission Resolution + +Effective permission: + +```txt +System Role ++ +Membership Role ++ +Direct Permissions +``` + +Evaluation order: + +```txt +System Admin +↓ +Role Permissions +↓ +User Overrides +``` + +--- + +# Scope L.4 Branch Scope + +User can be assigned: + +```txt +Bangkok +Rayong +Chiang Mai +``` + +Visibility enforced server-side. + +Examples: + +```txt +Sales Bangkok +cannot view +Rayong enquiries +``` + +--- + +# Scope L.5 Product Type Scope + +Supported: + +```txt +Crane +Dock Door +Solar Cell +Service +Spare Part +``` + +Example: + +```txt +Sales Crane +cannot see +Solar quotations +``` + +--- + +# Scope L.6 Ownership Enforcement + +Server-side rules. + +Sales: + +```txt +Own records only +``` + +Manager: + +```txt +Team records +``` + +Admin: + +```txt +Organization records +``` + +Marketing: + +```txt +Lead monitoring only +``` + +--- + +# Scope L.7 Menu Security + +Navigation generated from permissions. + +Examples: + +```txt +Lead +Enquiry +Quotation +Approval +Dashboard +Settings +``` + +Menus disappear automatically when permission is absent. + +--- + +# Scope L.8 Dashboard Security + +Enforce: + +```txt +Lead KPI +Enquiry KPI +Revenue KPI +Approval KPI +``` + +according to permission. + +No UI-only hiding. + +Server-side enforcement required. + +--- + +# Scope L.9 CRM Settings Security + +Protect: + +```txt +Document Templates +Approval Workflows +Document Sequences +Master Options +``` + +using dedicated CRM permissions. + +--- + +# Scope L.10 Permission Audit + +Audit: + +```txt +Role Created +Role Updated +Role Deleted + +Permission Added +Permission Removed + +Scope Changed +``` + +Entity Types: + +```txt +crm_role +crm_permission +crm_scope +``` + +--- + +# Scope L.11 Migration Strategy + +Convert legacy roles: + +```txt +admin +user +``` + +into CRM-aligned business roles. + +Backfill: + +```txt +sales +marketing +sales_manager +crm_admin +``` + +where possible. + +Retain historical records. + +--- + +# Scope L.12 Verification + +Verify: + +```txt +Marketing cannot see quotation price +Sales cannot see other sales enquiries +Managers can see team data +Dashboard respects permissions +Menus respect permissions +CRM settings respect permissions +``` + +Run: + +```txt +npx tsc --noEmit +``` + +--- + +# Deliverables + +1. Role Management Center +2. Permission Matrix +3. Branch Scope Enforcement +4. Product Type Scope Enforcement +5. Ownership Visibility Enforcement +6. Dashboard Security Enforcement +7. CRM Settings Security +8. Audit Logging +9. Legacy Role Migration + +--- + +# Definition of Done + +Task L is complete when: + +* permissions are assignable +* role matrix exists +* branch scope enforced +* product scope enforced +* ownership enforced +* dashboard secured +* settings secured +* menus secured +* audits recorded + +This becomes the final authorization foundation before Report Center and Notification Center work begins. diff --git a/src/app/api/crm/dashboard/export/route.ts b/src/app/api/crm/dashboard/export/route.ts index 2c4d110..323f7d0 100644 --- a/src/app/api/crm/dashboard/export/route.ts +++ b/src/app/api/crm/dashboard/export/route.ts @@ -40,7 +40,12 @@ export async function GET(request: NextRequest) { userId: session.user.id, membershipRole: membership.role, businessRole: membership.businessRole, - permissions: membership.permissions + permissions: membership.permissions, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }, { dateFrom: searchParams.get('dateFrom'), diff --git a/src/app/api/crm/dashboard/route.ts b/src/app/api/crm/dashboard/route.ts index 3f7d586..2248046 100644 --- a/src/app/api/crm/dashboard/route.ts +++ b/src/app/api/crm/dashboard/route.ts @@ -15,7 +15,12 @@ export async function GET(request: NextRequest) { userId: session.user.id, membershipRole: membership.role, businessRole: membership.businessRole, - permissions: membership.permissions + permissions: membership.permissions, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }, { dateFrom: searchParams.get('dateFrom'), diff --git a/src/app/api/crm/enquiries/[id]/assign/route.ts b/src/app/api/crm/enquiries/[id]/assign/route.ts index a73c419..21ce562 100644 --- a/src/app/api/crm/enquiries/[id]/assign/route.ts +++ b/src/app/api/crm/enquiries/[id]/assign/route.ts @@ -17,13 +17,25 @@ export async function POST(request: NextRequest, { params }: Params) { permission: PERMISSIONS.crmEnquiryAssign }); const payload = enquiryAssignmentRequestSchema.parse(await request.json()); - const before = await getEnquiryDetail(id, organization.id, { + const accessContext = { organizationId: organization.id, userId: session.user.id, membershipRole: membership.role, - businessRole: membership.businessRole - }); - const updated = await assignEnquiryToUser(id, organization.id, session.user.id, payload); + businessRole: membership.businessRole, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; + const before = await getEnquiryDetail(id, organization.id, accessContext); + const updated = await assignEnquiryToUser( + id, + organization.id, + session.user.id, + payload, + accessContext + ); await auditAction({ organizationId: organization.id, diff --git a/src/app/api/crm/enquiries/[id]/customers/route.ts b/src/app/api/crm/enquiries/[id]/customers/route.ts index ea03042..a9efdca 100644 --- a/src/app/api/crm/enquiries/[id]/customers/route.ts +++ b/src/app/api/crm/enquiries/[id]/customers/route.ts @@ -13,12 +13,18 @@ export async function GET(_request: NextRequest, { params }: Params) { const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmEnquiryRead }); - const items = await listEnquiryProjectParties(id, organization.id, { + const accessContext = { organizationId: organization.id, userId: session.user.id, membershipRole: membership.role, - businessRole: membership.businessRole - }); + businessRole: membership.businessRole, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; + const items = await listEnquiryProjectParties(id, organization.id, accessContext); return NextResponse.json({ success: true, diff --git a/src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts b/src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts index 1b38555..3175b1f 100644 --- a/src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts +++ b/src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts @@ -26,13 +26,19 @@ export async function PATCH(request: NextRequest, { params }: Params) { permission: PERMISSIONS.crmEnquiryFollowupUpdate }); const payload = followupRequestSchema.parse(await request.json()); + const accessContext = { + organizationId: organization.id, + userId: session.user.id, + membershipRole: membership.role, + businessRole: membership.businessRole, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; const before = ( - await listEnquiryFollowups(id, organization.id, { - organizationId: organization.id, - userId: session.user.id, - membershipRole: membership.role, - businessRole: membership.businessRole - }) + await listEnquiryFollowups(id, organization.id, accessContext) ).find( (item) => item.id === followupId ); @@ -47,12 +53,7 @@ export async function PATCH(request: NextRequest, { params }: Params) { organization.id, session.user.id, payload, - { - organizationId: organization.id, - userId: session.user.id, - membershipRole: membership.role, - businessRole: membership.businessRole - } + accessContext ); await auditUpdate({ @@ -95,13 +96,19 @@ export async function DELETE(_request: NextRequest, { params }: Params) { const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmEnquiryFollowupDelete }); + const accessContext = { + organizationId: organization.id, + userId: session.user.id, + membershipRole: membership.role, + businessRole: membership.businessRole, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; const before = ( - await listEnquiryFollowups(id, organization.id, { - organizationId: organization.id, - userId: session.user.id, - membershipRole: membership.role, - businessRole: membership.businessRole - }) + await listEnquiryFollowups(id, organization.id, accessContext) ).find( (item) => item.id === followupId ); @@ -115,12 +122,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) { followupId, organization.id, session.user.id, - { - organizationId: organization.id, - userId: session.user.id, - membershipRole: membership.role, - businessRole: membership.businessRole - } + accessContext ); await auditDelete({ diff --git a/src/app/api/crm/enquiries/[id]/followups/route.ts b/src/app/api/crm/enquiries/[id]/followups/route.ts index ae9894d..21de1d0 100644 --- a/src/app/api/crm/enquiries/[id]/followups/route.ts +++ b/src/app/api/crm/enquiries/[id]/followups/route.ts @@ -24,12 +24,18 @@ export async function GET(_request: NextRequest, { params }: Params) { const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmEnquiryFollowupRead }); - const items = await listEnquiryFollowups(id, organization.id, { + const accessContext = { organizationId: organization.id, userId: session.user.id, membershipRole: membership.role, - businessRole: membership.businessRole - }); + businessRole: membership.businessRole, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; + const items = await listEnquiryFollowups(id, organization.id, accessContext); return NextResponse.json({ success: true, @@ -57,17 +63,23 @@ export async function POST(request: NextRequest, { params }: Params) { permission: PERMISSIONS.crmEnquiryFollowupCreate }); const payload = followupRequestSchema.parse(await request.json()); + const accessContext = { + organizationId: organization.id, + userId: session.user.id, + membershipRole: membership.role, + businessRole: membership.businessRole, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; const created = await createEnquiryFollowup( id, organization.id, session.user.id, payload, - { - organizationId: organization.id, - userId: session.user.id, - membershipRole: membership.role, - businessRole: membership.businessRole - } + accessContext ); await auditCreate({ diff --git a/src/app/api/crm/enquiries/[id]/reassign/route.ts b/src/app/api/crm/enquiries/[id]/reassign/route.ts index fec49c6..5f006a9 100644 --- a/src/app/api/crm/enquiries/[id]/reassign/route.ts +++ b/src/app/api/crm/enquiries/[id]/reassign/route.ts @@ -17,13 +17,25 @@ export async function POST(request: NextRequest, { params }: Params) { permission: PERMISSIONS.crmEnquiryReassign }); const payload = enquiryAssignmentRequestSchema.parse(await request.json()); - const before = await getEnquiryDetail(id, organization.id, { + const accessContext = { organizationId: organization.id, userId: session.user.id, membershipRole: membership.role, - businessRole: membership.businessRole - }); - const updated = await reassignEnquiryToUser(id, organization.id, session.user.id, payload); + businessRole: membership.businessRole, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; + const before = await getEnquiryDetail(id, organization.id, accessContext); + const updated = await reassignEnquiryToUser( + id, + organization.id, + session.user.id, + payload, + accessContext + ); await auditAction({ organizationId: organization.id, diff --git a/src/app/api/crm/enquiries/[id]/route.ts b/src/app/api/crm/enquiries/[id]/route.ts index 698f45e..dd17bc3 100644 --- a/src/app/api/crm/enquiries/[id]/route.ts +++ b/src/app/api/crm/enquiries/[id]/route.ts @@ -32,7 +32,12 @@ export async function GET(_request: NextRequest, { params }: Params) { organizationId: organization.id, userId: session.user.id, membershipRole: membership.role, - businessRole: membership.businessRole + businessRole: membership.businessRole, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; const [enquiry, activity] = await Promise.all([ getEnquiryDetail(id, organization.id, accessContext), @@ -66,18 +71,19 @@ export async function PATCH(request: NextRequest, { params }: Params) { permission: PERMISSIONS.crmEnquiryUpdate }); const payload = enquiryRequestSchema.parse(await request.json()); - const before = await getEnquiryDetail(id, organization.id, { + const accessContext = { organizationId: organization.id, userId: session.user.id, membershipRole: membership.role, - businessRole: membership.businessRole - }); - const updated = await updateEnquiry(id, organization.id, session.user.id, payload, { - organizationId: organization.id, - userId: session.user.id, - membershipRole: membership.role, - businessRole: membership.businessRole - }); + businessRole: membership.businessRole, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; + const before = await getEnquiryDetail(id, organization.id, accessContext); + const updated = await updateEnquiry(id, organization.id, session.user.id, payload, accessContext); await auditUpdate({ organizationId: organization.id, @@ -120,18 +126,19 @@ export async function DELETE(_request: NextRequest, { params }: Params) { const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmEnquiryDelete }); - const before = await getEnquiryDetail(id, organization.id, { + const accessContext = { organizationId: organization.id, userId: session.user.id, membershipRole: membership.role, - businessRole: membership.businessRole - }); - const updated = await softDeleteEnquiry(id, organization.id, session.user.id, { - organizationId: organization.id, - userId: session.user.id, - membershipRole: membership.role, - businessRole: membership.businessRole - }); + businessRole: membership.businessRole, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; + const before = await getEnquiryDetail(id, organization.id, accessContext); + const updated = await softDeleteEnquiry(id, organization.id, session.user.id, accessContext); await auditDelete({ organizationId: organization.id, diff --git a/src/app/api/crm/enquiries/route.ts b/src/app/api/crm/enquiries/route.ts index 056d70b..2a99457 100644 --- a/src/app/api/crm/enquiries/route.ts +++ b/src/app/api/crm/enquiries/route.ts @@ -29,13 +29,19 @@ export async function GET(request: NextRequest) { const branch = searchParams.get('branch') ?? undefined; const customer = searchParams.get('customer') ?? undefined; const sort = searchParams.get('sort') ?? undefined; + const accessContext = { + organizationId: organization.id, + userId: session.user.id, + membershipRole: membership.role, + businessRole: membership.businessRole, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; const result = await listEnquiries( - { - organizationId: organization.id, - userId: session.user.id, - membershipRole: membership.role, - businessRole: membership.businessRole - }, + accessContext, { page, limit, @@ -79,10 +85,21 @@ export async function POST(request: NextRequest) { permission: PERMISSIONS.crmEnquiryCreate }); const payload = enquiryRequestSchema.parse(await request.json()); + const accessContext = { + organizationId: organization.id, + userId: session.user.id, + membershipRole: membership.role, + businessRole: membership.businessRole, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; const created = await createEnquiry( organization.id, session.user.id, - membership.businessRole, + accessContext, payload ); diff --git a/src/app/api/crm/settings/roles/[code]/clone/route.ts b/src/app/api/crm/settings/roles/[code]/clone/route.ts new file mode 100644 index 0000000..32ed653 --- /dev/null +++ b/src/app/api/crm/settings/roles/[code]/clone/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { cloneCrmRoleProfile } from '@/features/foundation/role-management/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +const cloneRoleSchema = z.object({ + code: z.string().min(2), + name: z.string().min(2), + description: z.string().nullable().optional() +}); + +type RouteProps = { + params: Promise<{ code: string }>; +}; + +export async function POST(request: NextRequest, props: RouteProps) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmRoleCreate + }); + const payload = cloneRoleSchema.parse(await request.json()); + const { code } = await props.params; + const role = await cloneCrmRoleProfile(organization.id, session.user.id, code, payload); + + return NextResponse.json( + { + success: true, + message: 'CRM role cloned successfully', + role + }, + { 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 }); + } + + return NextResponse.json({ message: 'Unable to clone CRM role' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/settings/roles/[code]/route.ts b/src/app/api/crm/settings/roles/[code]/route.ts new file mode 100644 index 0000000..85825b5 --- /dev/null +++ b/src/app/api/crm/settings/roles/[code]/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { updateCrmRoleProfile } from '@/features/foundation/role-management/server/service'; +import { + APPROVAL_AUTHORITIES, + ALL_PERMISSIONS, + OWNERSHIP_SCOPES, + PERMISSIONS, + SCOPE_MODES, + type Permission +} from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +const updateRoleSchema = z.object({ + name: z.string().min(2), + description: z.string().nullable().optional(), + permissions: z + .array(z.string()) + .refine( + (values) => values.every((value) => ALL_PERMISSIONS.includes(value as Permission)), + 'Invalid permission key' + ) + .transform((values) => values as Permission[]), + branchScopeMode: z.enum(SCOPE_MODES), + productScopeMode: z.enum(SCOPE_MODES), + ownershipScope: z.enum(OWNERSHIP_SCOPES), + approvalAuthority: z.enum(APPROVAL_AUTHORITIES), + isActive: z.boolean() +}); + +type RouteProps = { + params: Promise<{ code: string }>; +}; + +export async function PATCH(request: NextRequest, props: RouteProps) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmRoleUpdate + }); + const payload = updateRoleSchema.parse(await request.json()); + const { code } = await props.params; + const role = await updateCrmRoleProfile(organization.id, session.user.id, code, payload); + + return NextResponse.json({ + success: true, + message: 'CRM role updated successfully', + role + }); + } 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 }); + } + + return NextResponse.json({ message: 'Unable to update CRM role' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/settings/roles/route.ts b/src/app/api/crm/settings/roles/route.ts new file mode 100644 index 0000000..e425368 --- /dev/null +++ b/src/app/api/crm/settings/roles/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { listCrmRoleProfiles } from '@/features/foundation/role-management/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET() { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmRoleRead + }); + const result = await listCrmRoleProfiles(organization.id, session.user.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'CRM roles loaded successfully', + ...result + }); + } 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 request' }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load CRM roles' }, { status: 500 }); + } +} diff --git a/src/app/dashboard/crm/leads/[id]/page.tsx b/src/app/dashboard/crm/leads/[id]/page.tsx index ce3d6d5..0e92b70 100644 --- a/src/app/dashboard/crm/leads/[id]/page.tsx +++ b/src/app/dashboard/crm/leads/[id]/page.tsx @@ -45,7 +45,12 @@ export default async function LeadDetailRoute({ params }: PageProps) { organizationId: session.user.activeOrganizationId, userId: session.user.id, membershipRole: session.user.activeMembershipRole ?? 'user', - businessRole: session.user.activeBusinessRole ?? 'sales_support' + businessRole: session.user.activeBusinessRole ?? 'sales_support', + branchScopeIds: session.user.activeBranchScopeIds ?? [], + productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [], + ownershipScope: session.user.activeOwnershipScope ?? 'organization', + branchScopeMode: 'all', + productScopeMode: 'all' }) : null; diff --git a/src/app/dashboard/crm/settings/master-options/page.tsx b/src/app/dashboard/crm/settings/master-options/page.tsx index 5a35fa7..abb89a8 100644 --- a/src/app/dashboard/crm/settings/master-options/page.tsx +++ b/src/app/dashboard/crm/settings/master-options/page.tsx @@ -16,7 +16,7 @@ export default async function MasterOptionsRoute(props: PageProps) { session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.organizationManage))); + session.user.activePermissions.includes(PERMISSIONS.crmMasterOptionRead))); searchParamsCache.parse(searchParams); diff --git a/src/app/dashboard/crm/settings/roles/page.tsx b/src/app/dashboard/crm/settings/roles/page.tsx new file mode 100644 index 0000000..b9d8969 --- /dev/null +++ b/src/app/dashboard/crm/settings/roles/page.tsx @@ -0,0 +1,39 @@ +import { auth } from '@/auth'; +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import PageContainer from '@/components/layout/page-container'; +import { RoleManagementPage } from '@/features/foundation/role-management/components/role-management-page'; +import { crmRoleProfilesQueryOptions } from '@/features/foundation/role-management/api/queries'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { getQueryClient } from '@/lib/query-client'; + +export default async function CrmRolesRoute() { + const session = await auth(); + const canRead = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + session.user.activePermissions.includes(PERMISSIONS.crmRoleRead)); + const queryClient = getQueryClient(); + + if (canRead) { + void queryClient.prefetchQuery(crmRoleProfilesQueryOptions()); + } + + return ( + + You do not have access to CRM role management. + + } + > + {canRead ? ( + + + + ) : null} + + ); +} diff --git a/src/auth.ts b/src/auth.ts index 1dafa0c..e063799 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -6,10 +6,9 @@ import { z } from 'zod'; import { memberships, organizations, users } from '@/db/schema'; import { getDefaultBusinessRole, - getDefaultPermissions, - isBusinessRole, isSystemRole } from '@/lib/auth/rbac'; +import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access'; import { db } from '@/lib/db'; const signInSchema = z.object({ @@ -28,6 +27,9 @@ type SessionOrganization = { slug: string; role: string; businessRole: string; + branchScopeIds: string[]; + productTypeScopeIds: string[]; + ownershipScope: string; plan: string; imageUrl: string | null; }; @@ -47,6 +49,8 @@ async function buildUserAccessContext(userId: string) { role: memberships.role, businessRole: memberships.businessRole, permissions: memberships.permissions, + branchScopeIds: memberships.branchScopeIds, + productTypeScopeIds: memberships.productTypeScopeIds, name: organizations.name, slug: organizations.slug, plan: organizations.plan, @@ -67,18 +71,10 @@ async function buildUserAccessContext(userId: string) { return { organizationId: organization.id, role: membership?.role ?? 'admin', - businessRole: - membership?.businessRole && isBusinessRole(membership.businessRole) - ? membership.businessRole - : getDefaultBusinessRole('admin'), - permissions: - membership?.permissions ?? - getDefaultPermissions( - membership?.role === 'admin' ? 'admin' : 'user', - membership?.businessRole && isBusinessRole(membership.businessRole) - ? membership.businessRole - : getDefaultBusinessRole('admin') - ), + businessRole: membership?.businessRole ?? getDefaultBusinessRole('admin'), + permissions: membership?.permissions ?? [], + branchScopeIds: membership?.branchScopeIds ?? [], + productTypeScopeIds: membership?.productTypeScopeIds ?? [], name: organization.name, slug: organization.slug, plan: organization.plan, @@ -97,20 +93,64 @@ async function buildUserAccessContext(userId: string) { .where(eq(users.id, dbUser.id)); } - const sessionOrganizations: SessionOrganization[] = rows.map((row) => ({ - id: row.organizationId, - name: row.name, - slug: row.slug, - role: row.role, - businessRole: row.businessRole, - plan: row.plan, - imageUrl: row.imageUrl - })); + const sessionOrganizations: SessionOrganization[] = []; + + for (const row of rows) { + const resolvedAccess = await resolveCrmMembershipAccess({ + systemRole, + organizationId: row.organizationId, + membership: { + id: '', + userId: dbUser.id, + organizationId: row.organizationId, + role: row.role, + businessRole: row.businessRole, + permissions: row.permissions, + branchScopeIds: row.branchScopeIds ?? [], + productTypeScopeIds: row.productTypeScopeIds ?? [], + createdAt: new Date(), + updatedAt: new Date() + } + }); + + sessionOrganizations.push({ + id: row.organizationId, + name: row.name, + slug: row.slug, + role: row.role, + businessRole: row.businessRole, + branchScopeIds: resolvedAccess.branchScopeIds, + productTypeScopeIds: resolvedAccess.productTypeScopeIds, + ownershipScope: resolvedAccess.ownershipScope, + plan: row.plan, + imageUrl: row.imageUrl + }); + } + + const activeMembershipAccess = activeMembership + ? await resolveCrmMembershipAccess({ + systemRole, + organizationId: activeMembership.organizationId, + membership: { + id: '', + userId: dbUser.id, + organizationId: activeMembership.organizationId, + role: activeMembership.role, + businessRole: activeMembership.businessRole, + permissions: activeMembership.permissions, + branchScopeIds: activeMembership.branchScopeIds ?? [], + productTypeScopeIds: activeMembership.productTypeScopeIds ?? [], + createdAt: new Date(), + updatedAt: new Date() + } + }) + : null; return { dbUser, sessionOrganizations, - activeMembership + activeMembership, + activeMembershipAccess }; } @@ -177,7 +217,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ return token; } - const { dbUser, sessionOrganizations, activeMembership } = accessContext; + const { dbUser, sessionOrganizations, activeMembership, activeMembershipAccess } = accessContext; const systemRole = isSystemRole(dbUser.systemRole) ? dbUser.systemRole : 'user'; token.name = dbUser.name; @@ -189,7 +229,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ token.activeOrganizationPlan = activeMembership?.plan ?? null; token.activeMembershipRole = activeMembership?.role ?? null; token.activeBusinessRole = activeMembership?.businessRole ?? null; - token.activePermissions = activeMembership?.permissions ?? []; + token.activePermissions = activeMembershipAccess?.permissions ?? []; + token.activeBranchScopeIds = activeMembershipAccess?.branchScopeIds ?? []; + token.activeProductTypeScopeIds = activeMembershipAccess?.productTypeScopeIds ?? []; + token.activeOwnershipScope = activeMembershipAccess?.ownershipScope ?? null; token.organizations = sessionOrganizations; return token; @@ -217,6 +260,14 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ session.user.activePermissions = Array.isArray(token.activePermissions) ? token.activePermissions.filter((value): value is string => typeof value === 'string') : []; + session.user.activeBranchScopeIds = Array.isArray(token.activeBranchScopeIds) + ? token.activeBranchScopeIds.filter((value): value is string => typeof value === 'string') + : []; + session.user.activeProductTypeScopeIds = Array.isArray(token.activeProductTypeScopeIds) + ? token.activeProductTypeScopeIds.filter((value): value is string => typeof value === 'string') + : []; + session.user.activeOwnershipScope = + typeof token.activeOwnershipScope === 'string' ? token.activeOwnershipScope : null; session.user.organizations = Array.isArray(token.organizations) ? token.organizations.filter( (value): value is SessionOrganization => @@ -227,6 +278,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ typeof value.slug === 'string' && typeof value.role === 'string' && typeof value.businessRole === 'string' && + Array.isArray(value.branchScopeIds) && + Array.isArray(value.productTypeScopeIds) && + typeof value.ownershipScope === 'string' && typeof value.plan === 'string' ) : []; diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index 71c9e57..8199012 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -59,7 +59,7 @@ export const navGroups: NavGroup[] = [ icon: "teams", isActive: false, items: [], - access: { requireOrg: true, role: "admin" }, + access: { requireOrg: true, permission: "crm.quotation.read" }, }, { title: "Users", @@ -136,12 +136,17 @@ export const navGroups: NavGroup[] = [ url: "/dashboard/crm/settings/master-options", icon: "settings", isActive: false, - access: { requireOrg: true, permission: "crm.document_template.read" }, + access: { requireOrg: true, permission: "crm.role.read" }, items: [ + { + title: "Roles & Permissions", + url: "/dashboard/crm/settings/roles", + access: { requireOrg: true, permission: "crm.role.read" }, + }, { title: "Master Options", url: "/dashboard/crm/settings/master-options", - access: { requireOrg: true, permission: "organization:manage" }, + access: { requireOrg: true, permission: "crm.master_option.read" }, }, { title: "Document Sequences", diff --git a/src/db/schema.ts b/src/db/schema.ts index d8995b7..bd5c883 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -51,10 +51,41 @@ export const memberships = pgTable('memberships', { role: text('role').default('user').notNull(), businessRole: text('business_role').default('sales_support').notNull(), permissions: text('permissions').array().default([]).notNull(), + branchScopeIds: text('branch_scope_ids').array().default([]).notNull(), + productTypeScopeIds: text('product_type_scope_ids').array().default([]).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() }); +export const crmRoleProfiles = pgTable( + 'crm_role_profiles', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + code: text('code').notNull(), + name: text('name').notNull(), + description: text('description'), + permissions: text('permissions').array().default([]).notNull(), + branchScopeMode: text('branch_scope_mode').default('assigned').notNull(), + productScopeMode: text('product_scope_mode').default('assigned').notNull(), + ownershipScope: text('ownership_scope').default('own').notNull(), + approvalAuthority: text('approval_authority').default('none').notNull(), + isSystem: boolean('is_system').default(false).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) => ({ + organizationRoleCodeIdx: uniqueIndex('crm_role_profiles_org_code_idx').on( + table.organizationId, + table.code + ) + }) +); + export const products = pgTable('products', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), organizationId: text('organization_id').notNull(), diff --git a/src/features/crm/dashboard/server/service.ts b/src/features/crm/dashboard/server/service.ts index 459f839..2ee1b2b 100644 --- a/src/features/crm/dashboard/server/service.ts +++ b/src/features/crm/dashboard/server/service.ts @@ -22,6 +22,7 @@ import { import { db } from '@/lib/db'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { getUserBranches } from '@/features/foundation/branch-scope/service'; +import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access'; import { listApprovalRequests } from '@/features/foundation/approval/server/service'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import { @@ -57,6 +58,11 @@ type DashboardContext = { membershipRole: string; businessRole: string; permissions: string[]; + branchScopeIds: string[]; + productTypeScopeIds: string[]; + ownershipScope: string; + branchScopeMode: string; + productScopeMode: string; }; type NormalizedFilters = { @@ -73,15 +79,6 @@ type StatusMaps = { quotationStatusById: Map; }; -const DASHBOARD_FULL_ACCESS_ROLES = new Set([ - 'marketing', - 'sales_manager', - 'department_manager', - 'top_manager' -]); - -const DASHBOARD_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']); - function normalizeDashboardFilters(filters: CrmDashboardFilters): NormalizedFilters { const now = new Date(); const monthStart = startOfDay(new Date(now.getFullYear(), now.getMonth(), 1)); @@ -128,20 +125,26 @@ function canViewApprovalData(context: DashboardContext) { } function hasDashboardFullScope(context: DashboardContext) { - return ( - context.membershipRole === 'admin' || DASHBOARD_FULL_ACCESS_ROLES.has(context.businessRole) - ); + return context.membershipRole === 'admin' || context.ownershipScope !== 'own'; } function canAccessDashboardEnquiry( row: typeof crmEnquiries.$inferSelect, context: DashboardContext ) { + if (!hasBranchScopeAccess(context, row.branchId)) { + return false; + } + + if (!hasProductScopeAccess(context, row.productType)) { + return false; + } + if (hasDashboardFullScope(context)) { return true; } - if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) { + if (context.ownershipScope === 'own') { return row.createdBy === context.userId || row.assignedToUserId === context.userId; } @@ -152,11 +155,19 @@ function canAccessDashboardQuotation( row: typeof crmQuotations.$inferSelect, context: DashboardContext ) { + if (!hasBranchScopeAccess(context, row.branchId)) { + return false; + } + + if (!hasProductScopeAccess(context, row.quotationType)) { + return false; + } + if (hasDashboardFullScope(context)) { return true; } - if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) { + if (context.ownershipScope === 'own') { return row.salesmanId === context.userId; } diff --git a/src/features/crm/enquiries/server/service.ts b/src/features/crm/enquiries/server/service.ts index bfd773b..053000d 100644 --- a/src/features/crm/enquiries/server/service.ts +++ b/src/features/crm/enquiries/server/service.ts @@ -11,6 +11,7 @@ import { } from '@/db/schema'; import { db } from '@/lib/db'; import { AuthError } from '@/lib/auth/session'; +import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access'; import { listAuditLogs } from '@/features/foundation/audit-log/service'; import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service'; import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service'; @@ -50,13 +51,6 @@ const ENQUIRY_OPTION_CATEGORIES = { } as const; const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']); -const SALES_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']); -const ENQUIRY_FULL_ACCESS_ROLES = new Set([ - 'marketing', - 'sales_manager', - 'department_manager', - 'top_manager' -]); const SALES_CREATED_ENQUIRY_ROLES = new Set([ 'sales', 'sales_support', @@ -73,6 +67,11 @@ export interface EnquiryAccessContext { userId: string; membershipRole: string; businessRole: string; + branchScopeIds: string[]; + productTypeScopeIds: string[]; + ownershipScope: string; + branchScopeMode: string; + productScopeMode: string; } function mapOption( @@ -141,20 +140,26 @@ function mapEnquiryRecord( } function hasEnquiryFullAccess(context: EnquiryAccessContext) { - return ( - context.membershipRole === 'admin' || ENQUIRY_FULL_ACCESS_ROLES.has(context.businessRole) - ); + return context.membershipRole === 'admin' || context.ownershipScope !== 'own'; } function canAccessEnquiryRow( row: typeof crmEnquiries.$inferSelect, context: EnquiryAccessContext ) { + if (!hasBranchScopeAccess(context, row.branchId)) { + return false; + } + + if (!hasProductScopeAccess(context, row.productType)) { + return false; + } + if (hasEnquiryFullAccess(context)) { return true; } - if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) { + if (context.ownershipScope === 'own') { return row.createdBy === context.userId || row.assignedToUserId === context.userId; } @@ -162,17 +167,23 @@ function canAccessEnquiryRow( } function buildAccessScopedFilters(context: EnquiryAccessContext): SQL[] { - if (hasEnquiryFullAccess(context)) { - return []; + const filters: SQL[] = []; + + if (context.branchScopeMode === 'assigned' && context.branchScopeIds.length > 0) { + filters.push(inArray(crmEnquiries.branchId, context.branchScopeIds)); } - if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) { - return [ + if (context.productScopeMode === 'assigned' && context.productTypeScopeIds.length > 0) { + filters.push(inArray(crmEnquiries.productType, context.productTypeScopeIds)); + } + + if (!hasEnquiryFullAccess(context) && context.ownershipScope === 'own') { + filters.push( or(eq(crmEnquiries.createdBy, context.userId), eq(crmEnquiries.assignedToUserId, context.userId))! - ]; + ); } - return []; + return filters; } function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord { @@ -437,7 +448,11 @@ async function assertFollowupBelongsToEnquiry( return followup; } -async function validateEnquiryPayload(organizationId: string, payload: EnquiryMutationPayload) { +async function validateEnquiryPayload( + organizationId: string, + payload: EnquiryMutationPayload, + accessContext?: EnquiryAccessContext +) { await assertCustomerBelongsToOrganization(payload.customerId, organizationId); if (payload.contactId) { @@ -465,6 +480,14 @@ async function validateEnquiryPayload(organizationId: string, payload: EnquiryMu await validateBranchAccess(payload.branchId); } + if (accessContext && !hasBranchScopeAccess(accessContext, payload.branchId)) { + throw new AuthError('Forbidden branch scope', 403); + } + + if (accessContext && !hasProductScopeAccess(accessContext, payload.productType)) { + throw new AuthError('Forbidden product scope', 403); + } + for (const projectParty of payload.projectParties ?? []) { await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId); await assertMasterOptionValue( @@ -996,13 +1019,13 @@ export async function getEnquiryActivity( export async function createEnquiry( organizationId: string, userId: string, - businessRole: string, + accessContext: EnquiryAccessContext, payload: EnquiryMutationPayload ) { - await validateEnquiryPayload(organizationId, payload); + await validateEnquiryPayload(organizationId, payload, accessContext); const pipelineStage = await resolvePipelineStageForCreate( organizationId, - businessRole, + accessContext.businessRole, payload.status ); @@ -1064,7 +1087,7 @@ export async function updateEnquiry( payload: EnquiryMutationPayload, accessContext?: EnquiryAccessContext ) { - await validateEnquiryPayload(organizationId, payload); + await validateEnquiryPayload(organizationId, payload, accessContext); const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload); @@ -1167,9 +1190,10 @@ async function updateEnquiryAssignment( organizationId: string, userId: string, payload: EnquiryAssignmentMutationPayload, - mode: 'assign' | 'reassign' + mode: 'assign' | 'reassign', + accessContext?: EnquiryAccessContext ) { - const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId); + const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); if (mode === 'assign' && enquiry.assignedToUserId) { throw new AuthError('Enquiry is already assigned. Use reassign instead.', 400); @@ -1202,18 +1226,20 @@ export async function assignEnquiryToUser( id: string, organizationId: string, userId: string, - payload: EnquiryAssignmentMutationPayload + payload: EnquiryAssignmentMutationPayload, + accessContext?: EnquiryAccessContext ) { - return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign'); + return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign', accessContext); } export async function reassignEnquiryToUser( id: string, organizationId: string, userId: string, - payload: EnquiryAssignmentMutationPayload + payload: EnquiryAssignmentMutationPayload, + accessContext?: EnquiryAccessContext ) { - return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign'); + return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign', accessContext); } export async function listEnquiryFollowups( diff --git a/src/features/foundation/role-management/api/mutations.ts b/src/features/foundation/role-management/api/mutations.ts new file mode 100644 index 0000000..6e35a84 --- /dev/null +++ b/src/features/foundation/role-management/api/mutations.ts @@ -0,0 +1,30 @@ +import { mutationOptions } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; +import { cloneCrmRoleProfile, updateCrmRoleProfile } from './service'; +import { crmRoleQueryKeys } from './queries'; + +export const updateCrmRoleProfileMutation = mutationOptions({ + mutationFn: ({ + code, + payload + }: { + code: string; + payload: Parameters[1]; + }) => updateCrmRoleProfile(code, payload), + onSettled: async () => { + await getQueryClient().invalidateQueries({ queryKey: crmRoleQueryKeys.all }); + } +}); + +export const cloneCrmRoleProfileMutation = mutationOptions({ + mutationFn: ({ + sourceCode, + payload + }: { + sourceCode: string; + payload: Parameters[1]; + }) => cloneCrmRoleProfile(sourceCode, payload), + onSettled: async () => { + await getQueryClient().invalidateQueries({ queryKey: crmRoleQueryKeys.all }); + } +}); diff --git a/src/features/foundation/role-management/api/queries.ts b/src/features/foundation/role-management/api/queries.ts new file mode 100644 index 0000000..c9cd9b5 --- /dev/null +++ b/src/features/foundation/role-management/api/queries.ts @@ -0,0 +1,13 @@ +import { queryOptions } from '@tanstack/react-query'; +import { getCrmRoleProfiles } from './service'; + +export const crmRoleQueryKeys = { + all: ['crm-role-profiles'] as const +}; + +export function crmRoleProfilesQueryOptions() { + return queryOptions({ + queryKey: crmRoleQueryKeys.all, + queryFn: getCrmRoleProfiles + }); +} diff --git a/src/features/foundation/role-management/api/service.ts b/src/features/foundation/role-management/api/service.ts new file mode 100644 index 0000000..efb774b --- /dev/null +++ b/src/features/foundation/role-management/api/service.ts @@ -0,0 +1,28 @@ +import { apiClient } from '@/lib/api-client'; +import type { + CloneCrmRoleProfilePayload, + CrmRoleProfileRecord, + CrmRoleProfilesResponse, + UpdateCrmRoleProfilePayload +} from './types'; + +export async function getCrmRoleProfiles() { + return apiClient('/crm/settings/roles'); +} + +export async function updateCrmRoleProfile(code: string, payload: UpdateCrmRoleProfilePayload) { + return apiClient<{ success: boolean; role: CrmRoleProfileRecord }>(`/crm/settings/roles/${code}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }); +} + +export async function cloneCrmRoleProfile(sourceCode: string, payload: CloneCrmRoleProfilePayload) { + return apiClient<{ success: boolean; role: CrmRoleProfileRecord }>( + `/crm/settings/roles/${sourceCode}/clone`, + { + method: 'POST', + body: JSON.stringify(payload) + } + ); +} diff --git a/src/features/foundation/role-management/api/types.ts b/src/features/foundation/role-management/api/types.ts new file mode 100644 index 0000000..e5ee99b --- /dev/null +++ b/src/features/foundation/role-management/api/types.ts @@ -0,0 +1,57 @@ +import type { + CrmApprovalAuthority, + CrmOwnershipScope, + CrmScopeMode, + Permission +} from '@/lib/auth/rbac'; + +export interface CrmRoleProfileRecord { + id: string; + organizationId: string; + code: string; + name: string; + description: string | null; + permissions: Permission[]; + branchScopeMode: CrmScopeMode; + productScopeMode: CrmScopeMode; + ownershipScope: CrmOwnershipScope; + approvalAuthority: CrmApprovalAuthority; + isSystem: boolean; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface PermissionGroupDto { + key: string; + label: string; + permissions: Array<{ + key: Permission; + label: string; + }>; +} + +export interface CrmRoleProfilesResponse { + success: boolean; + time: string; + message: string; + roles: CrmRoleProfileRecord[]; + permissionGroups: PermissionGroupDto[]; +} + +export interface UpdateCrmRoleProfilePayload { + name: string; + description?: string | null; + permissions: Permission[]; + branchScopeMode: CrmScopeMode; + productScopeMode: CrmScopeMode; + ownershipScope: CrmOwnershipScope; + approvalAuthority: CrmApprovalAuthority; + isActive: boolean; +} + +export interface CloneCrmRoleProfilePayload { + code: string; + name: string; + description?: string | null; +} diff --git a/src/features/foundation/role-management/components/role-management-page.tsx b/src/features/foundation/role-management/components/role-management-page.tsx new file mode 100644 index 0000000..4353837 --- /dev/null +++ b/src/features/foundation/role-management/components/role-management-page.tsx @@ -0,0 +1,391 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { + APPROVAL_AUTHORITIES, + OWNERSHIP_SCOPES, + SCOPE_MODES, + type Permission +} from '@/lib/auth/rbac'; +import { cloneCrmRoleProfileMutation, updateCrmRoleProfileMutation } from '../api/mutations'; +import { crmRoleProfilesQueryOptions } from '../api/queries'; + +type RoleEditorState = { + name: string; + description: string; + permissions: Permission[]; + branchScopeMode: (typeof SCOPE_MODES)[number]; + productScopeMode: (typeof SCOPE_MODES)[number]; + ownershipScope: (typeof OWNERSHIP_SCOPES)[number]; + approvalAuthority: (typeof APPROVAL_AUTHORITIES)[number]; + isActive: boolean; +}; + +export function RoleManagementPage() { + const { data } = useSuspenseQuery(crmRoleProfilesQueryOptions()); + const [selectedCode, setSelectedCode] = useState(data.roles[0]?.code ?? null); + const [cloneCode, setCloneCode] = useState(''); + const [cloneName, setCloneName] = useState(''); + const [state, setState] = useState(null); + const selectedRole = useMemo( + () => data.roles.find((role) => role.code === selectedCode) ?? null, + [data.roles, selectedCode] + ); + + useEffect(() => { + if (!selectedRole) { + setState(null); + return; + } + + setState({ + name: selectedRole.name, + description: selectedRole.description ?? '', + permissions: selectedRole.permissions, + branchScopeMode: selectedRole.branchScopeMode, + productScopeMode: selectedRole.productScopeMode, + ownershipScope: selectedRole.ownershipScope, + approvalAuthority: selectedRole.approvalAuthority, + isActive: selectedRole.isActive + }); + }, [selectedRole]); + + const updateMutation = useMutation({ + ...updateCrmRoleProfileMutation, + onSuccess: () => toast.success('CRM role updated successfully'), + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Unable to update CRM role') + }); + + const cloneMutation = useMutation({ + ...cloneCrmRoleProfileMutation, + onSuccess: (result) => { + toast.success('CRM role cloned successfully'); + setSelectedCode(result.role.code); + setCloneCode(''); + setCloneName(''); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Unable to clone CRM role') + }); + + if (!selectedRole || !state) { + return ( + + No CRM roles available. + + ); + } + + const currentRole = selectedRole; + const editorState = state; + + function togglePermission(permission: Permission, checked: boolean) { + setState((current) => + current + ? { + ...current, + permissions: checked + ? [...new Set([...current.permissions, permission])] + : current.permissions.filter((item) => item !== permission) + } + : current + ); + } + + async function handleSave() { + await updateMutation.mutateAsync({ + code: currentRole.code, + payload: { + ...editorState, + description: editorState.description || null + } + }); + } + + async function handleClone() { + if (!cloneCode.trim() || !cloneName.trim()) { + toast.error('Clone code and clone name are required'); + return; + } + + await cloneMutation.mutateAsync({ + sourceCode: currentRole.code, + payload: { + code: cloneCode, + name: cloneName, + description: editorState.description || null + } + }); + } + + return ( + + + + Roles + System and cloned CRM role profiles for this organization. + + + {data.roles.map((role) => ( + setSelectedCode(role.code)} + className={`w-full rounded-lg border p-3 text-left transition ${ + role.code === selectedCode ? 'border-primary bg-primary/5' : 'hover:bg-muted/40' + }`} + > + + {role.name} + + {role.isSystem ? System : null} + {!role.isActive ? Inactive : null} + + + {role.code} + + {role.description || 'No description'} + + + ))} + + + + + + + {currentRole.name} + + Configure permissions, ownership visibility, branch scope mode, and approval authority. + + + + + + Role Name + setState({ ...state, name: event.target.value })} + /> + + + Role Code + + + + + + Description + setState({ ...state, description: event.target.value })} + /> + + + + + Ownership Scope + + setState({ ...state, ownershipScope: value as RoleEditorState['ownershipScope'] }) + } + > + + + + + {OWNERSHIP_SCOPES.map((value) => ( + + {value} + + ))} + + + + + Branch Scope + + setState({ ...state, branchScopeMode: value as RoleEditorState['branchScopeMode'] }) + } + > + + + + + {SCOPE_MODES.map((value) => ( + + {value} + + ))} + + + + + Product Scope + + setState({ + ...state, + productScopeMode: value as RoleEditorState['productScopeMode'] + }) + } + > + + + + + {SCOPE_MODES.map((value) => ( + + {value} + + ))} + + + + + Approval Authority + + setState({ + ...state, + approvalAuthority: value as RoleEditorState['approvalAuthority'] + }) + } + > + + + + + {APPROVAL_AUTHORITIES.map((value) => ( + + {value} + + ))} + + + + + + + setState({ ...state, isActive: checked === true })} + /> + + Role active + + Inactive roles remain historical but should not be used for new assignments. + + + + + + + Permission Matrix + + Effective permissions are role permissions plus membership-level overrides. + + + + {data.permissionGroups.map((group) => ( + + + {group.label} + + + {group.permissions.map((permission) => { + const checked = editorState.permissions.includes(permission.key); + + return ( + + + togglePermission(permission.key, value === true) + } + /> + + {permission.label} + {permission.key} + + + ); + })} + + + ))} + + + + + void handleSave()} isLoading={updateMutation.isPending}> + Save Role + + + + + + + + Clone Role + + Create a custom role profile by copying the current role and then tailoring its permissions. + + + + + New Role Code + setCloneCode(event.target.value)} + placeholder='sales_crane_manager' + /> + + + New Role Name + setCloneName(event.target.value)} + placeholder='Sales Crane Manager' + /> + + + void handleClone()} isLoading={cloneMutation.isPending}> + Clone + + + + + + + ); +} diff --git a/src/features/foundation/role-management/server/service.ts b/src/features/foundation/role-management/server/service.ts new file mode 100644 index 0000000..13d4862 --- /dev/null +++ b/src/features/foundation/role-management/server/service.ts @@ -0,0 +1,252 @@ +import { and, asc, eq, isNull } from 'drizzle-orm'; +import { crmRoleProfiles } from '@/db/schema'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { + CRM_PERMISSION_GROUPS, + getDefaultRoleProfiles, + isApprovalAuthority, + isOwnershipScope, + isScopeMode, + type Permission +} from '@/lib/auth/rbac'; +import { db } from '@/lib/db'; +import { AuthError } from '@/lib/auth/session'; +import type { + CloneCrmRoleProfilePayload, + CrmRoleProfileRecord, + UpdateCrmRoleProfilePayload +} from '../api/types'; + +function mapRole(row: typeof crmRoleProfiles.$inferSelect): CrmRoleProfileRecord { + return { + id: row.id, + organizationId: row.organizationId, + code: row.code, + name: row.name, + description: row.description, + permissions: row.permissions as Permission[], + branchScopeMode: isScopeMode(row.branchScopeMode) ? row.branchScopeMode : 'assigned', + productScopeMode: isScopeMode(row.productScopeMode) ? row.productScopeMode : 'assigned', + ownershipScope: isOwnershipScope(row.ownershipScope) ? row.ownershipScope : 'own', + approvalAuthority: isApprovalAuthority(row.approvalAuthority) + ? row.approvalAuthority + : 'none', + isSystem: row.isSystem, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString() + }; +} + +function sanitizeCode(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, '_') + .replace(/^_+|_+$/g, ''); +} + +export async function ensureCrmRoleProfiles( + organizationId: string, + userId: string +) { + const existingRows = await db + .select({ code: crmRoleProfiles.code }) + .from(crmRoleProfiles) + .where( + and( + eq(crmRoleProfiles.organizationId, organizationId), + isNull(crmRoleProfiles.deletedAt) + ) + ); + const existingCodes = new Set(existingRows.map((row) => row.code)); + const missingProfiles = getDefaultRoleProfiles().filter( + (profile) => !existingCodes.has(profile.code) + ); + + if (!missingProfiles.length) { + return; + } + + await db.insert(crmRoleProfiles).values( + missingProfiles.map((profile) => ({ + id: crypto.randomUUID(), + organizationId, + code: profile.code, + name: profile.name, + description: profile.description, + permissions: profile.permissions, + branchScopeMode: profile.branchScopeMode, + productScopeMode: profile.productScopeMode, + ownershipScope: profile.ownershipScope, + approvalAuthority: profile.approvalAuthority, + isSystem: profile.isSystem, + isActive: true, + createdBy: userId, + updatedBy: userId + })) + ); +} + +export async function listCrmRoleProfiles( + organizationId: string, + userId: string +) { + await ensureCrmRoleProfiles(organizationId, userId); + + const rows = await db + .select() + .from(crmRoleProfiles) + .where( + and( + eq(crmRoleProfiles.organizationId, organizationId), + isNull(crmRoleProfiles.deletedAt) + ) + ) + .orderBy(asc(crmRoleProfiles.isSystem), asc(crmRoleProfiles.name)); + + return { + roles: rows.map(mapRole), + permissionGroups: CRM_PERMISSION_GROUPS + }; +} + +export async function getCrmRoleProfileByCode( + organizationId: string, + userId: string, + code: string +) { + await ensureCrmRoleProfiles(organizationId, userId); + const row = await db.query.crmRoleProfiles.findFirst({ + where: and( + eq(crmRoleProfiles.organizationId, organizationId), + eq(crmRoleProfiles.code, code), + isNull(crmRoleProfiles.deletedAt) + ) + }); + + if (!row) { + throw new AuthError('CRM role not found', 404); + } + + return mapRole(row); +} + +export async function updateCrmRoleProfile( + organizationId: string, + userId: string, + code: string, + payload: UpdateCrmRoleProfilePayload +) { + const current = await db.query.crmRoleProfiles.findFirst({ + where: and( + eq(crmRoleProfiles.organizationId, organizationId), + eq(crmRoleProfiles.code, code), + isNull(crmRoleProfiles.deletedAt) + ) + }); + + if (!current) { + throw new AuthError('CRM role not found', 404); + } + + const [updated] = await db + .update(crmRoleProfiles) + .set({ + name: payload.name.trim(), + description: payload.description?.trim() || null, + permissions: payload.permissions, + branchScopeMode: payload.branchScopeMode, + productScopeMode: payload.productScopeMode, + ownershipScope: payload.ownershipScope, + approvalAuthority: payload.approvalAuthority, + isActive: payload.isActive, + updatedAt: new Date(), + updatedBy: userId + }) + .where(eq(crmRoleProfiles.id, current.id)) + .returning(); + + await auditAction({ + organizationId, + userId, + entityType: 'crm_role', + entityId: updated.id, + action: 'update', + beforeData: mapRole(current), + afterData: mapRole(updated) + }); + + return mapRole(updated); +} + +export async function cloneCrmRoleProfile( + organizationId: string, + userId: string, + sourceCode: string, + payload: CloneCrmRoleProfilePayload +) { + const source = await db.query.crmRoleProfiles.findFirst({ + where: and( + eq(crmRoleProfiles.organizationId, organizationId), + eq(crmRoleProfiles.code, sourceCode), + isNull(crmRoleProfiles.deletedAt) + ) + }); + + if (!source) { + throw new AuthError('Source CRM role not found', 404); + } + + const targetCode = sanitizeCode(payload.code); + + if (!targetCode) { + throw new AuthError('Role code is required', 400); + } + + const duplicate = await db.query.crmRoleProfiles.findFirst({ + where: and( + eq(crmRoleProfiles.organizationId, organizationId), + eq(crmRoleProfiles.code, targetCode), + isNull(crmRoleProfiles.deletedAt) + ) + }); + + if (duplicate) { + throw new AuthError('Role code already exists', 409); + } + + const [created] = await db + .insert(crmRoleProfiles) + .values({ + id: crypto.randomUUID(), + organizationId, + code: targetCode, + name: payload.name.trim(), + description: payload.description?.trim() || source.description, + permissions: source.permissions, + branchScopeMode: source.branchScopeMode, + productScopeMode: source.productScopeMode, + ownershipScope: source.ownershipScope, + approvalAuthority: source.approvalAuthority, + isSystem: false, + isActive: true, + createdBy: userId, + updatedBy: userId + }) + .returning(); + + await auditAction({ + organizationId, + userId, + entityType: 'crm_role', + entityId: created.id, + action: 'create', + afterData: { + sourceCode, + role: mapRole(created) + } + }); + + return mapRole(created); +} diff --git a/src/features/users/components/user-form-sheet.tsx b/src/features/users/components/user-form-sheet.tsx index 6ecc14b..234c2ee 100644 --- a/src/features/users/components/user-form-sheet.tsx +++ b/src/features/users/components/user-form-sheet.tsx @@ -52,7 +52,8 @@ const businessRoleOptions = [ { value: 'sales', label: 'Sales' }, { value: 'sales_support', label: 'Sales Support' }, { value: 'department_manager', label: 'Department Manager' }, - { value: 'top_manager', label: 'Top Manager' } + { value: 'top_manager', label: 'Top Manager' }, + { value: 'crm_admin', label: 'CRM Admin' } ] as const; export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) { diff --git a/src/features/users/schemas/user.ts b/src/features/users/schemas/user.ts index 24f819a..a595772 100644 --- a/src/features/users/schemas/user.ts +++ b/src/features/users/schemas/user.ts @@ -1,6 +1,4 @@ import * as z from 'zod'; -import { BUSINESS_ROLES } from '@/lib/auth/rbac'; - export const userSchema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters'), email: z.string().email('Please enter a valid email'), @@ -11,7 +9,7 @@ export const userSchema = z.object({ z.object({ organizationId: z.string().min(1), membershipRole: z.enum(['admin', 'user']), - businessRole: z.enum(BUSINESS_ROLES) + businessRole: z.string().min(1) }) ) .min(1, 'Select at least one organization') diff --git a/src/lib/auth/crm-access.ts b/src/lib/auth/crm-access.ts new file mode 100644 index 0000000..7b37a03 --- /dev/null +++ b/src/lib/auth/crm-access.ts @@ -0,0 +1,151 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import { crmRoleProfiles, memberships } from '@/db/schema'; +import { db } from '@/lib/db'; +import { + ALL_PERMISSIONS, + type CrmApprovalAuthority, + type CrmOwnershipScope, + type CrmScopeMode, + getDefaultBusinessRole, + getRoleProfileDefinition, + isApprovalAuthority, + isMembershipRole, + isOwnershipScope, + isScopeMode, + resolveEffectivePermissions, + type MembershipRole, + type Permission, + type SystemRole +} from './rbac'; + +export interface ResolvedCrmAccess { + membershipRole: MembershipRole; + businessRole: string; + permissions: Permission[]; + branchScopeIds: string[]; + productTypeScopeIds: string[]; + ownershipScope: CrmOwnershipScope; + branchScopeMode: CrmScopeMode; + productScopeMode: CrmScopeMode; + approvalAuthority: CrmApprovalAuthority; + roleProfileName: string | null; +} + +type BranchScopedAccess = Pick< + ResolvedCrmAccess, + 'branchScopeMode' | 'branchScopeIds' +> | { + branchScopeMode: string; + branchScopeIds: string[]; +}; + +type ProductScopedAccess = Pick< + ResolvedCrmAccess, + 'productScopeMode' | 'productTypeScopeIds' +> | { + productScopeMode: string; + productTypeScopeIds: string[]; +}; + +function normalizeStringArray(value: unknown) { + return Array.isArray(value) + ? [...new Set(value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0))] + : []; +} + +export async function resolveCrmMembershipAccess(input: { + systemRole: SystemRole; + organizationId: string; + membership: typeof memberships.$inferSelect; +}): Promise { + if (input.systemRole === 'super_admin') { + return { + membershipRole: 'admin', + businessRole: input.membership.businessRole, + permissions: ALL_PERMISSIONS, + branchScopeIds: [], + productTypeScopeIds: [], + ownershipScope: 'organization', + branchScopeMode: 'all', + productScopeMode: 'all', + approvalAuthority: 'final', + roleProfileName: 'System Admin' + }; + } + + const membershipRole = isMembershipRole(input.membership.role) + ? input.membership.role + : 'user'; + const businessRole = input.membership.businessRole || getDefaultBusinessRole(membershipRole); + const [roleProfile] = await db + .select() + .from(crmRoleProfiles) + .where( + and( + eq(crmRoleProfiles.organizationId, input.organizationId), + eq(crmRoleProfiles.code, businessRole), + eq(crmRoleProfiles.isActive, true), + isNull(crmRoleProfiles.deletedAt) + ) + ) + .limit(1); + const defaultDefinition = getRoleProfileDefinition(businessRole); + + const ownershipScope: CrmOwnershipScope = isOwnershipScope(roleProfile?.ownershipScope ?? '') + ? (roleProfile.ownershipScope as CrmOwnershipScope) + : (defaultDefinition?.ownershipScope ?? 'own'); + const branchScopeMode: CrmScopeMode = isScopeMode(roleProfile?.branchScopeMode ?? '') + ? (roleProfile.branchScopeMode as CrmScopeMode) + : (defaultDefinition?.branchScopeMode ?? 'assigned'); + const productScopeMode: CrmScopeMode = isScopeMode(roleProfile?.productScopeMode ?? '') + ? (roleProfile.productScopeMode as CrmScopeMode) + : (defaultDefinition?.productScopeMode ?? 'assigned'); + const approvalAuthority: CrmApprovalAuthority = isApprovalAuthority( + roleProfile?.approvalAuthority ?? '' + ) + ? (roleProfile.approvalAuthority as CrmApprovalAuthority) + : (defaultDefinition?.approvalAuthority ?? 'none'); + + return { + membershipRole, + businessRole, + permissions: resolveEffectivePermissions({ + systemRole: input.systemRole, + membershipRole, + businessRole, + rolePermissions: roleProfile?.permissions ?? defaultDefinition?.permissions ?? [], + directPermissions: input.membership.permissions + }), + branchScopeIds: normalizeStringArray(input.membership.branchScopeIds), + productTypeScopeIds: normalizeStringArray(input.membership.productTypeScopeIds), + ownershipScope, + branchScopeMode, + productScopeMode, + approvalAuthority, + roleProfileName: roleProfile?.name ?? defaultDefinition?.name ?? null + }; +} + +export function hasBranchScopeAccess(access: BranchScopedAccess, branchId?: string | null) { + if (!branchId) { + return true; + } + + if (access.branchScopeMode === 'all' || access.branchScopeIds.length === 0) { + return true; + } + + return access.branchScopeIds.includes(branchId); +} + +export function hasProductScopeAccess(access: ProductScopedAccess, productTypeId?: string | null) { + if (!productTypeId) { + return true; + } + + if (access.productScopeMode === 'all' || access.productTypeScopeIds.length === 0) { + return true; + } + + return access.productTypeScopeIds.includes(productTypeId); +} diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index e81799c..25d8e31 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -6,12 +6,19 @@ export const BUSINESS_ROLES = [ 'sales_support', 'sales_manager', 'department_manager', - 'top_manager' + 'top_manager', + 'crm_admin' ] as const; +export const OWNERSHIP_SCOPES = ['own', 'team', 'organization', 'monitor'] as const; +export const SCOPE_MODES = ['all', 'assigned'] as const; +export const APPROVAL_AUTHORITIES = ['none', 'manager', 'department', 'final'] as const; export type SystemRole = (typeof SYSTEM_ROLES)[number]; export type MembershipRole = (typeof MEMBERSHIP_ROLES)[number]; -export type BusinessRole = (typeof BUSINESS_ROLES)[number]; +export type BusinessRole = string; +export type CrmOwnershipScope = (typeof OWNERSHIP_SCOPES)[number]; +export type CrmScopeMode = (typeof SCOPE_MODES)[number]; +export type CrmApprovalAuthority = (typeof APPROVAL_AUTHORITIES)[number]; export const PERMISSIONS = { productsRead: 'products:read', @@ -52,6 +59,7 @@ export const PERMISSIONS = { crmQuotationFollowupManage: 'crm.quotation.followup.manage', crmQuotationAttachmentManage: 'crm.quotation.attachment.manage', crmQuotationRevisionCreate: 'crm.quotation.revision.create', + crmQuotationPricingRead: 'crm.quotation.pricing.read', crmApprovalRead: 'crm.approval.read', crmApprovalSubmit: 'crm.approval.submit', crmApprovalApprove: 'crm.approval.approve', @@ -77,106 +85,262 @@ export const PERMISSIONS = { crmQuotationDocumentPreview: 'crm.quotation.document.preview', crmQuotationPdfPreview: 'crm.quotation.pdf.preview', crmQuotationPdfDownload: 'crm.quotation.pdf.download', - crmQuotationPdfGenerateApproved: 'crm.quotation.pdf.generate_approved' + crmQuotationPdfGenerateApproved: 'crm.quotation.pdf.generate_approved', + crmRoleRead: 'crm.role.read', + crmRoleCreate: 'crm.role.create', + crmRoleUpdate: 'crm.role.update', + crmRoleDelete: 'crm.role.delete', + crmRoleAssign: 'crm.role.assign', + crmMasterOptionRead: 'crm.master_option.read', + crmMasterOptionCreate: 'crm.master_option.create', + crmMasterOptionUpdate: 'crm.master_option.update', + crmMasterOptionDelete: 'crm.master_option.delete' } as const; export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; -const MANAGER_PERMISSIONS: Permission[] = [ - PERMISSIONS.crmLeadRead, - PERMISSIONS.crmLeadCreate, - PERMISSIONS.crmLeadUpdate, - PERMISSIONS.crmLeadAssign, - PERMISSIONS.crmLeadDelete, - PERMISSIONS.crmCustomerRead, - PERMISSIONS.crmCustomerCreate, - PERMISSIONS.crmCustomerUpdate, - PERMISSIONS.crmContactRead, - PERMISSIONS.crmContactCreate, - PERMISSIONS.crmContactUpdate, - PERMISSIONS.crmEnquiryRead, - PERMISSIONS.crmEnquiryCreate, - PERMISSIONS.crmEnquiryUpdate, - PERMISSIONS.crmEnquiryAssign, - PERMISSIONS.crmEnquiryReassign, - PERMISSIONS.crmEnquiryFollowupRead, - PERMISSIONS.crmEnquiryFollowupCreate, - PERMISSIONS.crmEnquiryFollowupUpdate, - PERMISSIONS.crmQuotationRead, - PERMISSIONS.crmQuotationCreate, - PERMISSIONS.crmQuotationUpdate, - PERMISSIONS.crmQuotationItemManage, - PERMISSIONS.crmQuotationCustomerManage, - PERMISSIONS.crmQuotationTopicManage, - PERMISSIONS.crmQuotationFollowupManage, - PERMISSIONS.crmQuotationAttachmentManage, - PERMISSIONS.crmQuotationRevisionCreate, - PERMISSIONS.crmApprovalRead, - PERMISSIONS.crmApprovalSubmit, - PERMISSIONS.crmApprovalWorkflowRead, - PERMISSIONS.crmDashboardRead, - PERMISSIONS.crmDashboardExport, - PERMISSIONS.crmDocumentTemplateRead, - PERMISSIONS.crmDocumentSequenceRead, - PERMISSIONS.crmDocumentArtifactRead, - PERMISSIONS.crmDocumentArtifactDownload, - PERMISSIONS.crmDocumentArtifactVoid, - PERMISSIONS.crmQuotationDocumentPreview, - PERMISSIONS.crmQuotationPdfPreview, - PERMISSIONS.crmQuotationPdfDownload -]; +export interface CrmRoleProfileDefinition { + code: string; + name: string; + description: string; + permissions: Permission[]; + ownershipScope: CrmOwnershipScope; + branchScopeMode: CrmScopeMode; + productScopeMode: CrmScopeMode; + approvalAuthority: CrmApprovalAuthority; + isSystem: boolean; +} -function getMembershipBasePermissions(role: MembershipRole): Permission[] { - if (role === 'admin') { - return [ - PERMISSIONS.productsRead, - PERMISSIONS.productsWrite, - PERMISSIONS.organizationManage, - PERMISSIONS.usersManage, +export interface PermissionGroup { + key: string; + label: string; + permissions: Array<{ key: Permission; label: string }>; +} + +const DEFAULT_ROLE_DEFINITIONS: Record> = { + marketing: { + name: 'Marketing', + description: 'Lead creation, assignment, monitoring, and enquiry visibility without commercial authority.', + permissions: [ PERMISSIONS.crmLeadRead, PERMISSIONS.crmLeadCreate, PERMISSIONS.crmLeadUpdate, PERMISSIONS.crmLeadAssign, - PERMISSIONS.crmLeadDelete, PERMISSIONS.crmCustomerRead, - PERMISSIONS.crmCustomerCreate, - PERMISSIONS.crmCustomerUpdate, - PERMISSIONS.crmCustomerDelete, PERMISSIONS.crmContactRead, - PERMISSIONS.crmContactCreate, - PERMISSIONS.crmContactUpdate, - PERMISSIONS.crmContactDelete, + PERMISSIONS.crmEnquiryRead, + PERMISSIONS.crmEnquiryFollowupRead, + PERMISSIONS.crmDashboardRead + ], + ownershipScope: 'monitor', + branchScopeMode: 'assigned', + productScopeMode: 'assigned', + approvalAuthority: 'none', + isSystem: true + }, + sales: { + name: 'Sales', + description: 'Own enquiries, quotations, and follow-ups within assigned branch and product scope.', + permissions: [ + PERMISSIONS.crmCustomerRead, + PERMISSIONS.crmContactRead, PERMISSIONS.crmEnquiryRead, PERMISSIONS.crmEnquiryCreate, PERMISSIONS.crmEnquiryUpdate, - PERMISSIONS.crmEnquiryDelete, - PERMISSIONS.crmEnquiryAssign, - PERMISSIONS.crmEnquiryReassign, PERMISSIONS.crmEnquiryFollowupRead, PERMISSIONS.crmEnquiryFollowupCreate, PERMISSIONS.crmEnquiryFollowupUpdate, - PERMISSIONS.crmEnquiryFollowupDelete, PERMISSIONS.crmQuotationRead, PERMISSIONS.crmQuotationCreate, PERMISSIONS.crmQuotationUpdate, - PERMISSIONS.crmQuotationDelete, PERMISSIONS.crmQuotationItemManage, PERMISSIONS.crmQuotationCustomerManage, PERMISSIONS.crmQuotationTopicManage, PERMISSIONS.crmQuotationFollowupManage, PERMISSIONS.crmQuotationAttachmentManage, PERMISSIONS.crmQuotationRevisionCreate, + PERMISSIONS.crmQuotationPricingRead, + PERMISSIONS.crmApprovalRead, + PERMISSIONS.crmApprovalSubmit, + PERMISSIONS.crmDashboardRead, + PERMISSIONS.crmDocumentArtifactRead, + PERMISSIONS.crmDocumentArtifactDownload, + PERMISSIONS.crmQuotationDocumentPreview, + PERMISSIONS.crmQuotationPdfPreview, + PERMISSIONS.crmQuotationPdfDownload + ], + ownershipScope: 'own', + branchScopeMode: 'assigned', + productScopeMode: 'assigned', + approvalAuthority: 'none', + isSystem: true + }, + sales_support: { + name: 'Sales Support', + description: 'Quotation drafting and operational support without approval authority.', + permissions: [ + PERMISSIONS.crmCustomerRead, + PERMISSIONS.crmContactRead, + PERMISSIONS.crmEnquiryRead, + PERMISSIONS.crmEnquiryUpdate, + PERMISSIONS.crmEnquiryFollowupRead, + PERMISSIONS.crmEnquiryFollowupCreate, + PERMISSIONS.crmEnquiryFollowupUpdate, + PERMISSIONS.crmQuotationRead, + PERMISSIONS.crmQuotationCreate, + PERMISSIONS.crmQuotationUpdate, + PERMISSIONS.crmQuotationItemManage, + PERMISSIONS.crmQuotationCustomerManage, + PERMISSIONS.crmQuotationTopicManage, + PERMISSIONS.crmQuotationFollowupManage, + PERMISSIONS.crmQuotationAttachmentManage, + PERMISSIONS.crmQuotationRevisionCreate, + PERMISSIONS.crmQuotationPricingRead, + PERMISSIONS.crmApprovalRead, + PERMISSIONS.crmDashboardRead, + PERMISSIONS.crmDocumentArtifactRead, + PERMISSIONS.crmDocumentArtifactDownload, + PERMISSIONS.crmQuotationDocumentPreview, + PERMISSIONS.crmQuotationPdfPreview, + PERMISSIONS.crmQuotationPdfDownload + ], + ownershipScope: 'own', + branchScopeMode: 'assigned', + productScopeMode: 'assigned', + approvalAuthority: 'none', + isSystem: true + }, + sales_manager: { + name: 'Sales Manager', + description: 'Team visibility, sales assignment, and quotation approval authority.', + permissions: [ + PERMISSIONS.crmLeadRead, + PERMISSIONS.crmLeadCreate, + PERMISSIONS.crmLeadUpdate, + PERMISSIONS.crmLeadAssign, + PERMISSIONS.crmCustomerRead, + PERMISSIONS.crmCustomerCreate, + PERMISSIONS.crmCustomerUpdate, + PERMISSIONS.crmContactRead, + PERMISSIONS.crmContactCreate, + PERMISSIONS.crmContactUpdate, + PERMISSIONS.crmEnquiryRead, + PERMISSIONS.crmEnquiryCreate, + PERMISSIONS.crmEnquiryUpdate, + PERMISSIONS.crmEnquiryAssign, + PERMISSIONS.crmEnquiryReassign, + PERMISSIONS.crmEnquiryFollowupRead, + PERMISSIONS.crmEnquiryFollowupCreate, + PERMISSIONS.crmEnquiryFollowupUpdate, + PERMISSIONS.crmQuotationRead, + PERMISSIONS.crmQuotationCreate, + PERMISSIONS.crmQuotationUpdate, + PERMISSIONS.crmQuotationItemManage, + PERMISSIONS.crmQuotationCustomerManage, + PERMISSIONS.crmQuotationTopicManage, + PERMISSIONS.crmQuotationFollowupManage, + PERMISSIONS.crmQuotationAttachmentManage, + PERMISSIONS.crmQuotationRevisionCreate, + PERMISSIONS.crmQuotationPricingRead, PERMISSIONS.crmApprovalRead, PERMISSIONS.crmApprovalSubmit, PERMISSIONS.crmApprovalApprove, PERMISSIONS.crmApprovalReject, PERMISSIONS.crmApprovalReturn, + PERMISSIONS.crmDashboardRead, + PERMISSIONS.crmDashboardExport, + PERMISSIONS.crmDocumentArtifactRead, + PERMISSIONS.crmDocumentArtifactDownload, + PERMISSIONS.crmQuotationDocumentPreview, + PERMISSIONS.crmQuotationPdfPreview, + PERMISSIONS.crmQuotationPdfDownload + ], + ownershipScope: 'team', + branchScopeMode: 'assigned', + productScopeMode: 'assigned', + approvalAuthority: 'manager', + isSystem: true + }, + department_manager: { + name: 'Department Manager', + description: 'Department analytics and higher-level approval authority.', + permissions: [ + PERMISSIONS.crmLeadRead, + PERMISSIONS.crmCustomerRead, + PERMISSIONS.crmContactRead, + PERMISSIONS.crmEnquiryRead, + PERMISSIONS.crmEnquiryAssign, + PERMISSIONS.crmEnquiryReassign, + PERMISSIONS.crmEnquiryFollowupRead, + PERMISSIONS.crmQuotationRead, + PERMISSIONS.crmQuotationPricingRead, + PERMISSIONS.crmApprovalRead, + PERMISSIONS.crmApprovalApprove, + PERMISSIONS.crmApprovalReject, + PERMISSIONS.crmApprovalReturn, + PERMISSIONS.crmDashboardRead, + PERMISSIONS.crmDashboardExport, + PERMISSIONS.crmDocumentArtifactRead, + PERMISSIONS.crmDocumentArtifactDownload, + PERMISSIONS.crmQuotationDocumentPreview, + PERMISSIONS.crmQuotationPdfPreview, + PERMISSIONS.crmQuotationPdfDownload + ], + ownershipScope: 'organization', + branchScopeMode: 'assigned', + productScopeMode: 'assigned', + approvalAuthority: 'department', + isSystem: true + }, + top_manager: { + name: 'Top Manager', + description: 'Final approval authority with full CRM analytics visibility.', + permissions: [ + PERMISSIONS.crmLeadRead, + PERMISSIONS.crmCustomerRead, + PERMISSIONS.crmContactRead, + PERMISSIONS.crmEnquiryRead, + PERMISSIONS.crmEnquiryAssign, + PERMISSIONS.crmEnquiryReassign, + PERMISSIONS.crmEnquiryFollowupRead, + PERMISSIONS.crmQuotationRead, + PERMISSIONS.crmQuotationPricingRead, + PERMISSIONS.crmApprovalRead, + PERMISSIONS.crmApprovalApprove, + PERMISSIONS.crmApprovalReject, + PERMISSIONS.crmApprovalReturn, + PERMISSIONS.crmDashboardRead, + PERMISSIONS.crmDashboardExport, + PERMISSIONS.crmDocumentArtifactRead, + PERMISSIONS.crmDocumentArtifactDownload, + PERMISSIONS.crmQuotationDocumentPreview, + PERMISSIONS.crmQuotationPdfPreview, + PERMISSIONS.crmQuotationPdfDownload + ], + ownershipScope: 'organization', + branchScopeMode: 'all', + productScopeMode: 'all', + approvalAuthority: 'final', + isSystem: true + }, + crm_admin: { + name: 'CRM Admin', + description: 'CRM configuration authority for templates, workflows, sequences, and permissions.', + permissions: [ + PERMISSIONS.crmDashboardRead, + PERMISSIONS.crmRoleRead, + PERMISSIONS.crmRoleCreate, + PERMISSIONS.crmRoleUpdate, + PERMISSIONS.crmRoleDelete, + PERMISSIONS.crmRoleAssign, + PERMISSIONS.crmMasterOptionRead, + PERMISSIONS.crmMasterOptionCreate, + PERMISSIONS.crmMasterOptionUpdate, + PERMISSIONS.crmMasterOptionDelete, PERMISSIONS.crmApprovalWorkflowRead, PERMISSIONS.crmApprovalWorkflowCreate, PERMISSIONS.crmApprovalWorkflowUpdate, PERMISSIONS.crmApprovalWorkflowDelete, - PERMISSIONS.crmDashboardRead, - PERMISSIONS.crmDashboardExport, PERMISSIONS.crmDocumentTemplateRead, PERMISSIONS.crmDocumentTemplateCreate, PERMISSIONS.crmDocumentTemplateUpdate, @@ -192,116 +356,163 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] { PERMISSIONS.crmQuotationPdfPreview, PERMISSIONS.crmQuotationPdfDownload, PERMISSIONS.crmQuotationPdfGenerateApproved + ], + ownershipScope: 'organization', + branchScopeMode: 'all', + productScopeMode: 'all', + approvalAuthority: 'none', + isSystem: true + } +}; + +export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [ + { + key: 'lead', + label: 'Leads', + permissions: [ + { key: PERMISSIONS.crmLeadRead, label: 'Read leads' }, + { key: PERMISSIONS.crmLeadCreate, label: 'Create leads' }, + { key: PERMISSIONS.crmLeadUpdate, label: 'Update leads' }, + { key: PERMISSIONS.crmLeadAssign, label: 'Assign leads' }, + { key: PERMISSIONS.crmLeadDelete, label: 'Delete leads' } + ] + }, + { + key: 'enquiry', + label: 'Enquiries', + permissions: [ + { key: PERMISSIONS.crmEnquiryRead, label: 'Read enquiries' }, + { key: PERMISSIONS.crmEnquiryCreate, label: 'Create enquiries' }, + { key: PERMISSIONS.crmEnquiryUpdate, label: 'Update enquiries' }, + { key: PERMISSIONS.crmEnquiryDelete, label: 'Delete enquiries' }, + { key: PERMISSIONS.crmEnquiryAssign, label: 'Assign enquiries' }, + { key: PERMISSIONS.crmEnquiryReassign, label: 'Reassign enquiries' }, + { key: PERMISSIONS.crmEnquiryFollowupRead, label: 'Read enquiry follow-ups' }, + { key: PERMISSIONS.crmEnquiryFollowupCreate, label: 'Create enquiry follow-ups' }, + { key: PERMISSIONS.crmEnquiryFollowupUpdate, label: 'Update enquiry follow-ups' }, + { key: PERMISSIONS.crmEnquiryFollowupDelete, label: 'Delete enquiry follow-ups' } + ] + }, + { + key: 'quotation', + label: 'Quotations', + permissions: [ + { key: PERMISSIONS.crmQuotationRead, label: 'Read quotations' }, + { key: PERMISSIONS.crmQuotationCreate, label: 'Create quotations' }, + { key: PERMISSIONS.crmQuotationUpdate, label: 'Update quotations' }, + { key: PERMISSIONS.crmQuotationDelete, label: 'Delete quotations' }, + { key: PERMISSIONS.crmQuotationPricingRead, label: 'Read quotation pricing' }, + { key: PERMISSIONS.crmQuotationItemManage, label: 'Manage quotation items' }, + { key: PERMISSIONS.crmQuotationCustomerManage, label: 'Manage quotation customers' }, + { key: PERMISSIONS.crmQuotationTopicManage, label: 'Manage quotation topics' }, + { key: PERMISSIONS.crmQuotationFollowupManage, label: 'Manage quotation follow-ups' }, + { key: PERMISSIONS.crmQuotationAttachmentManage, label: 'Manage quotation attachments' }, + { key: PERMISSIONS.crmQuotationRevisionCreate, label: 'Create quotation revisions' } + ] + }, + { + key: 'approval', + label: 'Approvals', + permissions: [ + { key: PERMISSIONS.crmApprovalRead, label: 'Read approvals' }, + { key: PERMISSIONS.crmApprovalSubmit, label: 'Submit approvals' }, + { key: PERMISSIONS.crmApprovalApprove, label: 'Approve quotations' }, + { key: PERMISSIONS.crmApprovalReject, label: 'Reject quotations' }, + { key: PERMISSIONS.crmApprovalReturn, label: 'Return quotations' } + ] + }, + { + key: 'dashboard', + label: 'Dashboard', + permissions: [ + { key: PERMISSIONS.crmDashboardRead, label: 'Read dashboard' }, + { key: PERMISSIONS.crmDashboardExport, label: 'Export dashboard' } + ] + }, + { + key: 'settings', + label: 'CRM Settings', + permissions: [ + { key: PERMISSIONS.crmRoleRead, label: 'Read roles' }, + { key: PERMISSIONS.crmRoleCreate, label: 'Create roles' }, + { key: PERMISSIONS.crmRoleUpdate, label: 'Update roles' }, + { key: PERMISSIONS.crmRoleDelete, label: 'Delete roles' }, + { key: PERMISSIONS.crmRoleAssign, label: 'Assign role scopes' }, + { key: PERMISSIONS.crmMasterOptionRead, label: 'Read master options' }, + { key: PERMISSIONS.crmMasterOptionCreate, label: 'Create master options' }, + { key: PERMISSIONS.crmMasterOptionUpdate, label: 'Update master options' }, + { key: PERMISSIONS.crmMasterOptionDelete, label: 'Delete master options' }, + { key: PERMISSIONS.crmApprovalWorkflowRead, label: 'Read approval workflows' }, + { key: PERMISSIONS.crmApprovalWorkflowCreate, label: 'Create approval workflows' }, + { key: PERMISSIONS.crmApprovalWorkflowUpdate, label: 'Update approval workflows' }, + { key: PERMISSIONS.crmApprovalWorkflowDelete, label: 'Delete approval workflows' }, + { key: PERMISSIONS.crmDocumentTemplateRead, label: 'Read document templates' }, + { key: PERMISSIONS.crmDocumentTemplateCreate, label: 'Create document templates' }, + { key: PERMISSIONS.crmDocumentTemplateUpdate, label: 'Update document templates' }, + { key: PERMISSIONS.crmDocumentTemplateDelete, label: 'Delete document templates' }, + { key: PERMISSIONS.crmDocumentSequenceRead, label: 'Read document sequences' }, + { key: PERMISSIONS.crmDocumentSequenceCreate, label: 'Create document sequences' }, + { key: PERMISSIONS.crmDocumentSequenceUpdate, label: 'Update document sequences' }, + { key: PERMISSIONS.crmDocumentSequenceReset, label: 'Reset document sequences' } + ] + }, + { + key: 'documents', + label: 'Documents', + permissions: [ + { key: PERMISSIONS.crmDocumentArtifactRead, label: 'Read approved artifacts' }, + { key: PERMISSIONS.crmDocumentArtifactDownload, label: 'Download approved artifacts' }, + { key: PERMISSIONS.crmDocumentArtifactVoid, label: 'Void approved artifacts' }, + { key: PERMISSIONS.crmQuotationDocumentPreview, label: 'Preview quotation document' }, + { key: PERMISSIONS.crmQuotationPdfPreview, label: 'Preview quotation PDF' }, + { key: PERMISSIONS.crmQuotationPdfDownload, label: 'Download quotation PDF' }, + { key: PERMISSIONS.crmQuotationPdfGenerateApproved, label: 'Generate approved quotation PDF' } + ] + } +]; + +export const ALL_PERMISSIONS = Object.values(PERMISSIONS) as Permission[]; + +function getMembershipBasePermissions(role: MembershipRole): Permission[] { + if (role === 'admin') { + return [ + PERMISSIONS.productsRead, + PERMISSIONS.productsWrite, + PERMISSIONS.organizationManage, + PERMISSIONS.usersManage ]; } - return [ - PERMISSIONS.productsRead, - PERMISSIONS.crmLeadRead, - PERMISSIONS.crmCustomerRead, - PERMISSIONS.crmContactRead, - PERMISSIONS.crmEnquiryRead, - PERMISSIONS.crmEnquiryFollowupRead, - PERMISSIONS.crmQuotationRead, - PERMISSIONS.crmApprovalRead, - PERMISSIONS.crmApprovalWorkflowRead, - PERMISSIONS.crmDashboardRead, - PERMISSIONS.crmDocumentTemplateRead, - PERMISSIONS.crmDocumentSequenceRead, - PERMISSIONS.crmDocumentArtifactRead, - PERMISSIONS.crmDocumentArtifactDownload, - PERMISSIONS.crmQuotationDocumentPreview, - PERMISSIONS.crmQuotationPdfPreview, - PERMISSIONS.crmQuotationPdfDownload - ]; + return [PERMISSIONS.productsRead]; +} + +export function getRoleProfileDefinition(roleCode: string): CrmRoleProfileDefinition | null { + const definition = DEFAULT_ROLE_DEFINITIONS[roleCode]; + + if (!definition) { + return null; + } + + return { + code: roleCode, + ...definition + }; +} + +export function getDefaultRoleProfiles(): CrmRoleProfileDefinition[] { + return BUSINESS_ROLES.map((roleCode) => { + const definition = getRoleProfileDefinition(roleCode); + + if (!definition) { + throw new Error(`Missing default role definition for ${roleCode}`); + } + + return definition; + }); } export function getBusinessRolePermissions(role: BusinessRole): Permission[] { - switch (role) { - case 'marketing': - return [ - PERMISSIONS.crmLeadRead, - PERMISSIONS.crmLeadCreate, - PERMISSIONS.crmLeadUpdate, - PERMISSIONS.crmLeadAssign, - PERMISSIONS.crmLeadDelete, - PERMISSIONS.crmCustomerRead, - PERMISSIONS.crmContactRead, - PERMISSIONS.crmEnquiryRead, - PERMISSIONS.crmEnquiryCreate, - PERMISSIONS.crmEnquiryAssign, - PERMISSIONS.crmEnquiryFollowupRead, - PERMISSIONS.crmDashboardRead - ]; - case 'sales_manager': - return MANAGER_PERMISSIONS; - case 'sales': - return [ - PERMISSIONS.crmCustomerRead, - PERMISSIONS.crmContactRead, - PERMISSIONS.crmEnquiryRead, - PERMISSIONS.crmEnquiryCreate, - PERMISSIONS.crmEnquiryUpdate, - PERMISSIONS.crmEnquiryFollowupRead, - PERMISSIONS.crmEnquiryFollowupCreate, - PERMISSIONS.crmEnquiryFollowupUpdate, - PERMISSIONS.crmQuotationRead, - PERMISSIONS.crmQuotationCreate, - PERMISSIONS.crmQuotationUpdate, - PERMISSIONS.crmQuotationItemManage, - PERMISSIONS.crmQuotationCustomerManage, - PERMISSIONS.crmQuotationTopicManage, - PERMISSIONS.crmQuotationFollowupManage, - PERMISSIONS.crmQuotationAttachmentManage, - PERMISSIONS.crmQuotationRevisionCreate, - PERMISSIONS.crmApprovalRead, - PERMISSIONS.crmApprovalSubmit, - PERMISSIONS.crmApprovalWorkflowRead, - PERMISSIONS.crmDashboardRead, - PERMISSIONS.crmDocumentTemplateRead, - PERMISSIONS.crmDocumentSequenceRead, - PERMISSIONS.crmDocumentArtifactRead, - PERMISSIONS.crmDocumentArtifactDownload, - PERMISSIONS.crmQuotationDocumentPreview, - PERMISSIONS.crmQuotationPdfPreview, - PERMISSIONS.crmQuotationPdfDownload - ]; - case 'sales_support': - return [ - PERMISSIONS.crmCustomerRead, - PERMISSIONS.crmContactRead, - PERMISSIONS.crmEnquiryRead, - PERMISSIONS.crmEnquiryCreate, - PERMISSIONS.crmEnquiryUpdate, - PERMISSIONS.crmEnquiryFollowupRead, - PERMISSIONS.crmEnquiryFollowupCreate, - PERMISSIONS.crmEnquiryFollowupUpdate, - PERMISSIONS.crmQuotationRead, - PERMISSIONS.crmQuotationCreate, - PERMISSIONS.crmQuotationUpdate, - PERMISSIONS.crmQuotationItemManage, - PERMISSIONS.crmQuotationCustomerManage, - PERMISSIONS.crmQuotationTopicManage, - PERMISSIONS.crmQuotationFollowupManage, - PERMISSIONS.crmQuotationAttachmentManage, - PERMISSIONS.crmQuotationRevisionCreate, - PERMISSIONS.crmApprovalRead, - PERMISSIONS.crmApprovalWorkflowRead, - PERMISSIONS.crmDashboardRead, - PERMISSIONS.crmDocumentTemplateRead, - PERMISSIONS.crmDocumentSequenceRead, - PERMISSIONS.crmDocumentArtifactRead, - PERMISSIONS.crmDocumentArtifactDownload, - PERMISSIONS.crmQuotationDocumentPreview, - PERMISSIONS.crmQuotationPdfPreview, - PERMISSIONS.crmQuotationPdfDownload - ]; - case 'department_manager': - case 'top_manager': - return MANAGER_PERMISSIONS; - default: - return []; - } + return getRoleProfileDefinition(role)?.permissions ?? []; } export function getDefaultBusinessRole(role: MembershipRole): BusinessRole { @@ -317,6 +528,27 @@ export function getDefaultPermissions( ]; } +export function resolveEffectivePermissions(input: { + systemRole: SystemRole; + membershipRole: MembershipRole; + businessRole: string; + rolePermissions?: string[] | null; + directPermissions?: string[] | null; +}) { + if (input.systemRole === 'super_admin') { + return ALL_PERMISSIONS; + } + + return [ + ...new Set([ + ...getMembershipBasePermissions(input.membershipRole), + ...getBusinessRolePermissions(input.businessRole), + ...(input.rolePermissions?.filter((value): value is Permission => typeof value === 'string') ?? []), + ...(input.directPermissions?.filter((value): value is Permission => typeof value === 'string') ?? []) + ]) + ]; +} + export function isSystemRole(value: string): value is SystemRole { return SYSTEM_ROLES.includes(value as SystemRole); } @@ -326,5 +558,17 @@ export function isMembershipRole(value: string): value is MembershipRole { } export function isBusinessRole(value: string): value is BusinessRole { - return BUSINESS_ROLES.includes(value as BusinessRole); + return value.trim().length > 0; +} + +export function isOwnershipScope(value: string): value is CrmOwnershipScope { + return OWNERSHIP_SCOPES.includes(value as CrmOwnershipScope); +} + +export function isScopeMode(value: string): value is CrmScopeMode { + return SCOPE_MODES.includes(value as CrmScopeMode); +} + +export function isApprovalAuthority(value: string): value is CrmApprovalAuthority { + return APPROVAL_AUTHORITIES.includes(value as CrmApprovalAuthority); } diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index bc2a621..1df4a3d 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -1,7 +1,9 @@ import { and, eq } from 'drizzle-orm'; import { auth } from '@/auth'; import { memberships, organizations } from '@/db/schema'; +import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access'; import { db } from '@/lib/db'; +import type { Permission } from './rbac'; export class AuthError extends Error { status: number; @@ -51,15 +53,22 @@ export async function requireOrganizationAccess(options?: { throw new AuthError('Organization membership required', 403); } - return { - session, - membership: { - userId: session.user.id, - organizationId: activeOrganization.id, - role: activeOrganization.role, - businessRole: activeOrganization.businessRole, - permissions: session.user.activePermissions - }, + return { + session, + membership: { + userId: session.user.id, + organizationId: activeOrganization.id, + role: activeOrganization.role, + businessRole: activeOrganization.businessRole, + permissions: session.user.activePermissions, + branchScopeIds: session.user.activeBranchScopeIds, + productTypeScopeIds: session.user.activeProductTypeScopeIds, + ownershipScope: session.user.activeOwnershipScope, + branchScopeMode: 'all', + productScopeMode: 'all', + approvalAuthority: 'final', + roleProfileName: 'System Admin' + }, organization: { id: activeOrganization.id, name: activeOrganization.name, @@ -85,6 +94,12 @@ export async function requireOrganizationAccess(options?: { throw new AuthError('Forbidden', 403); } + const resolvedAccess = await resolveCrmMembershipAccess({ + systemRole: session.user.systemRole, + organizationId: membership.organizationId, + membership + }); + const allowedRoles = options?.roles ?? (options?.role ? [options.role] : []); const allowedPermissions = options?.permissions ?? (options?.permission ? [options.permission] : []); @@ -94,7 +109,9 @@ export async function requireOrganizationAccess(options?: { if ( allowedPermissions.length > 0 && - !allowedPermissions.every((permission) => membership.permissions.includes(permission)) + !allowedPermissions.every((permission) => + resolvedAccess.permissions.includes(permission as Permission) + ) ) { throw new AuthError('Forbidden', 403); } @@ -109,7 +126,17 @@ export async function requireOrganizationAccess(options?: { return { session, - membership, + membership: { + ...membership, + permissions: resolvedAccess.permissions, + branchScopeIds: resolvedAccess.branchScopeIds, + productTypeScopeIds: resolvedAccess.productTypeScopeIds, + ownershipScope: resolvedAccess.ownershipScope, + branchScopeMode: resolvedAccess.branchScopeMode, + productScopeMode: resolvedAccess.productScopeMode, + approvalAuthority: resolvedAccess.approvalAuthority, + roleProfileName: resolvedAccess.roleProfileName + }, organization }; } diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 383c12c..bdd6600 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -11,6 +11,9 @@ declare module 'next-auth' { activeMembershipRole: string | null; activeBusinessRole: string | null; activePermissions: string[]; + activeBranchScopeIds: string[]; + activeProductTypeScopeIds: string[]; + activeOwnershipScope: string | null; organizations: Array<{ id: string; name: string; @@ -40,6 +43,9 @@ declare module 'next-auth/jwt' { activeMembershipRole?: string | null; activeBusinessRole?: string | null; activePermissions?: string[]; + activeBranchScopeIds?: string[]; + activeProductTypeScopeIds?: string[]; + activeOwnershipScope?: string | null; organizations?: Array<{ id: string; name: string;