task-d.5.3

This commit is contained in:
phaichayon
2026-06-24 14:23:15 +07:00
parent 350239b307
commit 0c28735c90
61 changed files with 11019 additions and 173 deletions

View File

@@ -0,0 +1,108 @@
# Task D.5.3: Lead Assignment -> Create Enquiry Foundation
## Objective
Establish the first production lead-assignment flow that preserves `crm_leads` as the
marketing-owned source record while creating or reusing a linked `crm_enquiries` record for the
sales execution workspace.
## Review Completed
- `AGENTS.md`
- `docs/standards/project-foundations.md`
- `docs/standards/task-catalog.md`
- `docs/adr/0018-lead-enquiry-domain-separation.md`
- `docs/implementation/task-d5-lead-enquiry-domain-separation.md`
- `docs/implementation/task-d52-lead-domain-service-api-foundation.md`
- `docs/security/crm-authorization-boundaries.md`
- `src/features/crm/leads/server/service.ts`
- `src/features/crm/enquiries/server/service.ts`
- `src/features/crm/customers/server/service.ts`
- `src/features/foundation/audit-log/service.ts`
- `src/features/foundation/document-sequence/service.ts`
## What Changed
- Added lead bootstrap and assignment fields to `crm_leads`:
- `description`
- `product_type`
- `priority`
- `estimated_value`
- `assigned_sales_owner_id`
- `assigned_at`
- `assigned_by`
- `assignment_remark`
- Added [assignment.service.ts](C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/server/assignment.service.ts)
with:
- `assignLead()`
- internal `createEnquiryFromLead()`
- Added [route.ts](C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/leads/[id]/assign/route.ts)
for `POST /api/crm/leads/[id]/assign`
- Extended lead DTOs and schemas so new lead records can carry the data needed to bootstrap an
enquiry
- Added `assigned` to seeded `crm_lead_status`
- Tightened enquiry-owner validation so assignees must be sales-assignable members, not merely any
organization member
## Assignment Behavior
- route permission is `crm.lead.assign`
- service validates:
- lead exists and is active
- actor can access the lead through resolved CRM scope
- assignee belongs to the same organization and is a sales-assignable member
- if an active enquiry already exists for the lead:
- no second enquiry is created
- the existing enquiry is returned
- lead assignment metadata is synchronized
- if no active enquiry exists:
- enquiry code generation reuses `generateNextDocumentCode()` with `documentType = crm_enquiry`
- enquiry is created with `lead_id = crm_leads.id`
- enquiry owner compatibility fields use the existing enquiry model:
- `assigned_to_user_id`
- `assigned_at`
- `assigned_by`
- `assignment_remark`
## Lead -> Enquiry Bootstrap Mapping
When creating the enquiry from a lead, the flow copies:
- `customerId`
- `contactId`
- `description`
- `projectName`
- `projectLocation`
- `productType`
- `estimatedValue`
- `priority`
The enquiry title is derived from `lead.projectName` and falls back to `Lead <code>` when the
project name is empty.
## Audit Actions
This task records:
- `assign_lead` on `crm_lead`
- `create_enquiry_from_lead` on `crm_enquiry`
Captured linkage includes:
- `leadId`
- `enquiryId`
- `salesOwnerId`
## Compatibility Notes
This task intentionally does not change:
- quotation flows
- approval flows
- dashboard datasets
- reporting datasets
- won/lost lifecycle flows
- legacy enquiry list/detail APIs outside the stricter assignee validation
`crm_enquiries.pipeline_stage` remains in place for compatibility, but lead assignment now creates a
dedicated enquiry record instead of mutating a lead record in place.

View File

