diff --git a/docs/adr/0014-crm-multi-role-user-assignment.md b/docs/adr/0014-crm-multi-role-user-assignment.md new file mode 100644 index 0000000..6a4161d --- /dev/null +++ b/docs/adr/0014-crm-multi-role-user-assignment.md @@ -0,0 +1,72 @@ +# ADR 0014: CRM Multi-Role User Assignment + +## Status + +Accepted + +## Context + +Task L introduced CRM role profiles and resolved CRM access, but authorization still depended on a single `memberships.businessRole` value per organization membership. + +That model is too restrictive for real CRM operations because one user may need multiple CRM responsibilities inside the same organization, such as: + +- `sales` + `sales_manager` +- `crm_admin` + `department_manager` +- `marketing` + `sales_support` + +We need to keep `memberships` as organization access while moving CRM authorization into its own persistent model. + +## Decision + +We adopt: + +- `memberships` as organization/workspace access +- `crm_user_role_assignments` as CRM authorization + +One user can have many CRM role assignments per organization. + +Each assignment stores: + +- `roleProfileId` +- branch scope mode and branch IDs +- product-type scope mode and product-type IDs +- primary/display flag +- active/inactive lifecycle + +Effective CRM access is resolved from: + +1. membership role +2. all active CRM role assignments +3. all assigned CRM role profile permissions +4. direct membership permissions + +Rules: + +- permissions are the union of all active role-profile permissions plus membership permissions +- branch scope is the union of active assignment scopes unless any active assignment grants `all` +- product-type scope is the union of active assignment scopes unless any active assignment grants `all` +- approval authority uses the highest active authority among assigned roles +- primary role is display-only and does not limit the permission union +- `memberships.businessRole` remains temporarily as a compatibility fallback only when no active CRM role assignment exists + +## Consequences + +### Positive + +- supports realistic multi-role CRM operation +- separates organization access from CRM-specific authorization +- allows per-role scope assignment per user +- keeps rollout compatible with existing membership data through lazy backfill + +### Negative + +- resolver complexity increases +- old `memberships.businessRole` semantics must be maintained during transition +- UI and audit coverage must handle assignment lifecycle, not just role-profile maintenance + +## Migration Strategy + +- create `crm_user_role_assignments` +- backfill one primary assignment from `memberships.businessRole` when possible +- treat `memberships.businessRole` as deprecated for CRM authorization after Task L.1 +- keep the column until user-management and downstream integrations fully move to assignment-based CRM access diff --git a/docs/implementation/task-l1-crm-multi-role-user-assignment.md b/docs/implementation/task-l1-crm-multi-role-user-assignment.md new file mode 100644 index 0000000..f77cacf --- /dev/null +++ b/docs/implementation/task-l1-crm-multi-role-user-assignment.md @@ -0,0 +1,46 @@ +# Task L.1: CRM Multi-Role User Assignment + +## Summary + +Task L.1 extends the CRM authorization foundation so one user can hold multiple CRM role assignments inside the same organization. + +## Implemented + +- Added `crm_user_role_assignments` schema for CRM-specific authorization rows +- Added assignment permissions: + - `crm.role.assignment.read` + - `crm.role.assignment.create` + - `crm.role.assignment.update` + - `crm.role.assignment.delete` + - `crm.role.assignment.manage` +- Added lazy compatibility backfill from `memberships.businessRole` into CRM role assignments +- Updated CRM access resolution to: + - union permissions across all active assignments + - union branch/product scope across assignments + - choose the most permissive ownership scope + - choose the highest approval authority + - use primary role for display only +- Added `CRM Settings > User Role Assignments` +- Added assignment API routes: + - `GET /api/crm/settings/user-role-assignments` + - `POST /api/crm/settings/user-role-assignments` + - `PATCH /api/crm/settings/user-role-assignments/[id]` + - `DELETE /api/crm/settings/user-role-assignments/[id]` +- Added audit logging for `crm_user_role_assignment` +- Added ADR `0014-crm-multi-role-user-assignment` + +## Compatibility + +- `memberships.businessRole` remains in place +- when no active CRM role assignment exists, resolver still falls back safely +- when role assignments exist, CRM authorization no longer treats `memberships.businessRole` as the primary source + +## Verification + +- Run: `npx tsc --noEmit` + +## Remaining Risks + +- user-creation and user-edit flows still center around legacy `memberships.businessRole` +- some CRM modules beyond the current resolver consumers may still need deeper scenario verification with mixed-role fixtures +- a dedicated one-shot operational migration script can still be useful even though lazy backfill is active diff --git a/docs/implementation/technical-debt.md b/docs/implementation/technical-debt.md index 88b6da4..39f1b2b 100644 --- a/docs/implementation/technical-debt.md +++ b/docs/implementation/technical-debt.md @@ -365,6 +365,19 @@ Future: - define whether mixed-role users see own, team, or organization scope by default - keep exports aligned with the same scope policy once enforced +## After Task L.1 + +### Deprecated membership.businessRole for CRM authorization + +Current: +`memberships.businessRole` is now a compatibility fallback only. Primary CRM authorization moves to `crm_user_role_assignments`. + +Future: + +- remove CRM authorization dependence on `memberships.businessRole` entirely +- update user-management flows to assign CRM roles through assignment rows instead of membership payloads +- remove the deprecated column only after all CRM modules and admin flows have migrated + ### Global project-party filter coverage Task J adds a global `Project Party Role` filter in the dashboard UI, but the strongest enforcement today is in revenue analytics and export sections. diff --git a/drizzle/0014_bored_valkyrie.sql b/drizzle/0014_bored_valkyrie.sql new file mode 100644 index 0000000..74871ec --- /dev/null +++ b/drizzle/0014_bored_valkyrie.sql @@ -0,0 +1,19 @@ +CREATE TABLE "crm_user_role_assignments" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "user_id" text NOT NULL, + "role_profile_id" text NOT NULL, + "branch_scope_mode" text DEFAULT 'inherit' NOT NULL, + "branch_scope_ids" text[] DEFAULT '{}' NOT NULL, + "product_type_scope_mode" text DEFAULT 'inherit' NOT NULL, + "product_type_scope_ids" text[] DEFAULT '{}' NOT NULL, + "is_primary" boolean DEFAULT false NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "assigned_by" text NOT NULL, + "assigned_at" timestamp with time zone DEFAULT now() 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 +); +--> statement-breakpoint +CREATE UNIQUE INDEX "crm_user_role_assignments_org_user_role_idx" ON "crm_user_role_assignments" USING btree ("organization_id","user_id","role_profile_id"); \ No newline at end of file diff --git a/drizzle/0014_crm_user_role_assignments.sql b/drizzle/0014_crm_user_role_assignments.sql new file mode 100644 index 0000000..7387337 --- /dev/null +++ b/drizzle/0014_crm_user_role_assignments.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS "crm_user_role_assignments" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "user_id" text NOT NULL, + "role_profile_id" text NOT NULL, + "branch_scope_mode" text DEFAULT 'inherit' NOT NULL, + "branch_scope_ids" text[] DEFAULT '{}' NOT NULL, + "product_type_scope_mode" text DEFAULT 'inherit' NOT NULL, + "product_type_scope_ids" text[] DEFAULT '{}' NOT NULL, + "is_primary" boolean DEFAULT false NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "assigned_by" text NOT NULL, + "assigned_at" timestamp with time zone DEFAULT now() 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 +); + +CREATE UNIQUE INDEX IF NOT EXISTS "crm_user_role_assignments_org_user_role_idx" +ON "crm_user_role_assignments" ("organization_id", "user_id", "role_profile_id"); diff --git a/drizzle/meta/0014_snapshot.json b/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..01db1df --- /dev/null +++ b/drizzle/meta/0014_snapshot.json @@ -0,0 +1,3580 @@ +{ + "id": "0d1df50e-da6b-49f3-81ac-207230784d7d", + "prevId": "6f765853-49cf-4310-ba12-414adc1e05dc", + "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.crm_user_role_assignments": { + "name": "crm_user_role_assignments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_profile_id": { + "name": "role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_scope_mode": { + "name": "branch_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + }, + "branch_scope_ids": { + "name": "branch_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "product_type_scope_mode": { + "name": "product_type_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + }, + "product_type_scope_ids": { + "name": "product_type_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_user_role_assignments_org_user_role_idx": { + "name": "crm_user_role_assignments_org_user_role_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_sequences": { + "name": "document_sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_number": { + "name": "current_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "padding_length": { + "name": "padding_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_sequences_org_doc_period_branch_idx": { + "name": "document_sequences_org_doc_period_branch_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "business_role": { + "name": "business_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sales_support'" + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "branch_scope_ids": { + "name": "branch_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "product_type_scope_ids": { + "name": "product_type_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ms_options": { + "name": "ms_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ms_options_org_category_code_idx": { + "name": "ms_options_org_category_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "products_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tr_audit_logs": { + "name": "tr_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_data": { + "name": "before_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_data": { + "name": "after_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "system_role": { + "name": "system_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 794846c..e77c8f9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1782097963376, "tag": "0013_great_nicolaos", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1782099743528, + "tag": "0014_bored_valkyrie", + "breakpoints": true } ] } \ No newline at end of file diff --git a/plans/task-l.1.md b/plans/task-l.1.md new file mode 100644 index 0000000..fab463f --- /dev/null +++ b/plans/task-l.1.md @@ -0,0 +1,575 @@ +# Task L.1: CRM Multi-Role User Assignment & Scope Management + +## Objective + +Extend Task L authorization foundation so one user can hold multiple CRM role profiles within the same organization. + +Business decision: + +```txt +1 User = Many CRM Roles +``` + +Examples: + +```txt +Sales Manager + Sales +CRM Admin + Department Manager +Sales Support + Marketing +``` + +This task adds role assignment persistence, assignment UI, and updates effective CRM access resolution. + +--- + +# Background + +Task L introduced: + +```txt +crmRoleProfiles +memberships.branchScopeIds +memberships.productTypeScopeIds +resolved CRM access helper +CRM Settings > Roles +``` + +Current limitation: + +```txt +membership.businessRole = single role +``` + +This is not enough for real business operation. + +Task L.1 moves CRM role assignment into a dedicated model while keeping membership as organization access. + +--- + +# Target Model + +Membership remains organization-level access: + +```txt +memberships +- userId +- organizationId +- role +- permissions +``` + +CRM role assignments become CRM-specific authorization rows: + +```txt +crm_user_role_assignments +- id +- organizationId +- userId +- roleProfileId +- branchScopeMode +- branchScopeIds +- productTypeScopeMode +- productTypeScopeIds +- isPrimary +- isActive +- assignedBy +- assignedAt +- createdAt +- updatedAt +- deletedAt +``` + +--- + +# Scope L1.1 Schema + +Add table: + +```txt +crm_user_role_assignments +``` + +Fields: + +```txt +id +organizationId +userId +roleProfileId + +branchScopeMode +branchScopeIds + +productTypeScopeMode +productTypeScopeIds + +isPrimary +isActive + +assignedBy +assignedAt + +createdAt +updatedAt +deletedAt +``` + +Recommended scope modes: + +```txt +all +selected +none +inherit +``` + +Unique constraint: + +```txt +organizationId + userId + roleProfileId +``` + +Rules: + +* one user can have many role profiles +* same role profile cannot be duplicated for the same user/org +* only one primary CRM role per user/org +* soft delete supported +* old `membership.businessRole` remains temporarily for compatibility + +--- + +# Scope L1.2 Migration / Backfill + +Backfill existing users. + +For each membership: + +```txt +membership.businessRole +``` + +create one CRM role assignment matching the role profile. + +Rules: + +* if businessRole has a matching `crmRoleProfiles.code`, create assignment +* if no matching role profile exists, skip and log warning +* set `isPrimary = true` +* branch/product scope from membership fields if available +* idempotent migration +* no duplicate assignments + +--- + +# Scope L1.3 Effective CRM Access Resolver + +Update resolved access helper. + +Old: + +```txt +membership.businessRole ++ +membership.permissions ++ +roleProfile.permissions +``` + +New: + +```txt +membership ++ +all active crm_user_role_assignments ++ +all assigned crmRoleProfiles ++ +direct membership.permissions +``` + +Permission behavior: + +```txt +effectivePermissions = union(all roleProfile.permissions, membership.permissions) +``` + +Ownership behavior: + +Use most permissive access among active role profiles: + +```txt +own +team +branch +organization +all +``` + +Branch scope behavior: + +```txt +if any role has all -> all +else union selected branchScopeIds +else none +``` + +Product type scope behavior: + +```txt +if any role has all -> all +else union selected productTypeScopeIds +else none +``` + +Approval authority: + +```txt +effectiveApprovalAuthority = union(all roleProfile.approvalAuthority) +``` + +Primary role: + +```txt +used for display only +not permission limit +``` + +--- + +# Scope L1.4 User Assignment UI + +Add page or tab: + +```txt +CRM Settings +└─ User Role Assignments +``` + +or add tab inside existing user detail: + +```txt +Users +└─ CRM Roles +``` + +Recommended UI: + +```txt +User +Assigned CRM Roles +Branch Scope +Product Type Scope +Primary Role +Status +Actions +``` + +Actions: + +```txt +Assign Role +Edit Scope +Set Primary +Deactivate Assignment +Reactivate Assignment +Remove Assignment +``` + +--- + +# Scope L1.5 Assignment Form + +Fields: + +```txt +User +Role Profile +Branch Scope Mode +Branches +Product Type Scope Mode +Product Types +Primary +Active +``` + +Behavior: + +* role profile dropdown from active `crmRoleProfiles` +* branch options from `crm_branch` +* product type options from `crm_product_type` +* if scope mode = all, disable selected list +* if scope mode = selected, require at least one selected option +* if primary = true, unset other primary role assignments for same user/org + +--- + +# Scope L1.6 API Routes + +Add: + +```txt +GET /api/crm/settings/user-role-assignments +POST /api/crm/settings/user-role-assignments +PATCH /api/crm/settings/user-role-assignments/[id] +DELETE /api/crm/settings/user-role-assignments/[id] +``` + +Optional: + +```txt +GET /api/crm/settings/users/[userId]/role-assignments +``` + +All APIs must: + +```txt +requireOrganizationAccess +requirePermission("crm.role.assignment.manage") +filter by organizationId +audit mutations +return user-safe errors +``` + +--- + +# Scope L1.7 Permissions + +Add permissions: + +```txt +crm.role.assignment.read +crm.role.assignment.create +crm.role.assignment.update +crm.role.assignment.delete +crm.role.assignment.manage +``` + +Grant default to: + +```txt +system_admin +crm_admin +``` + +Optional read to: + +```txt +sales_manager +department_manager +top_manager +``` + +--- + +# Scope L1.8 Audit + +Audit entity type: + +```txt +crm_user_role_assignment +``` + +Actions: + +```txt +create +update +delete +activate +deactivate +set_primary +scope_update +``` + +Audit payload should include: + +```txt +userId +roleProfileId +branchScopeMode +branchScopeIds +productTypeScopeMode +productTypeScopeIds +isPrimary +isActive +``` + +--- + +# Scope L1.9 Compatibility Layer + +Keep `membership.businessRole` temporarily. + +Rules: + +* do not remove column yet +* do not rely on it when role assignments exist +* fallback to `membership.businessRole` only when user has no active CRM role assignment +* document deprecation + +Add technical debt: + +```txt +membership.businessRole is deprecated for CRM authorization after Task L.1. +``` + +--- + +# Scope L1.10 Server-Side Enforcement Migration + +Update all CRM modules to use resolved multi-role access: + +```txt +Leads +Enquiries +Customers +Contacts +Quotations +Approvals +Dashboard +Settings +Reports if present +``` + +Priority: + +1. Leads / Enquiries +2. Quotations +3. Dashboard +4. Approval +5. Customers / Contacts +6. Settings + +Rules: + +* no UI-only enforcement +* all list/detail/action routes must check resolved CRM access +* pricing visibility must remain permission-based + +--- + +# Scope L1.11 Verification Matrix + +Create verification cases: + +## Marketing + Sales Support + +Expected: + +```txt +can view leads +can create support quotation if permission exists +cannot approve +cannot view revenue unless granted +``` + +## Sales + Sales Manager + +Expected: + +```txt +can see own sales records +can see team records if manager role scope allows +can create quotation +can approve only if role profile has authority +``` + +## CRM Admin + Department Manager + +Expected: + +```txt +can manage CRM settings +can approve department-level workflow +can view configured analytics +``` + +## Sales with Crane + Bangkok Scope + +Expected: + +```txt +can see Crane Bangkok records +cannot see Solar Rayong records +``` + +--- + +# Scope L1.12 Documentation / ADR + +Create ADR: + +```txt +docs/adr/0014-crm-multi-role-user-assignment.md +``` + +Document: + +```txt +1 user = many CRM roles +membership is organization access +crm_user_role_assignments is CRM authorization +permission union rule +scope union rule +primary role display-only rule +businessRole deprecation +``` + +Update: + +```txt +docs/implementation/technical-debt.md +``` + +--- + +# Explicit Non-Scope + +Do NOT implement: + +```txt +Keycloak group sync +External identity role sync +Team hierarchy module +Advanced ABAC rules +Approval delegation +Temporary role expiration +Role request workflow +``` + +--- + +# Output + +After completion, summarize: + +1. Files Added +2. Files Modified +3. Schema Added +4. Migration / Backfill +5. Resolver Changes +6. UI Added +7. API Added +8. Permissions Added +9. Audit Added +10. Verification Result +11. Remaining Risks + +--- + +# Definition of Done + +Task L.1 is complete when: + +* user can have multiple CRM role assignments +* each assignment can have branch/product scope +* one primary role can be selected +* effective permissions are unioned from all active roles +* effective branch/product scope is resolved correctly +* membership.businessRole is no longer primary CRM authorization source +* role assignment UI works +* role assignment API works +* all CRM critical routes use multi-role resolver +* audit logs are created for assignment changes +* ADR 0014 exists diff --git a/src/app/api/crm/settings/user-role-assignments/[id]/route.ts b/src/app/api/crm/settings/user-role-assignments/[id]/route.ts new file mode 100644 index 0000000..944f66e --- /dev/null +++ b/src/app/api/crm/settings/user-role-assignments/[id]/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + deleteCrmUserRoleAssignment, + updateCrmUserRoleAssignment +} from '@/features/foundation/crm-role-assignments/server/service'; +import { PERMISSIONS, ROLE_ASSIGNMENT_SCOPE_MODES } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +const updateAssignmentSchema = z.object({ + branchScopeMode: z.enum(ROLE_ASSIGNMENT_SCOPE_MODES), + branchScopeIds: z.array(z.string()).default([]), + productTypeScopeMode: z.enum(ROLE_ASSIGNMENT_SCOPE_MODES), + productTypeScopeIds: z.array(z.string()).default([]), + isPrimary: z.boolean(), + isActive: z.boolean() +}); + +type RouteProps = { + params: Promise<{ id: string }>; +}; + +export async function PATCH(request: NextRequest, props: RouteProps) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmRoleAssignmentManage + }); + const payload = updateAssignmentSchema.parse(await request.json()); + const { id } = await props.params; + const assignment = await updateCrmUserRoleAssignment( + organization.id, + session.user.id, + id, + payload + ); + + return NextResponse.json({ + success: true, + message: 'CRM user role assignment updated successfully', + assignment + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to update CRM user role assignment' }, + { status: 500 } + ); + } +} + +export async function DELETE(_request: NextRequest, props: RouteProps) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmRoleAssignmentManage + }); + const { id } = await props.params; + await deleteCrmUserRoleAssignment(organization.id, session.user.id, id); + + return NextResponse.json({ + success: true, + message: 'CRM user role assignment deleted successfully' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to delete CRM user role assignment' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/settings/user-role-assignments/route.ts b/src/app/api/crm/settings/user-role-assignments/route.ts new file mode 100644 index 0000000..0f4fb64 --- /dev/null +++ b/src/app/api/crm/settings/user-role-assignments/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + createCrmUserRoleAssignment, + listResolvedCrmRoleAssignments +} from '@/features/foundation/crm-role-assignments/server/service'; +import { PERMISSIONS, ROLE_ASSIGNMENT_SCOPE_MODES } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +const assignmentSchema = z.object({ + userId: z.string().min(1), + roleProfileId: z.string().min(1), + branchScopeMode: z.enum(ROLE_ASSIGNMENT_SCOPE_MODES), + branchScopeIds: z.array(z.string()).default([]), + productTypeScopeMode: z.enum(ROLE_ASSIGNMENT_SCOPE_MODES), + productTypeScopeIds: z.array(z.string()).default([]), + isPrimary: z.boolean(), + isActive: z.boolean() +}); + +export async function GET() { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmRoleAssignmentRead + }); + const data = await listResolvedCrmRoleAssignments(organization.id, session.user.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'CRM user role assignments loaded successfully', + ...data + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json( + { message: 'Unable to load CRM user role assignments' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmRoleAssignmentManage + }); + const payload = assignmentSchema.parse(await request.json()); + const assignment = await createCrmUserRoleAssignment( + organization.id, + session.user.id, + payload + ); + + return NextResponse.json( + { + success: true, + message: 'CRM user role assignment created successfully', + assignment + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to create CRM user role assignment' }, + { status: 500 } + ); + } +} diff --git a/src/app/dashboard/crm/settings/user-role-assignments/page.tsx b/src/app/dashboard/crm/settings/user-role-assignments/page.tsx new file mode 100644 index 0000000..3f52ffd --- /dev/null +++ b/src/app/dashboard/crm/settings/user-role-assignments/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 { UserRoleAssignmentsPage } from '@/features/foundation/crm-role-assignments/components/user-role-assignments-page'; +import { crmUserRoleAssignmentsQueryOptions } from '@/features/foundation/crm-role-assignments/api/queries'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { getQueryClient } from '@/lib/query-client'; + +export default async function CrmUserRoleAssignmentsRoute() { + const session = await auth(); + const canRead = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + session.user.activePermissions.includes(PERMISSIONS.crmRoleAssignmentRead)); + const queryClient = getQueryClient(); + + if (canRead) { + void queryClient.prefetchQuery(crmUserRoleAssignmentsQueryOptions()); + } + + return ( + + You do not have access to CRM user role assignments. + + } + > + {canRead ? ( + + + + ) : null} + + ); +} diff --git a/src/auth.ts b/src/auth.ts index e063799..cb6bebb 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -27,6 +27,7 @@ type SessionOrganization = { slug: string; role: string; businessRole: string; + businessRoles: string[]; branchScopeIds: string[]; productTypeScopeIds: string[]; ownershipScope: string; @@ -119,6 +120,7 @@ async function buildUserAccessContext(userId: string) { slug: row.slug, role: row.role, businessRole: row.businessRole, + businessRoles: resolvedAccess.businessRoles, branchScopeIds: resolvedAccess.branchScopeIds, productTypeScopeIds: resolvedAccess.productTypeScopeIds, ownershipScope: resolvedAccess.ownershipScope, @@ -228,7 +230,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ token.activeOrganizationName = activeMembership?.name ?? null; token.activeOrganizationPlan = activeMembership?.plan ?? null; token.activeMembershipRole = activeMembership?.role ?? null; - token.activeBusinessRole = activeMembership?.businessRole ?? null; + token.activeBusinessRole = activeMembershipAccess?.businessRole ?? activeMembership?.businessRole ?? null; token.activePermissions = activeMembershipAccess?.permissions ?? []; token.activeBranchScopeIds = activeMembershipAccess?.branchScopeIds ?? []; token.activeProductTypeScopeIds = activeMembershipAccess?.productTypeScopeIds ?? []; @@ -278,6 +280,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ typeof value.slug === 'string' && typeof value.role === 'string' && typeof value.businessRole === 'string' && + Array.isArray((value as { businessRoles?: unknown }).businessRoles) && Array.isArray(value.branchScopeIds) && Array.isArray(value.productTypeScopeIds) && typeof value.ownershipScope === 'string' && diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index 8199012..c23be86 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -136,13 +136,21 @@ export const navGroups: NavGroup[] = [ url: "/dashboard/crm/settings/master-options", icon: "settings", isActive: false, - access: { requireOrg: true, permission: "crm.role.read" }, + access: { requireOrg: true, permission: "crm.role.assignment.read" }, items: [ { title: "Roles & Permissions", url: "/dashboard/crm/settings/roles", access: { requireOrg: true, permission: "crm.role.read" }, }, + { + title: "User Role Assignments", + url: "/dashboard/crm/settings/user-role-assignments", + access: { + requireOrg: true, + permission: "crm.role.assignment.read", + }, + }, { title: "Master Options", url: "/dashboard/crm/settings/master-options", diff --git a/src/db/schema.ts b/src/db/schema.ts index bd5c883..925098b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -86,6 +86,34 @@ export const crmRoleProfiles = pgTable( }) ); +export const crmUserRoleAssignments = pgTable( + 'crm_user_role_assignments', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + userId: text('user_id').notNull(), + roleProfileId: text('role_profile_id').notNull(), + branchScopeMode: text('branch_scope_mode').default('inherit').notNull(), + branchScopeIds: text('branch_scope_ids').array().default([]).notNull(), + productTypeScopeMode: text('product_type_scope_mode').default('inherit').notNull(), + productTypeScopeIds: text('product_type_scope_ids').array().default([]).notNull(), + isPrimary: boolean('is_primary').default(false).notNull(), + isActive: boolean('is_active').default(true).notNull(), + assignedBy: text('assigned_by').notNull(), + assignedAt: timestamp('assigned_at', { withTimezone: true }).defaultNow().notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }) + }, + (table) => ({ + organizationUserRoleProfileIdx: uniqueIndex('crm_user_role_assignments_org_user_role_idx').on( + table.organizationId, + table.userId, + table.roleProfileId + ) + }) +); + export const products = pgTable('products', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), organizationId: text('organization_id').notNull(), diff --git a/src/features/foundation/crm-role-assignments/api/mutations.ts b/src/features/foundation/crm-role-assignments/api/mutations.ts new file mode 100644 index 0000000..a986f11 --- /dev/null +++ b/src/features/foundation/crm-role-assignments/api/mutations.ts @@ -0,0 +1,41 @@ +import { mutationOptions } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; +import { + createCrmUserRoleAssignment, + deleteCrmUserRoleAssignment, + updateCrmUserRoleAssignment +} from './service'; +import { crmUserRoleAssignmentQueryKeys } from './queries'; + +export const createCrmUserRoleAssignmentMutation = mutationOptions({ + mutationFn: createCrmUserRoleAssignment, + onSettled: async () => { + await getQueryClient().invalidateQueries({ + queryKey: crmUserRoleAssignmentQueryKeys.all + }); + } +}); + +export const updateCrmUserRoleAssignmentMutation = mutationOptions({ + mutationFn: ({ + id, + payload + }: { + id: string; + payload: Parameters[1]; + }) => updateCrmUserRoleAssignment(id, payload), + onSettled: async () => { + await getQueryClient().invalidateQueries({ + queryKey: crmUserRoleAssignmentQueryKeys.all + }); + } +}); + +export const deleteCrmUserRoleAssignmentMutation = mutationOptions({ + mutationFn: deleteCrmUserRoleAssignment, + onSettled: async () => { + await getQueryClient().invalidateQueries({ + queryKey: crmUserRoleAssignmentQueryKeys.all + }); + } +}); diff --git a/src/features/foundation/crm-role-assignments/api/queries.ts b/src/features/foundation/crm-role-assignments/api/queries.ts new file mode 100644 index 0000000..bafb83d --- /dev/null +++ b/src/features/foundation/crm-role-assignments/api/queries.ts @@ -0,0 +1,13 @@ +import { queryOptions } from '@tanstack/react-query'; +import { getCrmUserRoleAssignments } from './service'; + +export const crmUserRoleAssignmentQueryKeys = { + all: ['crm-user-role-assignments'] as const +}; + +export function crmUserRoleAssignmentsQueryOptions() { + return queryOptions({ + queryKey: crmUserRoleAssignmentQueryKeys.all, + queryFn: getCrmUserRoleAssignments + }); +} diff --git a/src/features/foundation/crm-role-assignments/api/service.ts b/src/features/foundation/crm-role-assignments/api/service.ts new file mode 100644 index 0000000..500c7f9 --- /dev/null +++ b/src/features/foundation/crm-role-assignments/api/service.ts @@ -0,0 +1,42 @@ +import { apiClient } from '@/lib/api-client'; +import type { + CrmUserRoleAssignmentMutationPayload, + CrmUserRoleAssignmentsResponse, + CrmUserRoleAssignmentRecord, + UpdateCrmUserRoleAssignmentPayload +} from './types'; + +export async function getCrmUserRoleAssignments() { + return apiClient('/crm/settings/user-role-assignments'); +} + +export async function createCrmUserRoleAssignment( + payload: CrmUserRoleAssignmentMutationPayload +) { + return apiClient<{ success: boolean; assignment: CrmUserRoleAssignmentRecord }>( + '/crm/settings/user-role-assignments', + { + method: 'POST', + body: JSON.stringify(payload) + } + ); +} + +export async function updateCrmUserRoleAssignment( + id: string, + payload: UpdateCrmUserRoleAssignmentPayload +) { + return apiClient<{ success: boolean; assignment: CrmUserRoleAssignmentRecord }>( + `/crm/settings/user-role-assignments/${id}`, + { + method: 'PATCH', + body: JSON.stringify(payload) + } + ); +} + +export async function deleteCrmUserRoleAssignment(id: string) { + return apiClient<{ success: boolean }>(`/crm/settings/user-role-assignments/${id}`, { + method: 'DELETE' + }); +} diff --git a/src/features/foundation/crm-role-assignments/api/types.ts b/src/features/foundation/crm-role-assignments/api/types.ts new file mode 100644 index 0000000..78ed9d1 --- /dev/null +++ b/src/features/foundation/crm-role-assignments/api/types.ts @@ -0,0 +1,63 @@ +import type { CrmRoleAssignmentScopeMode } from '@/lib/auth/rbac'; + +export interface CrmUserRoleAssignmentRecord { + id: string; + organizationId: string; + userId: string; + userName: string; + userEmail: string; + roleProfileId: string; + roleProfileCode: string; + roleProfileName: string; + branchScopeMode: CrmRoleAssignmentScopeMode; + branchScopeIds: string[]; + productTypeScopeMode: CrmRoleAssignmentScopeMode; + productTypeScopeIds: string[]; + isPrimary: boolean; + isActive: boolean; + assignedBy: string; + assignedAt: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface CrmRoleAssignmentReferenceOption { + id: string; + code?: string; + name?: string; + label?: string; + email?: string; +} + +export interface CrmUserRoleAssignmentsResponse { + success: boolean; + time: string; + message: string; + assignments: CrmUserRoleAssignmentRecord[]; + scopeModes: CrmRoleAssignmentScopeMode[]; + roleProfiles: Array<{ id: string; code: string; name: string }>; + users: Array<{ id: string; name: string; email: string }>; + branches: Array<{ id: string; code: string; label: string }>; + productTypes: Array<{ id: string; code: string; label: string }>; +} + +export interface CrmUserRoleAssignmentMutationPayload { + userId: string; + roleProfileId: string; + branchScopeMode: CrmRoleAssignmentScopeMode; + branchScopeIds: string[]; + productTypeScopeMode: CrmRoleAssignmentScopeMode; + productTypeScopeIds: string[]; + isPrimary: boolean; + isActive: boolean; +} + +export interface UpdateCrmUserRoleAssignmentPayload { + branchScopeMode: CrmRoleAssignmentScopeMode; + branchScopeIds: string[]; + productTypeScopeMode: CrmRoleAssignmentScopeMode; + productTypeScopeIds: string[]; + isPrimary: boolean; + isActive: boolean; +} diff --git a/src/features/foundation/crm-role-assignments/components/user-role-assignments-page.tsx b/src/features/foundation/crm-role-assignments/components/user-role-assignments-page.tsx new file mode 100644 index 0000000..dad59db --- /dev/null +++ b/src/features/foundation/crm-role-assignments/components/user-role-assignments-page.tsx @@ -0,0 +1,438 @@ +'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 { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { + createCrmUserRoleAssignmentMutation, + deleteCrmUserRoleAssignmentMutation, + updateCrmUserRoleAssignmentMutation +} from '../api/mutations'; +import { crmUserRoleAssignmentsQueryOptions } from '../api/queries'; +import type { + CrmUserRoleAssignmentMutationPayload, + CrmUserRoleAssignmentRecord, + UpdateCrmUserRoleAssignmentPayload +} from '../api/types'; + +type AssignmentEditorState = { + branchScopeMode: CrmUserRoleAssignmentMutationPayload['branchScopeMode']; + branchScopeIds: string[]; + productTypeScopeMode: CrmUserRoleAssignmentMutationPayload['productTypeScopeMode']; + productTypeScopeIds: string[]; + isPrimary: boolean; + isActive: boolean; +}; + +const emptyCreateState: CrmUserRoleAssignmentMutationPayload = { + userId: '', + roleProfileId: '', + branchScopeMode: 'inherit', + branchScopeIds: [], + productTypeScopeMode: 'inherit', + productTypeScopeIds: [], + isPrimary: false, + isActive: true +}; + +function ScopeSelector(props: { + label: string; + mode: CrmUserRoleAssignmentMutationPayload['branchScopeMode']; + ids: string[]; + options: Array<{ id: string; label: string; code: string }>; + onModeChange: (value: CrmUserRoleAssignmentMutationPayload['branchScopeMode']) => void; + onIdsChange: (value: string[]) => void; +}) { + return ( +
+
+ + +
+ + {props.mode === 'selected' ? ( +
+ {props.options.map((option) => { + const checked = props.ids.includes(option.id); + + return ( + + ); + })} +
+ ) : null} +
+ ); +} + +export function UserRoleAssignmentsPage() { + const { data } = useSuspenseQuery(crmUserRoleAssignmentsQueryOptions()); + const [selectedId, setSelectedId] = useState(data.assignments[0]?.id ?? null); + const [createState, setCreateState] = + useState(emptyCreateState); + const [editorState, setEditorState] = useState(null); + const selectedAssignment = useMemo( + () => data.assignments.find((assignment) => assignment.id === selectedId) ?? null, + [data.assignments, selectedId] + ); + + useEffect(() => { + if (!selectedAssignment) { + setEditorState(null); + return; + } + + setEditorState({ + branchScopeMode: selectedAssignment.branchScopeMode, + branchScopeIds: selectedAssignment.branchScopeIds, + productTypeScopeMode: selectedAssignment.productTypeScopeMode, + productTypeScopeIds: selectedAssignment.productTypeScopeIds, + isPrimary: selectedAssignment.isPrimary, + isActive: selectedAssignment.isActive + }); + }, [selectedAssignment]); + + const createMutation = useMutation({ + ...createCrmUserRoleAssignmentMutation, + onSuccess: () => { + toast.success('CRM role assignment created successfully'); + setCreateState(emptyCreateState); + }, + onError: (error) => + toast.error( + error instanceof Error ? error.message : 'Unable to create CRM role assignment' + ) + }); + + const updateMutation = useMutation({ + ...updateCrmUserRoleAssignmentMutation, + onSuccess: () => toast.success('CRM role assignment updated successfully'), + onError: (error) => + toast.error( + error instanceof Error ? error.message : 'Unable to update CRM role assignment' + ) + }); + + const deleteMutation = useMutation({ + ...deleteCrmUserRoleAssignmentMutation, + onSuccess: () => { + toast.success('CRM role assignment deleted successfully'); + setSelectedId(null); + }, + onError: (error) => + toast.error( + error instanceof Error ? error.message : 'Unable to delete CRM role assignment' + ) + }); + + async function handleCreate() { + if (!createState.userId || !createState.roleProfileId) { + toast.error('User and role profile are required'); + return; + } + + await createMutation.mutateAsync(createState); + } + + async function handleUpdate() { + if (!selectedAssignment || !editorState) { + return; + } + + await updateMutation.mutateAsync({ + id: selectedAssignment.id, + payload: editorState satisfies UpdateCrmUserRoleAssignmentPayload + }); + } + + return ( +
+ + + Assign CRM Role + + One user can hold multiple CRM roles in the same organization. Primary role is for + display; effective access unions across active assignments. + + + +
+
+ + +
+
+ + +
+
+ +
+ + setCreateState({ + ...createState, + branchScopeMode: value, + branchScopeIds: value === 'selected' ? createState.branchScopeIds : [] + }) + } + onIdsChange={(value) => setCreateState({ ...createState, branchScopeIds: value })} + /> + + setCreateState({ + ...createState, + productTypeScopeMode: value, + productTypeScopeIds: value === 'selected' ? createState.productTypeScopeIds : [] + }) + } + onIdsChange={(value) => + setCreateState({ ...createState, productTypeScopeIds: value }) + } + /> +
+ +
+ + +
+ + +
+
+ +
+ + + Existing Assignments + + Active and historical CRM role assignments in this organization. + + + + {data.assignments.map((assignment) => ( + + ))} + + + + + + Assignment Detail + + Update scope, activation, and primary-role display for the selected CRM assignment. + + + + {!selectedAssignment || !editorState ? ( +
+ Select an assignment to edit. +
+ ) : ( + <> +
+
+
User
+
{selectedAssignment.userName}
+
{selectedAssignment.userEmail}
+
+
+
Role Profile
+
{selectedAssignment.roleProfileName}
+
+ {selectedAssignment.roleProfileCode} +
+
+
+ +
+ + setEditorState({ + ...editorState, + branchScopeMode: value, + branchScopeIds: value === 'selected' ? editorState.branchScopeIds : [] + }) + } + onIdsChange={(value) => + setEditorState({ ...editorState, branchScopeIds: value }) + } + /> + + setEditorState({ + ...editorState, + productTypeScopeMode: value, + productTypeScopeIds: + value === 'selected' ? editorState.productTypeScopeIds : [] + }) + } + onIdsChange={(value) => + setEditorState({ ...editorState, productTypeScopeIds: value }) + } + /> +
+ +
+ + +
+ +
+ + +
+ + )} +
+
+
+
+ ); +} diff --git a/src/features/foundation/crm-role-assignments/server/service.ts b/src/features/foundation/crm-role-assignments/server/service.ts new file mode 100644 index 0000000..58dab21 --- /dev/null +++ b/src/features/foundation/crm-role-assignments/server/service.ts @@ -0,0 +1,625 @@ +import { and, asc, eq, inArray, isNull } from 'drizzle-orm'; +import { + crmRoleProfiles, + crmUserRoleAssignments, + memberships, + msOptions, + users +} from '@/db/schema'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { getUserBranches } from '@/features/foundation/branch-scope/service'; +import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; +import { ensureCrmRoleProfiles } from '@/features/foundation/role-management/server/service'; +import { + ROLE_ASSIGNMENT_SCOPE_MODES, + isRoleAssignmentScopeMode, + type CrmRoleAssignmentScopeMode +} from '@/lib/auth/rbac'; +import { db } from '@/lib/db'; +import { AuthError } from '@/lib/auth/session'; + +function normalizeStringArray(value: unknown) { + return Array.isArray(value) + ? [ + ...new Set( + value.filter( + (item): item is string => typeof item === 'string' && item.trim().length > 0 + ) + ) + ] + : []; +} + +function isSelectedScopeInvalid(mode: CrmRoleAssignmentScopeMode, values: string[]) { + return mode === 'selected' && values.length === 0; +} + +function mapAssignmentRow( + row: typeof crmUserRoleAssignments.$inferSelect, + options: { + roleProfileCode: string; + roleProfileName: string; + userName: string; + userEmail: string; + } +) { + return { + id: row.id, + organizationId: row.organizationId, + userId: row.userId, + userName: options.userName, + userEmail: options.userEmail, + roleProfileId: row.roleProfileId, + roleProfileCode: options.roleProfileCode, + roleProfileName: options.roleProfileName, + branchScopeMode: isRoleAssignmentScopeMode(row.branchScopeMode) + ? row.branchScopeMode + : 'inherit', + branchScopeIds: normalizeStringArray(row.branchScopeIds), + productTypeScopeMode: isRoleAssignmentScopeMode(row.productTypeScopeMode) + ? row.productTypeScopeMode + : 'inherit', + productTypeScopeIds: normalizeStringArray(row.productTypeScopeIds), + isPrimary: row.isPrimary, + isActive: row.isActive, + assignedBy: row.assignedBy, + assignedAt: row.assignedAt.toISOString(), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null + }; +} + +async function ensurePrimaryAssignmentForUser(organizationId: string, userId: string) { + const activeAssignments = await db + .select({ + id: crmUserRoleAssignments.id, + isPrimary: crmUserRoleAssignments.isPrimary + }) + .from(crmUserRoleAssignments) + .where( + and( + eq(crmUserRoleAssignments.organizationId, organizationId), + eq(crmUserRoleAssignments.userId, userId), + eq(crmUserRoleAssignments.isActive, true), + isNull(crmUserRoleAssignments.deletedAt) + ) + ) + .orderBy(asc(crmUserRoleAssignments.assignedAt)); + + if (!activeAssignments.length || activeAssignments.some((item) => item.isPrimary)) { + return; + } + + await db + .update(crmUserRoleAssignments) + .set({ + isPrimary: true, + updatedAt: new Date() + }) + .where(eq(crmUserRoleAssignments.id, activeAssignments[0]!.id)); +} + +async function setPrimaryAssignment( + organizationId: string, + userId: string, + assignmentId: string, + isPrimary: boolean +) { + if (!isPrimary) { + return; + } + + await db + .update(crmUserRoleAssignments) + .set({ + isPrimary: false, + updatedAt: new Date() + }) + .where( + and( + eq(crmUserRoleAssignments.organizationId, organizationId), + eq(crmUserRoleAssignments.userId, userId), + isNull(crmUserRoleAssignments.deletedAt) + ) + ); + + await db + .update(crmUserRoleAssignments) + .set({ + isPrimary: true, + updatedAt: new Date() + }) + .where(eq(crmUserRoleAssignments.id, assignmentId)); +} + +export async function ensureCrmRoleAssignmentsBackfillForMembership( + organizationId: string, + membership: typeof memberships.$inferSelect, + actorUserId: string +) { + await ensureCrmRoleProfiles(organizationId, actorUserId); + + const [roleProfile] = await db + .select({ + id: crmRoleProfiles.id + }) + .from(crmRoleProfiles) + .where( + and( + eq(crmRoleProfiles.organizationId, organizationId), + eq(crmRoleProfiles.code, membership.businessRole), + isNull(crmRoleProfiles.deletedAt) + ) + ) + .limit(1); + + if (!roleProfile) { + return; + } + + const existing = await db.query.crmUserRoleAssignments.findFirst({ + where: and( + eq(crmUserRoleAssignments.organizationId, organizationId), + eq(crmUserRoleAssignments.userId, membership.userId), + eq(crmUserRoleAssignments.roleProfileId, roleProfile.id) + ) + }); + + const branchScopeIds = normalizeStringArray(membership.branchScopeIds); + const productTypeScopeIds = normalizeStringArray(membership.productTypeScopeIds); + const branchScopeMode: CrmRoleAssignmentScopeMode = branchScopeIds.length ? 'selected' : 'all'; + const productTypeScopeMode: CrmRoleAssignmentScopeMode = productTypeScopeIds.length + ? 'selected' + : 'all'; + + if (existing) { + if (existing.deletedAt || !existing.isActive) { + await db + .update(crmUserRoleAssignments) + .set({ + branchScopeMode, + branchScopeIds, + productTypeScopeMode, + productTypeScopeIds, + isPrimary: true, + isActive: true, + assignedBy: actorUserId, + assignedAt: new Date(), + updatedAt: new Date(), + deletedAt: null + }) + .where(eq(crmUserRoleAssignments.id, existing.id)); + } + + await setPrimaryAssignment(organizationId, membership.userId, existing.id, true); + return; + } + + const [created] = await db + .insert(crmUserRoleAssignments) + .values({ + id: crypto.randomUUID(), + organizationId, + userId: membership.userId, + roleProfileId: roleProfile.id, + branchScopeMode, + branchScopeIds, + productTypeScopeMode, + productTypeScopeIds, + isPrimary: true, + isActive: true, + assignedBy: actorUserId + }) + .returning(); + + await setPrimaryAssignment(organizationId, membership.userId, created.id, true); +} + +export async function ensureCrmRoleAssignmentsBackfillForOrganization( + organizationId: string, + actorUserId: string +) { + const membershipRows = await db.query.memberships.findMany({ + where: eq(memberships.organizationId, organizationId) + }); + + for (const membership of membershipRows) { + await ensureCrmRoleAssignmentsBackfillForMembership(organizationId, membership, actorUserId); + } +} + +export async function listResolvedCrmRoleAssignments( + organizationId: string, + actorUserId: string +) { + await ensureCrmRoleAssignmentsBackfillForOrganization(organizationId, actorUserId); + + const rows = await db + .select({ + assignment: crmUserRoleAssignments, + roleProfileCode: crmRoleProfiles.code, + roleProfileName: crmRoleProfiles.name, + userName: users.name, + userEmail: users.email + }) + .from(crmUserRoleAssignments) + .innerJoin(crmRoleProfiles, eq(crmRoleProfiles.id, crmUserRoleAssignments.roleProfileId)) + .innerJoin(users, eq(users.id, crmUserRoleAssignments.userId)) + .where( + and( + eq(crmUserRoleAssignments.organizationId, organizationId), + isNull(crmUserRoleAssignments.deletedAt) + ) + ) + .orderBy(asc(users.name), asc(crmUserRoleAssignments.assignedAt)); + + const [branchOptions, productTypeOptions, roleProfiles, orgUsers] = await Promise.all([ + getUserBranches(), + getActiveOptionsByCategory('crm_product_type', { organizationId }), + db + .select({ + id: crmRoleProfiles.id, + code: crmRoleProfiles.code, + name: crmRoleProfiles.name + }) + .from(crmRoleProfiles) + .where( + and( + eq(crmRoleProfiles.organizationId, organizationId), + eq(crmRoleProfiles.isActive, true), + isNull(crmRoleProfiles.deletedAt) + ) + ) + .orderBy(asc(crmRoleProfiles.name)), + db + .select({ + userId: users.id, + name: users.name, + email: users.email + }) + .from(memberships) + .innerJoin(users, eq(users.id, memberships.userId)) + .where(eq(memberships.organizationId, organizationId)) + .orderBy(asc(users.name)) + ]); + + return { + assignments: rows.map((row) => + mapAssignmentRow(row.assignment, { + roleProfileCode: row.roleProfileCode, + roleProfileName: row.roleProfileName, + userName: row.userName, + userEmail: row.userEmail + }) + ), + scopeModes: [...ROLE_ASSIGNMENT_SCOPE_MODES], + roleProfiles, + users: [...new Map(orgUsers.map((row) => [row.userId, row])).values()].map((row) => ({ + id: row.userId, + name: row.name, + email: row.email + })), + branches: branchOptions.map((branch) => ({ + id: branch.id, + code: branch.code, + label: branch.name + })), + productTypes: productTypeOptions.map((option) => ({ + id: option.id, + code: option.code, + label: option.label + })) + }; +} + +export interface UpsertCrmUserRoleAssignmentPayload { + userId: string; + roleProfileId: string; + branchScopeMode: CrmRoleAssignmentScopeMode; + branchScopeIds: string[]; + productTypeScopeMode: CrmRoleAssignmentScopeMode; + productTypeScopeIds: string[]; + isPrimary: boolean; + isActive: boolean; +} + +async function validateAssignmentPayload( + organizationId: string, + payload: UpsertCrmUserRoleAssignmentPayload +) { + const [userMembership, roleProfile] = await Promise.all([ + db.query.memberships.findFirst({ + where: and( + eq(memberships.organizationId, organizationId), + eq(memberships.userId, payload.userId) + ) + }), + db.query.crmRoleProfiles.findFirst({ + where: and( + eq(crmRoleProfiles.organizationId, organizationId), + eq(crmRoleProfiles.id, payload.roleProfileId), + isNull(crmRoleProfiles.deletedAt) + ) + }) + ]); + + if (!userMembership) { + throw new AuthError('User does not belong to this organization', 404); + } + + if (!roleProfile) { + throw new AuthError('CRM role profile not found', 404); + } + + if (isSelectedScopeInvalid(payload.branchScopeMode, payload.branchScopeIds)) { + throw new AuthError('Select at least one branch when branch scope mode is selected', 400); + } + + if (isSelectedScopeInvalid(payload.productTypeScopeMode, payload.productTypeScopeIds)) { + throw new AuthError( + 'Select at least one product type when product scope mode is selected', + 400 + ); + } + + const branches = await getUserBranches(); + const validBranchIds = new Set(branches.map((branch) => branch.id)); + const productTypes = await getActiveOptionsByCategory('crm_product_type', { organizationId }); + const validProductTypeIds = new Set(productTypes.map((option) => option.id)); + + if (payload.branchScopeIds.some((id) => !validBranchIds.has(id))) { + throw new AuthError('Invalid branch scope selection', 400); + } + + if (payload.productTypeScopeIds.some((id) => !validProductTypeIds.has(id))) { + throw new AuthError('Invalid product type scope selection', 400); + } + + return { + userMembership, + roleProfile + }; +} + +export async function createCrmUserRoleAssignment( + organizationId: string, + actorUserId: string, + payload: UpsertCrmUserRoleAssignmentPayload +) { + await validateAssignmentPayload(organizationId, payload); + + const duplicate = await db.query.crmUserRoleAssignments.findFirst({ + where: and( + eq(crmUserRoleAssignments.organizationId, organizationId), + eq(crmUserRoleAssignments.userId, payload.userId), + eq(crmUserRoleAssignments.roleProfileId, payload.roleProfileId) + ) + }); + + let assignmentId = duplicate?.id ?? null; + + if (duplicate) { + await db + .update(crmUserRoleAssignments) + .set({ + branchScopeMode: payload.branchScopeMode, + branchScopeIds: payload.branchScopeIds, + productTypeScopeMode: payload.productTypeScopeMode, + productTypeScopeIds: payload.productTypeScopeIds, + isPrimary: payload.isPrimary, + isActive: payload.isActive, + assignedBy: actorUserId, + assignedAt: new Date(), + updatedAt: new Date(), + deletedAt: null + }) + .where(eq(crmUserRoleAssignments.id, duplicate.id)); + } else { + const [created] = await db + .insert(crmUserRoleAssignments) + .values({ + id: crypto.randomUUID(), + organizationId, + userId: payload.userId, + roleProfileId: payload.roleProfileId, + branchScopeMode: payload.branchScopeMode, + branchScopeIds: payload.branchScopeIds, + productTypeScopeMode: payload.productTypeScopeMode, + productTypeScopeIds: payload.productTypeScopeIds, + isPrimary: payload.isPrimary, + isActive: payload.isActive, + assignedBy: actorUserId + }) + .returning(); + + assignmentId = created.id; + } + + if (!assignmentId) { + throw new AuthError('Unable to create CRM role assignment', 500); + } + + await setPrimaryAssignment(organizationId, payload.userId, assignmentId, payload.isPrimary); + await ensurePrimaryAssignmentForUser(organizationId, payload.userId); + + const refreshed = await listResolvedCrmRoleAssignments(organizationId, actorUserId); + const assignment = refreshed.assignments.find((item) => item.id === assignmentId); + + if (!assignment) { + throw new AuthError('CRM role assignment not found after create', 500); + } + + await auditAction({ + organizationId, + userId: actorUserId, + entityType: 'crm_user_role_assignment', + entityId: assignment.id, + action: duplicate ? 'reactivate' : 'create', + afterData: assignment + }); + + return assignment; +} + +export async function updateCrmUserRoleAssignment( + organizationId: string, + actorUserId: string, + assignmentId: string, + payload: Omit +) { + const current = await db.query.crmUserRoleAssignments.findFirst({ + where: and( + eq(crmUserRoleAssignments.id, assignmentId), + eq(crmUserRoleAssignments.organizationId, organizationId) + ) + }); + + if (!current || current.deletedAt) { + throw new AuthError('CRM role assignment not found', 404); + } + + await validateAssignmentPayload(organizationId, { + userId: current.userId, + roleProfileId: current.roleProfileId, + ...payload + }); + + await db + .update(crmUserRoleAssignments) + .set({ + branchScopeMode: payload.branchScopeMode, + branchScopeIds: payload.branchScopeIds, + productTypeScopeMode: payload.productTypeScopeMode, + productTypeScopeIds: payload.productTypeScopeIds, + isPrimary: payload.isPrimary, + isActive: payload.isActive, + updatedAt: new Date() + }) + .where(eq(crmUserRoleAssignments.id, assignmentId)); + + await setPrimaryAssignment(organizationId, current.userId, assignmentId, payload.isPrimary); + await ensurePrimaryAssignmentForUser(organizationId, current.userId); + + const refreshed = await listResolvedCrmRoleAssignments(organizationId, actorUserId); + const assignment = refreshed.assignments.find((item) => item.id === assignmentId); + + if (!assignment) { + throw new AuthError('CRM role assignment not found after update', 500); + } + + await auditAction({ + organizationId, + userId: actorUserId, + entityType: 'crm_user_role_assignment', + entityId: assignment.id, + action: payload.isActive ? 'update' : 'deactivate', + beforeData: current, + afterData: assignment + }); + + return assignment; +} + +export async function deleteCrmUserRoleAssignment( + organizationId: string, + actorUserId: string, + assignmentId: string +) { + const current = await db.query.crmUserRoleAssignments.findFirst({ + where: and( + eq(crmUserRoleAssignments.id, assignmentId), + eq(crmUserRoleAssignments.organizationId, organizationId) + ) + }); + + if (!current || current.deletedAt) { + throw new AuthError('CRM role assignment not found', 404); + } + + await db + .update(crmUserRoleAssignments) + .set({ + isActive: false, + isPrimary: false, + deletedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(crmUserRoleAssignments.id, assignmentId)); + + await ensurePrimaryAssignmentForUser(organizationId, current.userId); + + await auditAction({ + organizationId, + userId: actorUserId, + entityType: 'crm_user_role_assignment', + entityId: assignmentId, + action: 'delete', + beforeData: current + }); +} + +export async function listCrmRoleAssignmentsForUser( + organizationId: string, + actorUserId: string, + userId: string +) { + const data = await listResolvedCrmRoleAssignments(organizationId, actorUserId); + + return data.assignments.filter((assignment) => assignment.userId === userId); +} + +export function parseRoleAssignmentScopeMode(value: string): CrmRoleAssignmentScopeMode { + if (!isRoleAssignmentScopeMode(value)) { + throw new AuthError('Invalid role assignment scope mode', 400); + } + + return value; +} + +export async function getRoleAssignmentReferenceSummaries(organizationId: string) { + const [roleProfiles, usersInOrganization, branches, productTypes] = await Promise.all([ + db + .select({ + id: crmRoleProfiles.id, + code: crmRoleProfiles.code, + name: crmRoleProfiles.name + }) + .from(crmRoleProfiles) + .where( + and( + eq(crmRoleProfiles.organizationId, organizationId), + eq(crmRoleProfiles.isActive, true), + isNull(crmRoleProfiles.deletedAt) + ) + ) + .orderBy(asc(crmRoleProfiles.name)), + db + .select({ + id: users.id, + name: users.name, + email: users.email + }) + .from(memberships) + .innerJoin(users, eq(users.id, memberships.userId)) + .where(eq(memberships.organizationId, organizationId)) + .orderBy(asc(users.name)), + getUserBranches(), + getActiveOptionsByCategory('crm_product_type', { organizationId }) + ]); + + return { + roleProfiles, + users: [...new Map(usersInOrganization.map((row) => [row.id, row])).values()], + branches: branches.map((branch) => ({ + id: branch.id, + code: branch.code, + label: branch.name + })), + productTypes: productTypes.map((option) => ({ + id: option.id, + code: option.code, + label: option.label + })) + }; +} diff --git a/src/lib/auth/crm-access.ts b/src/lib/auth/crm-access.ts index 7b37a03..8f7b218 100644 --- a/src/lib/auth/crm-access.ts +++ b/src/lib/auth/crm-access.ts @@ -1,18 +1,23 @@ import { and, eq, isNull } from 'drizzle-orm'; -import { crmRoleProfiles, memberships } from '@/db/schema'; +import { crmRoleProfiles, crmUserRoleAssignments, memberships } from '@/db/schema'; +import { ensureCrmRoleAssignmentsBackfillForMembership } from '@/features/foundation/crm-role-assignments/server/service'; import { db } from '@/lib/db'; import { ALL_PERMISSIONS, type CrmApprovalAuthority, + type CrmEffectiveScopeMode, type CrmOwnershipScope, + type CrmRoleAssignmentScopeMode, type CrmScopeMode, getDefaultBusinessRole, + getDefaultPermissions, + getMembershipBasePermissions, getRoleProfileDefinition, isApprovalAuthority, isMembershipRole, isOwnershipScope, + isRoleAssignmentScopeMode, isScopeMode, - resolveEffectivePermissions, type MembershipRole, type Permission, type SystemRole @@ -21,12 +26,13 @@ import { export interface ResolvedCrmAccess { membershipRole: MembershipRole; businessRole: string; + businessRoles: string[]; permissions: Permission[]; branchScopeIds: string[]; productTypeScopeIds: string[]; ownershipScope: CrmOwnershipScope; - branchScopeMode: CrmScopeMode; - productScopeMode: CrmScopeMode; + branchScopeMode: CrmEffectiveScopeMode; + productScopeMode: CrmEffectiveScopeMode; approvalAuthority: CrmApprovalAuthority; roleProfileName: string | null; } @@ -53,6 +59,153 @@ function normalizeStringArray(value: unknown) { : []; } +const OWNERSHIP_SCOPE_RANK: Record = { + monitor: 0, + own: 1, + team: 2, + organization: 3 +}; + +const APPROVAL_AUTHORITY_RANK: Record = { + none: 0, + manager: 1, + department: 2, + final: 3 +}; + +function getMostPermissiveOwnershipScope(scopes: CrmOwnershipScope[]) { + return scopes.reduce( + (current, scope) => + OWNERSHIP_SCOPE_RANK[scope] > OWNERSHIP_SCOPE_RANK[current] ? scope : current, + 'own' + ); +} + +function getMostPermissiveApprovalAuthority(authorities: CrmApprovalAuthority[]) { + return authorities.reduce( + (current, authority) => + APPROVAL_AUTHORITY_RANK[authority] > APPROVAL_AUTHORITY_RANK[current] + ? authority + : current, + 'none' + ); +} + +function resolveEffectiveScopeFromAssignments(input: { + assignmentMode: CrmRoleAssignmentScopeMode; + assignmentIds: string[]; + profileMode: CrmScopeMode; +}) { + if (input.assignmentMode === 'all') { + return { mode: 'all' as const, ids: [] as string[] }; + } + + if (input.assignmentMode === 'none') { + return { mode: 'none' as const, ids: [] as string[] }; + } + + if (input.assignmentMode === 'selected') { + return input.assignmentIds.length + ? { mode: 'assigned' as const, ids: input.assignmentIds } + : { mode: 'none' as const, ids: [] as string[] }; + } + + if (input.profileMode === 'all') { + return { mode: 'all' as const, ids: [] as string[] }; + } + + return input.assignmentIds.length + ? { mode: 'assigned' as const, ids: input.assignmentIds } + : { mode: 'none' as const, ids: [] as string[] }; +} + +function combineEffectiveScopes( + scopes: Array<{ mode: CrmEffectiveScopeMode; ids: string[] }> +): { mode: CrmEffectiveScopeMode; ids: string[] } { + if (scopes.some((scope) => scope.mode === 'all')) { + return { mode: 'all', ids: [] }; + } + + const mergedIds = [...new Set(scopes.flatMap((scope) => scope.ids))]; + + if (mergedIds.length > 0) { + return { + mode: 'assigned', + ids: mergedIds + }; + } + + return { mode: 'none', ids: [] }; +} + +function buildLegacyFallbackAccess(input: { + systemRole: SystemRole; + membership: typeof memberships.$inferSelect; + membershipRole: MembershipRole; + businessRole: string; + roleProfile: typeof crmRoleProfiles.$inferSelect | undefined; +}) { + const defaultDefinition = getRoleProfileDefinition(input.businessRole); + const ownershipScope: CrmOwnershipScope = isOwnershipScope(input.roleProfile?.ownershipScope ?? '') + ? (input.roleProfile!.ownershipScope as CrmOwnershipScope) + : (defaultDefinition?.ownershipScope ?? 'own'); + const branchScopeMode = + normalizeStringArray(input.membership.branchScopeIds).length > 0 + ? 'assigned' + : (isScopeMode(input.roleProfile?.branchScopeMode ?? '') + ? (input.roleProfile!.branchScopeMode as CrmScopeMode) + : (defaultDefinition?.branchScopeMode ?? 'assigned')); + const productScopeMode = + normalizeStringArray(input.membership.productTypeScopeIds).length > 0 + ? 'assigned' + : (isScopeMode(input.roleProfile?.productScopeMode ?? '') + ? (input.roleProfile!.productScopeMode as CrmScopeMode) + : (defaultDefinition?.productScopeMode ?? 'assigned')); + const approvalAuthority: CrmApprovalAuthority = isApprovalAuthority( + input.roleProfile?.approvalAuthority ?? '' + ) + ? (input.roleProfile!.approvalAuthority as CrmApprovalAuthority) + : (defaultDefinition?.approvalAuthority ?? 'none'); + const permissions = + input.systemRole === 'super_admin' + ? ALL_PERMISSIONS + : [ + ...new Set([ + ...getDefaultPermissions(input.membershipRole, input.businessRole), + ...(input.roleProfile?.permissions?.filter( + (value): value is Permission => typeof value === 'string' + ) ?? []), + ...(input.membership.permissions?.filter( + (value): value is Permission => typeof value === 'string' + ) ?? []) + ]) + ]; + + return { + membershipRole: input.membershipRole, + businessRole: input.businessRole, + businessRoles: [input.businessRole], + permissions, + branchScopeIds: normalizeStringArray(input.membership.branchScopeIds), + productTypeScopeIds: normalizeStringArray(input.membership.productTypeScopeIds), + ownershipScope, + branchScopeMode: + branchScopeMode === 'all' + ? 'all' + : normalizeStringArray(input.membership.branchScopeIds).length > 0 + ? 'assigned' + : 'none', + productScopeMode: + productScopeMode === 'all' + ? 'all' + : normalizeStringArray(input.membership.productTypeScopeIds).length > 0 + ? 'assigned' + : 'none', + approvalAuthority, + roleProfileName: input.roleProfile?.name ?? defaultDefinition?.name ?? null + } satisfies ResolvedCrmAccess; +} + export async function resolveCrmMembershipAccess(input: { systemRole: SystemRole; organizationId: string; @@ -62,6 +215,7 @@ export async function resolveCrmMembershipAccess(input: { return { membershipRole: 'admin', businessRole: input.membership.businessRole, + businessRoles: [input.membership.businessRole], permissions: ALL_PERMISSIONS, branchScopeIds: [], productTypeScopeIds: [], @@ -77,6 +231,12 @@ export async function resolveCrmMembershipAccess(input: { ? input.membership.role : 'user'; const businessRole = input.membership.businessRole || getDefaultBusinessRole(membershipRole); + await ensureCrmRoleAssignmentsBackfillForMembership( + input.organizationId, + input.membership, + input.membership.userId + ); + const [roleProfile] = await db .select() .from(crmRoleProfiles) @@ -89,40 +249,103 @@ export async function resolveCrmMembershipAccess(input: { ) ) .limit(1); - const defaultDefinition = getRoleProfileDefinition(businessRole); + const assignmentRows = await db + .select({ + assignment: crmUserRoleAssignments, + profileCode: crmRoleProfiles.code, + profileName: crmRoleProfiles.name, + profilePermissions: crmRoleProfiles.permissions, + profileOwnershipScope: crmRoleProfiles.ownershipScope, + profileBranchScopeMode: crmRoleProfiles.branchScopeMode, + profileProductScopeMode: crmRoleProfiles.productScopeMode, + profileApprovalAuthority: crmRoleProfiles.approvalAuthority + }) + .from(crmUserRoleAssignments) + .innerJoin(crmRoleProfiles, eq(crmRoleProfiles.id, crmUserRoleAssignments.roleProfileId)) + .where( + and( + eq(crmUserRoleAssignments.organizationId, input.organizationId), + eq(crmUserRoleAssignments.userId, input.membership.userId), + eq(crmUserRoleAssignments.isActive, true), + isNull(crmUserRoleAssignments.deletedAt), + isNull(crmRoleProfiles.deletedAt) + ) + ); - 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'); + if (!assignmentRows.length) { + return buildLegacyFallbackAccess({ + systemRole: input.systemRole, + membership: input.membership, + membershipRole, + businessRole, + roleProfile + }); + } + + const primaryAssignment = + assignmentRows.find((row) => row.assignment.isPrimary) ?? assignmentRows[0]; + const permissions = [ + ...new Set([ + ...getMembershipBasePermissions(membershipRole), + ...assignmentRows.flatMap((row) => + (row.profilePermissions ?? []).filter( + (value): value is Permission => typeof value === 'string' + ) + ), + ...(input.membership.permissions?.filter( + (value): value is Permission => typeof value === 'string' + ) ?? []) + ]) + ]; + const ownershipScopes = assignmentRows.map((row) => + isOwnershipScope(row.profileOwnershipScope) + ? (row.profileOwnershipScope as CrmOwnershipScope) + : 'own' + ); + const approvalAuthorities = assignmentRows.map((row) => + isApprovalAuthority(row.profileApprovalAuthority) + ? (row.profileApprovalAuthority as CrmApprovalAuthority) + : 'none' + ); + const branchScope = combineEffectiveScopes( + assignmentRows.map((row) => + resolveEffectiveScopeFromAssignments({ + assignmentMode: isRoleAssignmentScopeMode(row.assignment.branchScopeMode) + ? row.assignment.branchScopeMode + : 'inherit', + assignmentIds: normalizeStringArray(row.assignment.branchScopeIds), + profileMode: isScopeMode(row.profileBranchScopeMode) + ? (row.profileBranchScopeMode as CrmScopeMode) + : 'assigned' + }) + ) + ); + const productScope = combineEffectiveScopes( + assignmentRows.map((row) => + resolveEffectiveScopeFromAssignments({ + assignmentMode: isRoleAssignmentScopeMode(row.assignment.productTypeScopeMode) + ? row.assignment.productTypeScopeMode + : 'inherit', + assignmentIds: normalizeStringArray(row.assignment.productTypeScopeIds), + profileMode: isScopeMode(row.profileProductScopeMode) + ? (row.profileProductScopeMode as CrmScopeMode) + : 'assigned' + }) + ) + ); 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 + businessRole: primaryAssignment?.profileCode ?? businessRole, + businessRoles: assignmentRows.map((row) => row.profileCode), + permissions, + branchScopeIds: branchScope.ids, + productTypeScopeIds: productScope.ids, + ownershipScope: getMostPermissiveOwnershipScope(ownershipScopes), + branchScopeMode: branchScope.mode, + productScopeMode: productScope.mode, + approvalAuthority: getMostPermissiveApprovalAuthority(approvalAuthorities), + roleProfileName: primaryAssignment?.profileName ?? roleProfile?.name ?? null }; } @@ -131,10 +354,14 @@ export function hasBranchScopeAccess(access: BranchScopedAccess, branchId?: stri return true; } - if (access.branchScopeMode === 'all' || access.branchScopeIds.length === 0) { + if (access.branchScopeMode === 'all') { return true; } + if (access.branchScopeMode === 'none' || access.branchScopeIds.length === 0) { + return false; + } + return access.branchScopeIds.includes(branchId); } @@ -143,9 +370,13 @@ export function hasProductScopeAccess(access: ProductScopedAccess, productTypeId return true; } - if (access.productScopeMode === 'all' || access.productTypeScopeIds.length === 0) { + if (access.productScopeMode === 'all') { return true; } + if (access.productScopeMode === 'none' || access.productTypeScopeIds.length === 0) { + return false; + } + return access.productTypeScopeIds.includes(productTypeId); } diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index 25d8e31..6371a46 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -11,6 +11,7 @@ export const BUSINESS_ROLES = [ ] as const; export const OWNERSHIP_SCOPES = ['own', 'team', 'organization', 'monitor'] as const; export const SCOPE_MODES = ['all', 'assigned'] as const; +export const ROLE_ASSIGNMENT_SCOPE_MODES = ['all', 'selected', 'none', 'inherit'] as const; export const APPROVAL_AUTHORITIES = ['none', 'manager', 'department', 'final'] as const; export type SystemRole = (typeof SYSTEM_ROLES)[number]; @@ -18,6 +19,8 @@ export type MembershipRole = (typeof MEMBERSHIP_ROLES)[number]; export type BusinessRole = string; export type CrmOwnershipScope = (typeof OWNERSHIP_SCOPES)[number]; export type CrmScopeMode = (typeof SCOPE_MODES)[number]; +export type CrmRoleAssignmentScopeMode = (typeof ROLE_ASSIGNMENT_SCOPE_MODES)[number]; +export type CrmEffectiveScopeMode = 'all' | 'assigned' | 'none'; export type CrmApprovalAuthority = (typeof APPROVAL_AUTHORITIES)[number]; export const PERMISSIONS = { @@ -91,6 +94,11 @@ export const PERMISSIONS = { crmRoleUpdate: 'crm.role.update', crmRoleDelete: 'crm.role.delete', crmRoleAssign: 'crm.role.assign', + crmRoleAssignmentRead: 'crm.role.assignment.read', + crmRoleAssignmentCreate: 'crm.role.assignment.create', + crmRoleAssignmentUpdate: 'crm.role.assignment.update', + crmRoleAssignmentDelete: 'crm.role.assignment.delete', + crmRoleAssignmentManage: 'crm.role.assignment.manage', crmMasterOptionRead: 'crm.master_option.read', crmMasterOptionCreate: 'crm.master_option.create', crmMasterOptionUpdate: 'crm.master_option.update', @@ -249,6 +257,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record; @@ -52,6 +56,10 @@ declare module 'next-auth/jwt' { slug: string; role: string; businessRole: string; + businessRoles: string[]; + branchScopeIds: string[]; + productTypeScopeIds: string[]; + ownershipScope: string; plan: string; imageUrl: string | null; }>;