@@ -0,0 +1,657 @@
CREATE TABLE "crm_approval_actions" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"approval_request_id" text NOT NULL,
"step_number" integer NOT NULL,
"action" text NOT NULL,
"remark" text,
"acted_by" text NOT NULL,
"acted_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 TABLE "crm_approval_requests" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"workflow_id" text NOT NULL,
"entity_type" text NOT NULL,
"entity_id" text NOT NULL,
"current_step" integer DEFAULT 1 NOT NULL,
"status" text NOT NULL,
"requested_by" text NOT NULL,
"requested_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "crm_approval_steps" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"workflow_id" text NOT NULL,
"step_number" integer NOT NULL,
"role_code" text NOT NULL,
"role_name" text NOT NULL,
"is_required" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "crm_approval_workflows" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"code" text NOT NULL,
"name" text NOT NULL,
"entity_type" text NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "crm_contact_shares" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"contact_id" text NOT NULL,
"shared_to_user_id" text NOT NULL,
"shared_by_user_id" text NOT NULL,
"shared_at" timestamp with time zone DEFAULT now() NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"remark" text,
"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 TABLE "crm_customer_contacts" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"customer_id" text NOT NULL,
"name" text NOT NULL,
"position" text,
"department" text,
"phone" text,
"mobile" text,
"email" text,
"is_primary" boolean DEFAULT false NOT NULL,
"notes" text,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"created_by" text NOT NULL,
"updated_by" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "crm_customer_owner_history" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"customer_id" text NOT NULL,
"old_owner_user_id" text,
"new_owner_user_id" text,
"changed_by" text NOT NULL,
"changed_at" timestamp with time zone DEFAULT now() NOT NULL,
"remark" text,
"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 TABLE "crm_customers" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"branch_id" text,
"code" text NOT NULL,
"name" text NOT NULL,
"abbr" text,
"tax_id" text,
"customer_type" text NOT NULL,
"customer_status" text NOT NULL,
"address" text,
"province" text,
"district" text,
"sub_district" text,
"postal_code" text,
"country" text,
"phone" text,
"fax" text,
"email" text,
"website" text,
"lead_channel" text,
"customer_group" text,
"customer_sub_group" text,
"owner_user_id" text,
"owner_assigned_at" timestamp with time zone,
"owner_assigned_by" text,
"notes" text,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"created_by" text NOT NULL,
"updated_by" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "crm_document_artifacts" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"entity_type" text NOT NULL,
"entity_id" text NOT NULL,
"document_type" text NOT NULL,
"artifact_type" text NOT NULL,
"template_version_id" text,
"storage_provider" text NOT NULL,
"storage_key" text NOT NULL,
"file_name" text NOT NULL,
"content_type" text NOT NULL,
"file_size" integer,
"checksum" text,
"status" text DEFAULT 'active' NOT NULL,
"generated_by" text NOT NULL,
"generated_at" timestamp with time zone DEFAULT now() NOT NULL,
"locked_at" timestamp with time zone,
"locked_by" text,
"voided_at" timestamp with time zone,
"voided_by" text,
"void_reason" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "crm_document_template_mappings" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"template_version_id" text NOT NULL,
"placeholder_key" text NOT NULL,
"source_path" text NOT NULL,
"data_type" text NOT NULL,
"sheet_name" text,
"default_value" text,
"format_mask" text,
"sort_order" integer DEFAULT 0 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 TABLE "crm_document_template_table_columns" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"mapping_id" text NOT NULL,
"column_name" text NOT NULL,
"source_field" text NOT NULL,
"column_letter" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"format_mask" text,
"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 TABLE "crm_document_template_versions" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"template_id" text NOT NULL,
"version" text NOT NULL,
"file_path" text,
"schema_json" jsonb NOT NULL,
"preview_image_url" text,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"created_by" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "crm_document_templates" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"document_type" text NOT NULL,
"product_type" text NOT NULL,
"file_type" text NOT NULL,
"template_name" text NOT NULL,
"description" text,
"is_default" boolean DEFAULT false NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"created_by" text NOT NULL,
"updated_by" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "crm_enquiries" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"branch_id" text,
"lead_id" text,
"code" text NOT NULL,
"customer_id" text NOT NULL,
"contact_id" text,
"title" text NOT NULL,
"description" text,
"requirement" text,
"project_name" text,
"project_location" text,
"product_type" text NOT NULL,
"status" text NOT NULL,
"priority" text NOT NULL,
"lead_channel" text,
"estimated_value" double precision,
"chance_percent" integer,
"expected_close_date" timestamp with time zone,
"competitor" text,
"source" text,
"notes" text,
"is_hot_project" boolean DEFAULT false NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"pipeline_stage" text DEFAULT 'lead' NOT NULL,
"closed_won_at" timestamp with time zone,
"closed_lost_at" timestamp with time zone,
"closed_by_user_id" text,
"po_number" text,
"po_date" timestamp with time zone,
"po_amount" double precision,
"po_currency" text,
"lost_reason" text,
"lost_competitor" text,
"lost_remark" text,
"assigned_to_user_id" text,
"assigned_at" timestamp with time zone,
"assigned_by" text,
"assignment_remark" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"created_by" text NOT NULL,
"updated_by" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "crm_enquiry_attachments" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"enquiry_id" text NOT NULL,
"category" text NOT NULL,
"file_name" text NOT NULL,
"original_file_name" text NOT NULL,
"storage_provider" text NOT NULL,
"storage_key" text NOT NULL,
"file_size" integer,
"file_type" text,
"description" text,
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL,
"uploaded_by" text 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 TABLE "crm_enquiry_customers" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"enquiry_id" text NOT NULL,
"customer_id" text NOT NULL,
"role" text NOT NULL,
"remark" text,
"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 TABLE "crm_enquiry_followups" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"enquiry_id" text NOT NULL,
"followup_date" timestamp with time zone NOT NULL,
"followup_type" text NOT NULL,
"contact_id" text,
"outcome" text,
"notes" text,
"next_followup_date" timestamp with time zone,
"next_action" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"created_by" text NOT NULL,
"updated_by" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "crm_leads" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"branch_id" text,
"code" text NOT NULL,
"customer_id" text,
"contact_id" text,
"description" text,
"project_name" text,
"project_location" text,
"product_type" text,
"priority" text,
"estimated_value" double precision,
"awareness_id" text,
"status" text DEFAULT 'new_job' NOT NULL,
"followup_status" text,
"lost_reason" text,
"outcome" text DEFAULT 'open' NOT NULL,
"owner_marketing_user_id" text,
"assigned_sales_owner_id" text,
"assigned_at" timestamp with time zone,
"assigned_by" text,
"assignment_remark" text,
"created_by" text 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 TABLE "crm_quotation_attachments" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"quotation_id" text NOT NULL,
"file_name" text NOT NULL,
"original_file_name" text NOT NULL,
"file_path" text NOT NULL,
"file_size" integer,
"file_type" text,
"description" text,
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL,
"uploaded_by" text 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 TABLE "crm_quotation_customers" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"quotation_id" text NOT NULL,
"customer_id" text NOT NULL,
"role" text NOT NULL,
"is_primary" boolean DEFAULT false NOT NULL,
"remark" text,
"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 TABLE "crm_quotation_followups" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"quotation_id" text NOT NULL,
"followup_date" timestamp with time zone NOT NULL,
"followup_type" text NOT NULL,
"contact_id" text,
"outcome" text,
"notes" text,
"next_followup_date" timestamp with time zone,
"next_action" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"created_by" text NOT NULL,
"updated_by" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "crm_quotation_items" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"quotation_id" text NOT NULL,
"item_number" integer NOT NULL,
"product_type" text NOT NULL,
"description" text NOT NULL,
"quantity" double precision DEFAULT 0 NOT NULL,
"unit" text,
"unit_price" double precision DEFAULT 0 NOT NULL,
"discount" double precision DEFAULT 0 NOT NULL,
"discount_type" text,
"tax_rate" double precision DEFAULT 0 NOT NULL,
"total_price" double precision DEFAULT 0 NOT NULL,
"notes" text,
"sort_order" integer DEFAULT 0 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 TABLE "crm_quotation_topic_items" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"topic_id" text NOT NULL,
"content" text NOT NULL,
"sort_order" integer DEFAULT 0 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 TABLE "crm_quotation_topics" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"quotation_id" text NOT NULL,
"topic_type" text NOT NULL,
"title" text NOT NULL,
"sort_order" integer DEFAULT 0 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 TABLE "crm_quotations" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"branch_id" text,
"code" text NOT NULL,
"enquiry_id" text,
"customer_id" text NOT NULL,
"contact_id" text,
"quotation_date" timestamp with time zone NOT NULL,
"valid_until" timestamp with time zone,
"quotation_type" text NOT NULL,
"project_name" text,
"project_location" text,
"attention" text,
"reference" text,
"notes" text,
"status" text NOT NULL,
"revision" integer DEFAULT 0 NOT NULL,
"parent_quotation_id" text,
"revision_remark" text,
"currency" text NOT NULL,
"exchange_rate" double precision DEFAULT 1 NOT NULL,
"subtotal" double precision DEFAULT 0 NOT NULL,
"discount" double precision DEFAULT 0 NOT NULL,
"discount_type" text,
"tax_rate" double precision DEFAULT 0 NOT NULL,
"tax_amount" double precision DEFAULT 0 NOT NULL,
"total_amount" double precision DEFAULT 0 NOT NULL,
"chance_percent" integer,
"is_hot_project" boolean DEFAULT false NOT NULL,
"competitor" text,
"salesman_id" text,
"is_sent" boolean DEFAULT false NOT NULL,
"sent_at" timestamp with time zone,
"sent_via" text,
"approved_at" timestamp with time zone,
"approved_artifact_id" text,
"approved_pdf_url" text,
"approved_snapshot" jsonb,
"approved_template_version_id" text,
"accepted_at" timestamp with time zone,
"rejected_at" timestamp with time zone,
"rejection_reason" text,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"created_by" text NOT NULL,
"updated_by" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "crm_report_definitions" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"code" text NOT NULL,
"name" text NOT NULL,
"description" text,
"category" text NOT NULL,
"is_system" boolean DEFAULT true NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "crm_role_profiles" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"code" text NOT NULL,
"name" text NOT NULL,
"description" text,
"permissions" text[] DEFAULT '{}' NOT NULL,
"branch_scope_mode" text DEFAULT 'assigned' NOT NULL,
"product_scope_mode" text DEFAULT 'assigned' NOT NULL,
"ownership_scope" text DEFAULT 'own' NOT NULL,
"approval_authority" text DEFAULT 'none' NOT NULL,
"is_system" boolean DEFAULT false NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"created_by" text NOT NULL,
"updated_by" text NOT NULL
);
--> statement-breakpoint
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 TABLE "document_sequences" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"branch_id" text DEFAULT '' NOT NULL,
"document_type" text NOT NULL,
"prefix" text NOT NULL,
"period" text NOT NULL,
"current_number" integer DEFAULT 0 NOT NULL,
"padding_length" integer DEFAULT 3 NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memberships" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"organization_id" text NOT NULL,
"role" text DEFAULT 'user' NOT NULL,
"business_role" text DEFAULT 'sales_support' NOT NULL,
"permissions" text[] DEFAULT '{}' NOT NULL,
"branch_scope_ids" text[] DEFAULT '{}' NOT NULL,
"product_type_scope_ids" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ms_options" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"category" text NOT NULL,
"code" text NOT NULL,
"label" text NOT NULL,
"value" text,
"parent_id" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"metadata" jsonb,
"deleted_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organizations" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"image_url" text,
"plan" text DEFAULT 'free' NOT NULL,
"created_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "products" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "products_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"organization_id" text NOT NULL,
"name" text NOT NULL,
"category" text NOT NULL,
"description" text NOT NULL,
"photo_url" text NOT NULL,
"price" double precision NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tr_audit_logs" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"branch_id" text,
"user_id" text NOT NULL,
"entity_type" text NOT NULL,
"entity_id" text NOT NULL,
"action" text NOT NULL,
"before_data" jsonb,
"after_data" jsonb,
"request_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"password_hash" text NOT NULL,
"system_role" text DEFAULT 'user' NOT NULL,
"image" text,
"active_organization_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "crm_approval_steps_workflow_step_idx" ON "crm_approval_steps" USING btree ("workflow_id","step_number");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_approval_workflows_org_code_idx" ON "crm_approval_workflows" USING btree ("organization_id","code");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_contact_shares_org_contact_shared_user_idx" ON "crm_contact_shares" USING btree ("organization_id","contact_id","shared_to_user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_customers_org_code_idx" ON "crm_customers" USING btree ("organization_id","code");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_document_template_mappings_version_placeholder_idx" ON "crm_document_template_mappings" USING btree ("template_version_id","placeholder_key");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_document_template_table_columns_mapping_name_idx" ON "crm_document_template_table_columns" USING btree ("mapping_id","column_name");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_document_template_versions_template_version_idx" ON "crm_document_template_versions" USING btree ("template_id","version");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_document_templates_org_doc_product_file_name_idx" ON "crm_document_templates" USING btree ("organization_id","document_type","product_type","file_type","template_name");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_enquiries_org_code_idx" ON "crm_enquiries" USING btree ("organization_id","code");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_leads_org_code_idx" ON "crm_leads" USING btree ("organization_id","code");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_quotations_org_code_idx" ON "crm_quotations" USING btree ("organization_id","code");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_report_definitions_org_code_idx" ON "crm_report_definitions" USING btree ("organization_id","code");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_role_profiles_org_code_idx" ON "crm_role_profiles" USING btree ("organization_id","code");--> 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");--> statement-breakpoint
CREATE UNIQUE INDEX "document_sequences_org_doc_period_branch_idx" ON "document_sequences" USING btree ("organization_id","document_type","period","branch_id");--> statement-breakpoint
CREATE UNIQUE INDEX "ms_options_org_category_code_idx" ON "ms_options" USING btree ("organization_id","category","code");--> statement-breakpoint
CREATE UNIQUE INDEX "organizations_slug_idx" ON "organizations" USING btree ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");

File diff suppressed because it is too large Load Diff

View File

@@ -5,155 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1781148450992,
"tag": "0000_icy_lizard",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1781171200406,
"tag": "0001_orange_mandarin",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1781498575433,
"tag": "0002_plain_anthem",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1781505431077,
"tag": "0003_blushing_bruce_banner",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1781511534267,
"tag": "0004_worthless_ender_wiggin",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1781575976324,
"tag": "0005_numerous_blob",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1781579746622,
"tag": "0006_worried_mordo",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1781589458455,
"tag": "0007_luxuriant_malice",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1781595962545,
"tag": "0008_clean_justin_hammer",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1781603421415,
"tag": "0009_lazy_shard",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1781675409847,
"tag": "0010_calm_wendell_rand",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1781747812297,
"tag": "0011_goofy_the_hood",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1781836261952,
"tag": "0012_simple_bucky",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1782097963376,
"tag": "0013_great_nicolaos",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1782099743528,
"tag": "0014_bored_valkyrie",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1782549600000,
"tag": "0015_customer_ownership_contact_shares",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1782795600000,
"tag": "0016_won_lost_lifecycle_governance",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1782121929212,
"tag": "0017_short_swordsman",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1782799200000,
"tag": "0018_report_foundation",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1782137095907,
"tag": "0019_sleepy_ultron",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1782262800000,
"tag": "0020_lead_foundation_schema",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1782279100730,
"tag": "0021_worried_nebula",
"when": 1782285058938,
"tag": "0000_swift_prism",
"breakpoints": true
}
]

View File

@@ -0,0 +1,9 @@
ALTER TABLE "crm_leads"
ADD COLUMN IF NOT EXISTS "description" text,
ADD COLUMN IF NOT EXISTS "product_type" text,
ADD COLUMN IF NOT EXISTS "priority" text,
ADD COLUMN IF NOT EXISTS "estimated_value" double precision,
ADD COLUMN IF NOT EXISTS "assigned_sales_owner_id" text,
ADD COLUMN IF NOT EXISTS "assigned_at" timestamp with time zone,
ADD COLUMN IF NOT EXISTS "assigned_by" text,
ADD COLUMN IF NOT EXISTS "assignment_remark" text;

View File

@@ -0,0 +1,8 @@
ALTER TABLE "crm_leads" ADD COLUMN "description" text;--> statement-breakpoint
ALTER TABLE "crm_leads" ADD COLUMN "product_type" text;--> statement-breakpoint
ALTER TABLE "crm_leads" ADD COLUMN "priority" text;--> statement-breakpoint
ALTER TABLE "crm_leads" ADD COLUMN "estimated_value" double precision;--> statement-breakpoint
ALTER TABLE "crm_leads" ADD COLUMN "assigned_sales_owner_id" text;--> statement-breakpoint
ALTER TABLE "crm_leads" ADD COLUMN "assigned_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "crm_leads" ADD COLUMN "assigned_by" text;--> statement-breakpoint
ALTER TABLE "crm_leads" ADD COLUMN "assignment_remark" text;

View File

@@ -0,0 +1,331 @@
{
"id": "1e236d6a-ca42-4830-b230-67a7c233d87b",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"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": "'viewer'"
},
"permissions": {
"name": "permissions",
"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.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.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": {}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1781148450992,
"tag": "0000_icy_lizard",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1781171200406,
"tag": "0001_orange_mandarin",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1781498575433,
"tag": "0002_plain_anthem",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1781505431077,
"tag": "0003_blushing_bruce_banner",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1781511534267,
"tag": "0004_worthless_ender_wiggin",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1781575976324,
"tag": "0005_numerous_blob",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1781579746622,
"tag": "0006_worried_mordo",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1781589458455,
"tag": "0007_luxuriant_malice",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1781595962545,
"tag": "0008_clean_justin_hammer",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1781603421415,
"tag": "0009_lazy_shard",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1781675409847,
"tag": "0010_calm_wendell_rand",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1781747812297,
"tag": "0011_goofy_the_hood",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1781836261952,
"tag": "0012_simple_bucky",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1782097963376,
"tag": "0013_great_nicolaos",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1782099743528,
"tag": "0014_bored_valkyrie",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1782549600000,
"tag": "0015_customer_ownership_contact_shares",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1782795600000,
"tag": "0016_won_lost_lifecycle_governance",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1782121929212,
"tag": "0017_short_swordsman",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1782799200000,
"tag": "0018_report_foundation",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1782137095907,
"tag": "0019_sleepy_ultron",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1782262800000,
"tag": "0020_lead_foundation_schema",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1782279100730,
"tag": "0021_worried_nebula",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1782286200000,
"tag": "0022_lead_assignment_foundation",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1782284237696,
"tag": "0023_yellow_carlie_cooper",
"breakpoints": true
}
]
}

537
plans/task-d.5.3.md Normal file
View File

@@ -0,0 +1,537 @@
# Task D.5.3: Lead Assignment → Create Enquiry Foundation
## Status
Planned
## Objective
Implement the first production transition from Lead into Enquiry.
This task establishes the business boundary between:
```txt
Marketing Domain
Lead
Sales Domain
Enquiry
```
A Lead remains the marketing-owned source record.
An Enquiry becomes the sales execution workspace.
This task introduces:
* Lead assignment workflow
* Enquiry creation from Lead
* Lead ↔ Enquiry relationship
* Initial ownership transfer
* Enquiry bootstrap generation
without changing quotation, dashboard, reporting, or approval behavior.
---
# Mandatory Review
Review:
* AGENTS.md
* Project Foundations Registry
* Task Catalog
* ADR-0018 Lead / Enquiry Domain Separation
* Task D.5
* Task D.5.1
* Task D.5.2
* Customer Ownership Foundation
* CRM Security Foundation
* Audit Foundation
* Document Sequence Foundation
---
# Existing Foundation
Verified available:
```txt
crm_leads
crm_enquiries
crm_enquiries.lead_id
crm_lead sequence
crm_enquiry sequence
Lead Service
Lead API
```
Do not recreate:
```txt
tables
migrations
seed data
master-option categories
```
---
# Business Model
## Before Assignment
Owner:
```txt
Marketing
```
Workspace:
```txt
Lead
```
---
## After Assignment
Owner:
```txt
Sales
```
Workspace:
```txt
Enquiry
```
---
## Relationship
```txt
1 Lead
0..N Enquiries
```
Lead remains immutable historical source.
Enquiries become sales execution records.
---
# Scope D5.3.1 Lead Assignment Service
Create:
```txt
src/features/crm/leads/server/assignment.service.ts
```
Required operation:
```txt
assignLead()
```
---
Input:
```ts
AssignLeadInput
```
Fields:
```ts
leadId
salesOwnerId
assignmentRemark
```
---
Validation:
```txt
lead exists
lead active
salesOwnerId valid
same organization
```
---
# Scope D5.3.2 Create Enquiry From Lead
Create:
```txt
createEnquiryFromLead()
```
inside assignment service.
---
Rules
Assignment automatically creates:
```txt
crm_enquiry
```
linked to:
```txt
crm_leads.id
```
through:
```txt
crm_enquiries.lead_id
```
---
Reuse:
```txt
generateNextDocumentCode()
```
with:
```txt
crm_enquiry
```
---
Expected:
```txt
EN2606-001
```
or existing enquiry format.
No custom numbering logic.
---
# Scope D5.3.3 Enquiry Bootstrap Mapping
When enquiry is created:
Copy:
```txt
customerId
contactId
projectName
projectLocation
productType
description
estimatedValue
priority
```
from Lead.
---
Do NOT copy:
```txt
marketing-only notes
internal marketing activity history
audit events
```
---
Lead remains unchanged.
---
# Scope D5.3.4 Lead Assignment API
Create:
```txt
POST /api/crm/leads/[id]/assign
```
---
Handler must call:
```txt
Lead Assignment Service
```
No direct DB access.
---
Response:
```ts
{
leadId: string;
enquiryId: string;
enquiryCode: string;
}
```
---
# Scope D5.3.5 Ownership Transfer
After assignment:
Lead:
```txt
status = assigned
```
---
Lead:
```txt
assigned_sales_owner_id
assigned_at
assigned_by
```
updated.
---
Enquiry:
```txt
owner_user_id
```
set to assigned salesperson.
---
Marketing remains able to:
```txt
view lead
view assignment result
```
but not manage enquiry.
---
# Scope D5.3.6 CRM Security
Reuse:
```txt
resolveCrmMembershipAccess()
```
Assignment permission:
```txt
lead.assign
```
Only:
```txt
Marketing Manager
CRM Manager
Admin
```
or equivalent resolved permission.
---
Do not check role strings directly.
---
# Scope D5.3.7 Duplicate Protection
Prevent:
```txt
multiple enquiry creation
```
for the same assignment action.
---
Rules:
If assignment already produced an active enquiry:
```txt
return existing enquiry
```
instead of creating a second one.
---
# Scope D5.3.8 Audit Logging
Reuse audit foundation.
Required actions:
```txt
assign_lead
create_enquiry_from_lead
```
---
Audit must capture:
```txt
leadId
enquiryId
salesOwnerId
```
---
# Scope D5.3.9 Legacy Compatibility
Do not modify:
```txt
quotation flow
approval flow
dashboard
reports
won/lost process
```
---
Current enquiry APIs must continue functioning.
---
# Deliverables
New:
```txt
src/features/crm/leads/server/assignment.service.ts
```
---
New API:
```txt
POST /api/crm/leads/[id]/assign
```
---
Documentation:
```txt
docs/implementation/task-d53-lead-assignment-create-enquiry-foundation.md
```
---
# Verification
Run:
```txt
npm exec tsc --noEmit
```
---
Verify:
```txt
Assign Lead
Create Enquiry
Reuse Existing Enquiry
Lead Status Update
Audit Logging
Security Enforcement
```
---
Verify:
```txt
Quotation APIs still work
Dashboard still works
Reports still work
```
---
# Explicit Non-Scope
Do not:
* create Lead UI
* create Opportunity UI
* create quotation conversion
* change approval workflow
* change reports
* change dashboard
* migrate historical data
* implement won/lost synchronization
These belong to later D.5 phases.
---
# Definition of Done
Task is complete when:
* Lead assignment API exists
* Assignment creates or reuses enquiry
* crm_enquiries.lead_id is populated
* Enquiry owner is assigned salesperson
* Lead assignment is audited
* Existing CRM functionality remains unchanged
Result:
```txt
Lead Assignment = Established
Lead → Enquiry Transition = Established
Ready for Opportunity Workspace Separation
```

506
plans/task-d.5.4.md Normal file
View File

@@ -0,0 +1,506 @@
# Task D.5.4: Lead / Enquiry Workspace Separation UI Foundation
## Status
Planned
## Objective
Separate the user-facing CRM workspace between Lead and Enquiry.
This task makes Lead and Enquiry visible as different working areas while preserving existing enquiry, quotation, dashboard, and reporting behavior.
Business boundary:
```txt
Lead = Marketing-owned source record
Enquiry = Sales execution workspace
```
This task focuses on UI routing, navigation, data-source separation, and safe compatibility wiring.
---
## Mandatory Review
Review before implementation:
* `AGENTS.md`
* `docs/standards/project-foundations.md`
* `docs/standards/task-catalog.md`
* `docs/adr/0018-lead-enquiry-domain-separation.md`
* `docs/implementation/task-d5-lead-enquiry-domain-separation.md`
* `docs/implementation/task-d51-lead-foundation-stabilization.md`
* `docs/implementation/task-d52-lead-domain-service-api-foundation.md`
* `docs/implementation/task-d53-lead-assignment-create-enquiry-foundation.md`
* `docs/security/crm-authorization-boundaries.md`
* Existing CRM navigation/sidebar configuration
* Existing enquiry list/detail/form UI
* Existing lead API routes
* Existing enquiry API routes
---
## Existing Foundation
Already available:
```txt
crm_leads
crm_enquiries
crm_enquiries.lead_id
/api/crm/leads
/api/crm/leads/[id]
/api/crm/leads/[id]/followups
/api/crm/leads/[id]/assign
existing enquiry APIs
existing quotation APIs
```
Do not recreate:
```txt
tables
migrations
seed data
service foundation
assignment service
```
---
## Workspace Rules
### Lead Workspace
Audience:
```txt
Marketing
Marketing Manager
CRM Manager
Admin
```
Purpose:
```txt
capture lead
qualify lead
record awareness/status/lost reason
assign lead to sales
view linked enquiry result
```
Source:
```txt
crm_leads
/api/crm/leads
```
---
### Enquiry Workspace
Audience:
```txt
Sales
Sales Manager
CRM Manager
Admin
```
Purpose:
```txt
work assigned opportunity
prepare for quotation
track sales execution
continue existing enquiry lifecycle
```
Source:
```txt
crm_enquiries
existing /api/crm/enquiries
```
---
## Scope D5.4.1 Navigation Separation
Update CRM navigation so Lead and Enquiry are separate menu items.
Add:
```txt
CRM > Leads
CRM > Enquiries
```
Rules:
* Lead menu uses lead permission/scope
* Enquiry menu keeps existing permission/scope
* Do not rename Enquiry menu globally if it breaks existing routes
* Do not remove old enquiry route yet
Suggested routes:
```txt
/dashboard/crm/leads
/dashboard/crm/leads/[id]
/dashboard/crm/enquiries
/dashboard/crm/enquiries/[id]
```
If project uses another route prefix, follow existing app route convention.
---
## Scope D5.4.2 Lead List Page
Create Lead list page using `/api/crm/leads`.
Required columns:
```txt
Lead Code
Customer
Project Name
Status
Awareness
Estimated Value
Suggested Sales Owner
Assigned Sales Owner
Created Date
```
Required actions:
```txt
View
Edit
Assign
Delete
```
Rules:
* Assign action appears only when user has assignment permission
* Delete action appears only when user has delete permission
* No direct enquiry mutation from Lead list
* Use existing shadcn/table/list conventions
* Use design tokens only
* Follow existing CRM page layout pattern
---
## Scope D5.4.3 Lead Detail Page
Create Lead detail page using `/api/crm/leads/[id]`.
Sections:
```txt
Lead Summary
Customer Information
Qualification Information
Follow-up History
Assignment Information
Linked Enquiries
Audit-safe Activity View
```
Actions:
```txt
Edit Lead
Assign Lead
Create Follow-up
Delete Lead
Open Linked Enquiry
```
Rules:
* Assignment creates or reuses enquiry through `/api/crm/leads/[id]/assign`
* After assignment, show linked enquiry
* Marketing can see assignment result but must not edit enquiry unless permitted
* Follow-up history uses D.5.2 audit-backed implementation until future ADR decides final persistence
---
## Scope D5.4.4 Lead Create / Edit Form
Create or adapt Lead form components.
Required fields:
```txt
Customer
Contact
Project Name
Project Location
Description
Product Type
Priority
Estimated Value
Awareness
Status
Lost Reason
Notes
```
Rules:
* Use master options for awareness, status, lost reason
* No hardcoded labels
* Product type must use existing option/source convention
* Validate with D.5.2 lead schemas
* On create, call `POST /api/crm/leads`
* On edit, call `PATCH /api/crm/leads/[id]`
---
## Scope D5.4.5 Lead Assignment UI
Create assignment dialog or sheet.
Input:
```txt
Sales Owner
Assignment Remark
```
Behavior:
```txt
POST /api/crm/leads/[id]/assign
```
After success:
```txt
show enquiry code
show Open Enquiry action
refresh lead detail
refresh lead list
```
Rules:
* Sales owner list must use assignable sales members only
* If API returns existing enquiry, show reuse message instead of duplicate-created message
* Do not create enquiry client-side
* Do not mutate `crm_enquiries` directly from UI
---
## Scope D5.4.6 Enquiry UI Compatibility
Keep current enquiry UI operational.
Add optional linked-lead display if `lead_id` exists:
```txt
Source Lead
Lead Code
Open Lead
```
Rules:
* Do not rewrite enquiry workflow
* Do not change quotation conversion behavior
* Do not change dashboard/report queries
* Do not remove `pipeline_stage`
* Existing enquiry list/detail must still work
---
## Scope D5.4.7 Permission / Visibility Handling
UI must respect backend permissions but not replace backend enforcement.
Lead UI:
```txt
crm.lead.read
crm.lead.create
crm.lead.update
crm.lead.delete
crm.lead.assign
```
Enquiry UI:
```txt
existing enquiry permissions
```
Rules:
* Hide unauthorized actions
* Handle 403 gracefully
* Do not check role strings directly if access helpers exist
* Follow existing CRM access resolver/client conventions
---
## Scope D5.4.8 Empty / Loading / Error States
Implement consistent states:
```txt
loading
empty
not authorized
not found
server error
validation error
```
Messaging:
* Lead empty state should guide Marketing to create first Lead
* Enquiry empty state should remain sales-oriented
* Assignment success should clearly identify created/reused enquiry
---
## Scope D5.4.9 Documentation
Create:
```txt
docs/implementation/task-d54-lead-enquiry-workspace-separation-ui-foundation.md
```
Document:
```txt
routes added
components added
APIs consumed
permission mapping
compatibility notes
known limitations
```
---
## Deliverables
New or updated pages:
```txt
src/app/**/crm/leads/page.tsx
src/app/**/crm/leads/[id]/page.tsx
```
New or updated components:
```txt
src/features/crm/leads/components/lead-list.tsx
src/features/crm/leads/components/lead-form.tsx
src/features/crm/leads/components/lead-detail.tsx
src/features/crm/leads/components/lead-assignment-dialog.tsx
src/features/crm/leads/components/lead-followup-panel.tsx
```
Updated:
```txt
CRM navigation/sidebar
Enquiry detail optional Source Lead display
```
Documentation:
```txt
docs/implementation/task-d54-lead-enquiry-workspace-separation-ui-foundation.md
```
---
## Explicit Non-Scope
Do not:
* create new tables
* create migrations
* change lead assignment service behavior
* change enquiry service behavior beyond read-only linked lead display
* change quotation flow
* change approval flow
* change dashboard datasets
* change reports
* implement won/lost synchronization
* replace audit-backed lead follow-up persistence
* migrate historical enquiry records into leads
These belong to later D.5 phases.
---
## Verification
Run:
```txt
npm exec tsc --noEmit
```
Optional if project supports:
```txt
npm run lint
npm run build
```
Manual verification:
```txt
Lead menu appears for permitted users
Lead menu hidden or blocked for unauthorized users
Lead list loads from /api/crm/leads
Lead create works
Lead edit works
Lead detail works
Lead assign creates or reuses enquiry
Linked enquiry opens from lead detail
Existing enquiry page still works
Existing quotation flow still works
Dashboard still works
Reports still work
```
---
## Definition of Done
Task is complete when:
* Lead and Enquiry appear as separate workspaces
* Lead UI uses Lead APIs
* Enquiry UI remains compatible
* Lead assignment UI creates or reuses enquiry through API
* Linked enquiry is visible from Lead detail
* Existing enquiry, quotation, dashboard, and reporting flows remain unchanged
Result:
```txt
Lead Workspace = Established
Enquiry Workspace = Preserved
Lead / Enquiry UI Boundary = Established
Ready for D.5.5 Opportunity / Sales Workspace Refinement
```

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { assignLeadSchema } from '@/features/crm/leads/schemas/lead.schema';
import { assignLead } from '@/features/crm/leads/server/assignment.service';
import { buildLeadAccessContext } from '@/features/crm/leads/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmLeadAssign
});
const payload = assignLeadSchema.parse(await request.json());
const accessContext = buildLeadAccessContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const result = await assignLead(
organization.id,
session.user.id,
{
leadId: id,
salesOwnerId: payload.salesOwnerId,
assignmentRemark: payload.assignmentRemark
},
accessContext
);
return NextResponse.json({
success: true,
message: 'Lead assigned successfully',
leadId: result.leadId,
enquiryId: result.enquiryId,
enquiryCode: result.enquiryCode
});
} 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 assign lead' }, { status: 500 });
}
}

View File

@@ -275,14 +275,22 @@ export const crmLeads = pgTable(
code: text('code').notNull(),
customerId: text('customer_id'),
contactId: text('contact_id'),
description: text('description'),
projectName: text('project_name'),
projectLocation: text('project_location'),
productType: text('product_type'),
priority: text('priority'),
estimatedValue: doublePrecision('estimated_value'),
awarenessId: text('awareness_id'),
status: text('status').default('new_job').notNull(),
followupStatus: text('followup_status'),
lostReason: text('lost_reason'),
outcome: text('outcome').default('open').notNull(),
ownerMarketingUserId: text('owner_marketing_user_id'),
assignedSalesOwnerId: text('assigned_sales_owner_id'),
assignedAt: timestamp('assigned_at', { withTimezone: true }),
assignedBy: text('assigned_by'),
assignmentRemark: text('assignment_remark'),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),

View File

@@ -384,8 +384,9 @@ const FOUNDATION_OPTIONS = {
crm_lead_status: [
{ code: 'new_job', label: 'New Job', value: 'new_job', sortOrder: 1 },
{ code: 'follow_up', label: 'Follow Up', value: 'follow_up', sortOrder: 2 },
{ code: 'closed_lost', label: 'Closed Lost', value: 'closed_lost', sortOrder: 3 },
{ code: 'cancel', label: 'Cancel', value: 'cancel', sortOrder: 4 }
{ code: 'assigned', label: 'Assigned', value: 'assigned', sortOrder: 3 },
{ code: 'closed_lost', label: 'Closed Lost', value: 'closed_lost', sortOrder: 4 },
{ code: 'cancel', label: 'Cancel', value: 'cancel', sortOrder: 5 }
],
crm_lead_followup_status: [
{

View File

@@ -448,7 +448,9 @@ export async function assertAssignableUserBelongsToOrganization(
) {
const [membership] = await db
.select({
userId: memberships.userId
userId: memberships.userId,
role: memberships.role,
businessRole: memberships.businessRole
})
.from(memberships)
.where(and(eq(memberships.organizationId, organizationId), eq(memberships.userId, userId)))
@@ -458,6 +460,10 @@ export async function assertAssignableUserBelongsToOrganization(
throw new AuthError('Assignee not found in organization', 404);
}
if (membership.role !== 'admin' && !ASSIGNABLE_BUSINESS_ROLES.has(membership.businessRole)) {
throw new AuthError('Assignee must be a sales owner', 400);
}
return membership;
}

View File

@@ -4,10 +4,14 @@ export const leadSchema = z.object({
branchId: z.string().nullable().optional(),
customerId: z.string().nullable().optional(),
contactId: z.string().nullable().optional(),
description: z.string().nullable().optional(),
projectName: z.string().nullable().optional(),
projectLocation: z.string().nullable().optional(),
productType: z.string().nullable().optional(),
priority: z.string().nullable().optional(),
estimatedValue: z.number().nullable().optional(),
awarenessId: z.string().nullable().optional(),
status: z.string().min(1, 'Please select a lead status'),
status: z.string().min(1, 'Please select lead status'),
followupStatus: z.string().nullable().optional(),
lostReason: z.string().nullable().optional(),
outcome: z.enum(['open', 'won', 'lost']).default('open'),
@@ -16,9 +20,14 @@ export const leadSchema = z.object({
export const updateLeadSchema = leadSchema.partial();
export const assignLeadSchema = z.object({
salesOwnerId: z.string().min(1, 'Sales owner is required'),
assignmentRemark: z.string().nullable().optional()
});
export const leadFollowupSchema = z.object({
followupDate: z.string().min(1, 'Follow-up date is required'),
followupStatus: z.string().min(1, 'Please select a follow-up status'),
followupDate: z.string().min(1, 'Follow-up date required'),
followupStatus: z.string().min(1, 'Please select follow-up status'),
note: z.string().nullable().optional(),
nextFollowupDate: z.string().nullable().optional()
});

View File

@@ -0,0 +1,300 @@
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
import { crmEnquiries, crmLeads } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
import { auditAction } from '@/features/foundation/audit-log/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import {
canAccessScopedCrmRecord,
type CrmSecurityContext
} from '@/features/crm/security/server/service';
import {
assertAssignableUserBelongsToOrganization,
assertContactBelongsToOrganization,
assertCustomerBelongsToOrganization
} from '@/features/crm/enquiries/server/service';
import type { AssignLeadInput, AssignLeadResult } from '../types';
export type LeadAssignmentAccessContext = CrmSecurityContext;
type LeadRow = typeof crmLeads.$inferSelect;
type EnquiryRow = typeof crmEnquiries.$inferSelect;
function canAccessLeadRow(row: LeadRow, accessContext: LeadAssignmentAccessContext) {
return canAccessScopedCrmRecord(accessContext, {
branchId: row.branchId,
createdBy: row.createdBy,
ownerUserId: row.ownerMarketingUserId
});
}
async function assertLeadBelongsToOrganization(
leadId: string,
organizationId: string,
accessContext?: LeadAssignmentAccessContext
) {
const [lead] = await db
.select()
.from(crmLeads)
.where(
and(
eq(crmLeads.id, leadId),
eq(crmLeads.organizationId, organizationId),
isNull(crmLeads.deletedAt)
)
)
.limit(1);
if (!lead) {
throw new AuthError('Lead not found', 404);
}
if (accessContext && !canAccessLeadRow(lead, accessContext)) {
throw new AuthError('Forbidden', 403);
}
return lead;
}
async function resolveDefaultEnquiryStatusId(organizationId: string) {
const options = await getActiveOptionsByCategory('crm_enquiry_status', { organizationId });
const preferred = options.find((option) => option.code === 'new');
const resolved = preferred ?? options[0];
if (!resolved) {
throw new AuthError('Enquiry status options are not configured', 400);
}
return resolved.id;
}
async function findActiveEnquiryByLead(leadId: string, organizationId: string) {
const [enquiry] = await db
.select()
.from(crmEnquiries)
.where(
and(
eq(crmEnquiries.leadId, leadId),
eq(crmEnquiries.organizationId, organizationId),
inArray(crmEnquiries.pipelineStage, ['lead', 'enquiry']),
isNull(crmEnquiries.deletedAt)
)
)
.orderBy(asc(crmEnquiries.createdAt))
.limit(1);
return enquiry ?? null;
}
async function syncLeadAssignment(input: {
leadId: string;
status: string;
salesOwnerId: string;
assignedBy: string;
assignmentRemark?: string | null;
}) {
const now = new Date();
await db
.update(crmLeads)
.set({
status: input.status,
assignedSalesOwnerId: input.salesOwnerId,
assignedAt: now,
assignedBy: input.assignedBy,
assignmentRemark: input.assignmentRemark?.trim() || null,
updatedAt: now
})
.where(eq(crmLeads.id, input.leadId));
}
async function createEnquiryFromLead(input: {
lead: LeadRow;
organizationId: string;
actorUserId: string;
salesOwnerId: string;
assignmentRemark?: string | null;
}) {
if (!input.lead.customerId) {
throw new AuthError('Lead requires a customer before assignment', 400);
}
if (!input.lead.productType) {
throw new AuthError('Lead requires a product type before assignment', 400);
}
if (!input.lead.priority) {
throw new AuthError('Lead requires a priority before assignment', 400);
}
await assertCustomerBelongsToOrganization(input.lead.customerId, input.organizationId);
if (input.lead.contactId) {
await assertContactBelongsToOrganization(
input.lead.contactId,
input.organizationId,
input.lead.customerId
);
}
const [statusId, documentCode] = await Promise.all([
resolveDefaultEnquiryStatusId(input.organizationId),
generateNextDocumentCode({
organizationId: input.organizationId,
documentType: 'crm_enquiry',
branchId: input.lead.branchId ?? null
})
]);
const now = new Date();
const [created] = await db
.insert(crmEnquiries)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
branchId: input.lead.branchId ?? null,
leadId: input.lead.id,
code: documentCode.code,
customerId: input.lead.customerId,
contactId: input.lead.contactId ?? null,
title: input.lead.projectName?.trim() || `Lead ${input.lead.code}`,
description: input.lead.description?.trim() || null,
requirement: null,
projectName: input.lead.projectName?.trim() || null,
projectLocation: input.lead.projectLocation?.trim() || null,
productType: input.lead.productType,
status: statusId,
priority: input.lead.priority,
leadChannel: null,
estimatedValue: input.lead.estimatedValue ?? null,
chancePercent: null,
expectedCloseDate: null,
competitor: null,
source: `lead:${input.lead.code}`,
notes: null,
isHotProject: false,
isActive: true,
pipelineStage: 'enquiry',
assignedToUserId: input.salesOwnerId,
assignedAt: now,
assignedBy: input.actorUserId,
assignmentRemark: input.assignmentRemark?.trim() || null,
createdBy: input.actorUserId,
updatedBy: input.actorUserId
})
.returning();
return created;
}
export async function assignLead(
organizationId: string,
actorUserId: string,
input: AssignLeadInput,
accessContext?: LeadAssignmentAccessContext
): Promise<AssignLeadResult> {
const lead = await assertLeadBelongsToOrganization(input.leadId, organizationId, accessContext);
await assertAssignableUserBelongsToOrganization(input.salesOwnerId, organizationId);
const existingEnquiry = await findActiveEnquiryByLead(input.leadId, organizationId);
if (existingEnquiry) {
const resolvedSalesOwnerId = existingEnquiry.assignedToUserId ?? input.salesOwnerId;
if (!existingEnquiry.assignedToUserId) {
await db
.update(crmEnquiries)
.set({
pipelineStage: 'enquiry',
assignedToUserId: input.salesOwnerId,
assignedAt: new Date(),
assignedBy: actorUserId,
assignmentRemark: input.assignmentRemark?.trim() || null,
updatedAt: new Date(),
updatedBy: actorUserId
})
.where(eq(crmEnquiries.id, existingEnquiry.id));
}
await syncLeadAssignment({
leadId: input.leadId,
status: 'assigned',
salesOwnerId: resolvedSalesOwnerId,
assignedBy: actorUserId,
assignmentRemark: input.assignmentRemark
});
await auditAction({
organizationId,
branchId: lead.branchId,
userId: actorUserId,
entityType: 'crm_lead',
entityId: input.leadId,
action: 'assign_lead',
afterData: {
leadId: input.leadId,
enquiryId: existingEnquiry.id,
salesOwnerId: resolvedSalesOwnerId,
reusedExistingEnquiry: true
}
});
return {
leadId: input.leadId,
enquiryId: existingEnquiry.id,
enquiryCode: existingEnquiry.code
};
}
const createdEnquiry = await createEnquiryFromLead({
lead,
organizationId,
actorUserId,
salesOwnerId: input.salesOwnerId,
assignmentRemark: input.assignmentRemark
});
await syncLeadAssignment({
leadId: input.leadId,
status: 'assigned',
salesOwnerId: input.salesOwnerId,
assignedBy: actorUserId,
assignmentRemark: input.assignmentRemark
});
await auditAction({
organizationId,
branchId: lead.branchId,
userId: actorUserId,
entityType: 'crm_enquiry',
entityId: createdEnquiry.id,
action: 'create_enquiry_from_lead',
afterData: {
leadId: input.leadId,
enquiryId: createdEnquiry.id,
salesOwnerId: input.salesOwnerId
}
});
await auditAction({
organizationId,
branchId: lead.branchId,
userId: actorUserId,
entityType: 'crm_lead',
entityId: input.leadId,
action: 'assign_lead',
afterData: {
leadId: input.leadId,
enquiryId: createdEnquiry.id,
salesOwnerId: input.salesOwnerId,
reusedExistingEnquiry: false
}
});
return {
leadId: input.leadId,
enquiryId: createdEnquiry.id,
enquiryCode: createdEnquiry.code
};
}

View File

@@ -49,7 +49,9 @@ const LEAD_OPTION_CATEGORIES = {
awareness: 'crm_lead_awareness',
status: 'crm_lead_status',
followupStatus: 'crm_lead_followup_status',
lostReason: 'crm_lead_lost_reason'
lostReason: 'crm_lead_lost_reason',
productType: 'crm_product_type',
priority: 'crm_priority'
} as const;
export type LeadAccessContext = CrmSecurityContext;
@@ -61,6 +63,8 @@ type LeadLookupMaps = {
statusLabels: Map<string, string>;
followupStatusLabels: Map<string, string>;
lostReasonLabels: Map<string, string>;
productTypeLabels: Map<string, string>;
priorityLabels: Map<string, string>;
customerNames: Map<string, string>;
contactNames: Map<string, string>;
userNames: Map<string, string>;
@@ -121,8 +125,12 @@ function mapLeadSummary(row: LeadRow, lookups: LeadLookupMaps): LeadSummary {
customerName: row.customerId ? (lookups.customerNames.get(row.customerId) ?? null) : null,
contactId: row.contactId ?? null,
contactName: row.contactId ? (lookups.contactNames.get(row.contactId) ?? null) : null,
description: row.description ?? null,
projectName: row.projectName ?? null,
projectLocation: row.projectLocation ?? null,
productType: row.productType ?? null,
priority: row.priority ?? null,
estimatedValue: row.estimatedValue ?? null,
awarenessId: row.awarenessId ?? null,
awarenessLabel: row.awarenessId ? (lookups.awarenessLabels.get(row.awarenessId) ?? null) : null,
status: row.status,
@@ -138,6 +146,14 @@ function mapLeadSummary(row: LeadRow, lookups: LeadLookupMaps): LeadSummary {
ownerMarketingName: row.ownerMarketingUserId
? (lookups.userNames.get(row.ownerMarketingUserId) ?? null)
: null,
assignedSalesOwnerId: row.assignedSalesOwnerId ?? null,
assignedSalesOwnerName: row.assignedSalesOwnerId
? (lookups.userNames.get(row.assignedSalesOwnerId) ?? null)
: null,
assignedAt: toIsoDateTime(row.assignedAt),
assignedBy: row.assignedBy ?? null,
assignedByName: row.assignedBy ? (lookups.userNames.get(row.assignedBy) ?? null) : null,
assignmentRemark: row.assignmentRemark ?? null,
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
@@ -339,12 +355,15 @@ async function assertLeadBelongsToOrganization(
}
async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise<LeadLookupMaps> {
const [awarenesses, statuses, followupStatuses, lostReasons] = await Promise.all([
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId })
]);
const [awarenesses, statuses, followupStatuses, lostReasons, productTypes, priorities] =
await Promise.all([
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.productType, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.priority, { organizationId })
]);
const customerIds = [
...new Set(rows.map((row) => row.customerId).filter((value): value is string => Boolean(value)))
];
@@ -354,7 +373,12 @@ async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise
const userIds = [
...new Set(
rows
.flatMap((row) => [row.ownerMarketingUserId, row.createdBy])
.flatMap((row) => [
row.ownerMarketingUserId,
row.assignedSalesOwnerId,
row.assignedBy,
row.createdBy
])
.filter((value): value is string => Boolean(value))
)
];
@@ -385,6 +409,8 @@ async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise
statusLabels: new Map(statuses.map((item) => [item.id, item.label])),
followupStatusLabels: new Map(followupStatuses.map((item) => [item.id, item.label])),
lostReasonLabels: new Map(lostReasons.map((item) => [item.id, item.label])),
productTypeLabels: new Map(productTypes.map((item) => [item.id, item.label])),
priorityLabels: new Map(priorities.map((item) => [item.id, item.label])),
customerNames: new Map(customerRows.map((item) => [item.id, item.name])),
contactNames: new Map(contactRows.map((item) => [item.id, item.name])),
userNames: new Map(userRows.map((item) => [item.id, item.name]))
@@ -399,6 +425,12 @@ async function validateLeadPayload(
) {
await Promise.all([
assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.awareness, payload.awarenessId),
assertMasterOptionValue(
organizationId,
LEAD_OPTION_CATEGORIES.productType,
payload.productType
),
assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.priority, payload.priority),
payload.status
? assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.status, payload.status)
: Promise.resolve(),
@@ -454,19 +486,24 @@ async function validateLeadPayload(
}
export async function getLeadReferenceData(organizationId: string): Promise<LeadReferenceData> {
const [awarenesses, statuses, followupStatuses, lostReasons, branches] = await Promise.all([
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId }),
getUserBranches()
]);
const [awarenesses, statuses, followupStatuses, lostReasons, productTypes, priorities, branches] =
await Promise.all([
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.productType, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.priority, { organizationId }),
getUserBranches()
]);
return {
awarenesses: awarenesses.map(mapOption),
statuses: statuses.map(mapOption),
followupStatuses: followupStatuses.map(mapOption),
lostReasons: lostReasons.map(mapOption),
productTypes: productTypes.map(mapOption),
priorities: priorities.map(mapOption),
branches: branches.map(mapBranch)
};
}
@@ -570,8 +607,12 @@ export async function createLead(
code: codeResult.code,
customerId: payload.customerId ?? null,
contactId: payload.contactId ?? null,
description: payload.description?.trim() || null,
projectName: payload.projectName?.trim() || null,
projectLocation: payload.projectLocation?.trim() || null,
productType: payload.productType ?? null,
priority: payload.priority ?? null,
estimatedValue: payload.estimatedValue ?? null,
awarenessId: payload.awarenessId ?? null,
status: payload.status,
followupStatus: payload.followupStatus ?? null,
@@ -600,9 +641,14 @@ export async function updateLead(
payload.contactId === undefined
? current.contactId
: payload.contactId,
description: payload.description === undefined ? current.description : payload.description,
projectName: payload.projectName === undefined ? current.projectName : payload.projectName,
projectLocation:
payload.projectLocation === undefined ? current.projectLocation : payload.projectLocation,
productType: payload.productType === undefined ? current.productType : payload.productType,
priority: payload.priority === undefined ? current.priority : payload.priority,
estimatedValue:
payload.estimatedValue === undefined ? current.estimatedValue : payload.estimatedValue,
awarenessId: payload.awarenessId === undefined ? current.awarenessId : payload.awarenessId,
status: payload.status ?? current.status,
followupStatus:
@@ -623,8 +669,12 @@ export async function updateLead(
branchId: nextPayload.branchId ?? null,
customerId: nextPayload.customerId ?? null,
contactId: nextPayload.contactId ?? null,
description: nextPayload.description?.trim() || null,
projectName: nextPayload.projectName?.trim() || null,
projectLocation: nextPayload.projectLocation?.trim() || null,
productType: nextPayload.productType ?? null,
priority: nextPayload.priority ?? null,
estimatedValue: nextPayload.estimatedValue ?? null,
awarenessId: nextPayload.awarenessId ?? null,
status: nextPayload.status,
followupStatus: nextPayload.followupStatus ?? null,

View File

@@ -17,6 +17,8 @@ export interface LeadReferenceData {
statuses: LeadOption[];
followupStatuses: LeadOption[];
lostReasons: LeadOption[];
productTypes: LeadOption[];
priorities: LeadOption[];
branches: LeadBranchOption[];
}
@@ -24,8 +26,12 @@ export interface CreateLeadInput {
branchId?: string | null;
customerId?: string | null;
contactId?: string | null;
description?: string | null;
projectName?: string | null;
projectLocation?: string | null;
productType?: string | null;
priority?: string | null;
estimatedValue?: number | null;
awarenessId?: string | null;
status: string;
followupStatus?: string | null;
@@ -38,8 +44,12 @@ export interface UpdateLeadInput {
branchId?: string | null;
customerId?: string | null;
contactId?: string | null;
description?: string | null;
projectName?: string | null;
projectLocation?: string | null;
productType?: string | null;
priority?: string | null;
estimatedValue?: number | null;
awarenessId?: string | null;
status?: string;
followupStatus?: string | null;
@@ -69,6 +79,12 @@ export interface CreateLeadFollowupInput {
nextFollowupDate?: string | null;
}
export interface AssignLeadInput {
leadId: string;
salesOwnerId: string;
assignmentRemark?: string | null;
}
export interface LeadSummary {
id: string;
organizationId: string;
@@ -78,8 +94,12 @@ export interface LeadSummary {
customerName: string | null;
contactId: string | null;
contactName: string | null;
description: string | null;
projectName: string | null;
projectLocation: string | null;
productType: string | null;
priority: string | null;
estimatedValue: number | null;
awarenessId: string | null;
awarenessLabel: string | null;
status: string;
@@ -91,6 +111,12 @@ export interface LeadSummary {
outcome: string;
ownerMarketingUserId: string | null;
ownerMarketingName: string | null;
assignedSalesOwnerId: string | null;
assignedSalesOwnerName: string | null;
assignedAt: string | null;
assignedBy: string | null;
assignedByName: string | null;
assignmentRemark: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
@@ -119,3 +145,9 @@ export interface LeadFollowupSummary {
createdBy: string;
createdByName: string | null;
}
export interface AssignLeadResult {
leadId: string;
enquiryId: string;
enquiryCode: string;
}