From 9ae4f31f28196d65a23e363ada03880ca4e7220d Mon Sep 17 00:00:00 2001 From: phaichayon Date: Fri, 10 Jul 2026 10:33:45 +0700 Subject: [PATCH] task-ep.1.1 --- ...1-activity-domain-foundation-2026-07-10.md | 70 + drizzle/0002_empty_jean_grey.sql | 41 + drizzle/meta/0002_snapshot.json | 6662 +++++++++++++++++ drizzle/meta/_journal.json | 7 + plans/task-ep1.1.md | 445 ++ .../api/crm/activities/[id]/assign/route.ts | 47 + .../api/crm/activities/[id]/cancel/route.ts | 47 + .../api/crm/activities/[id]/complete/route.ts | 53 + .../crm/activities/[id]/reschedule/route.ts | 53 + src/app/api/crm/activities/[id]/route.ts | 104 + src/app/api/crm/activities/reference/route.ts | 19 + src/app/api/crm/activities/route.ts | 90 + src/app/dashboard/crm/activities/page.tsx | 27 + src/db/schema.ts | 42 + src/features/crm/activities/api/mutations.ts | 104 + src/features/crm/activities/api/queries.ts | 30 + src/features/crm/activities/api/service.ts | 103 + src/features/crm/activities/api/types.ts | 99 + .../activities/components/activity-badge.tsx | 6 + .../activities/components/activity-detail.tsx | 64 + .../components/activity-form-sheet.tsx | 249 + .../components/activity-listing.tsx | 88 + .../components/activity-priority-badge.tsx | 16 + .../components/activity-status-badge.tsx | 16 + .../crm/activities/schemas/activity.schema.ts | 86 + .../server/adapters/lead-followup.adapter.ts | 6 + .../adapters/opportunity-followup.adapter.ts | 6 + .../adapters/quotation-followup.adapter.ts | 6 + .../server/integration-contracts.ts | 32 + .../crm/activities/server/read-model.ts | 98 + .../crm/activities/server/repository.ts | 93 + src/features/crm/activities/server/service.ts | 782 ++ src/features/crm/activity/types.ts | 111 +- 33 files changed, 9680 insertions(+), 22 deletions(-) create mode 100644 docs/implementation/task-ep1.1-activity-domain-foundation-2026-07-10.md create mode 100644 drizzle/0002_empty_jean_grey.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 plans/task-ep1.1.md create mode 100644 src/app/api/crm/activities/[id]/assign/route.ts create mode 100644 src/app/api/crm/activities/[id]/cancel/route.ts create mode 100644 src/app/api/crm/activities/[id]/complete/route.ts create mode 100644 src/app/api/crm/activities/[id]/reschedule/route.ts create mode 100644 src/app/api/crm/activities/[id]/route.ts create mode 100644 src/app/api/crm/activities/reference/route.ts create mode 100644 src/app/api/crm/activities/route.ts create mode 100644 src/app/dashboard/crm/activities/page.tsx create mode 100644 src/features/crm/activities/api/mutations.ts create mode 100644 src/features/crm/activities/api/queries.ts create mode 100644 src/features/crm/activities/api/service.ts create mode 100644 src/features/crm/activities/api/types.ts create mode 100644 src/features/crm/activities/components/activity-badge.tsx create mode 100644 src/features/crm/activities/components/activity-detail.tsx create mode 100644 src/features/crm/activities/components/activity-form-sheet.tsx create mode 100644 src/features/crm/activities/components/activity-listing.tsx create mode 100644 src/features/crm/activities/components/activity-priority-badge.tsx create mode 100644 src/features/crm/activities/components/activity-status-badge.tsx create mode 100644 src/features/crm/activities/schemas/activity.schema.ts create mode 100644 src/features/crm/activities/server/adapters/lead-followup.adapter.ts create mode 100644 src/features/crm/activities/server/adapters/opportunity-followup.adapter.ts create mode 100644 src/features/crm/activities/server/adapters/quotation-followup.adapter.ts create mode 100644 src/features/crm/activities/server/integration-contracts.ts create mode 100644 src/features/crm/activities/server/read-model.ts create mode 100644 src/features/crm/activities/server/repository.ts create mode 100644 src/features/crm/activities/server/service.ts diff --git a/docs/implementation/task-ep1.1-activity-domain-foundation-2026-07-10.md b/docs/implementation/task-ep1.1-activity-domain-foundation-2026-07-10.md new file mode 100644 index 0000000..8568c72 --- /dev/null +++ b/docs/implementation/task-ep1.1-activity-domain-foundation-2026-07-10.md @@ -0,0 +1,70 @@ +# Task EP.1.1 Activity Domain Foundation - 2026-07-10 + +## Scope +- introduce the first additive `crm_activities` write model +- add activity API, service, lightweight repository, and read-model shaping +- add foundational UI building blocks for sheet, form, detail, and badges +- preserve legacy lead, opportunity, and quotation follow-up implementations + +## Review Summary +Reviewed before implementation: +- `AGENTS.md` +- `plans/task-ep1.1.md` +- `docs/standards/task-contract-template.md` +- `docs/standards/task-catalog.md` +- `docs/standards/project-foundations.md` +- `docs/standards/architecture-rules.md` +- `docs/standards/ui-ux-rules.md` +- `docs/standards/task-review-checklist.md` +- `docs/security/crm-authorization-boundaries.md` +- `docs/business/relationship-sales-workspace-blueprint-v1.md` +- `docs/implementation/task-bu-r.0.1-workspace-activity-business-blueprint-2026-07-07.md` +- `docs/implementation/task-ar.1-architecture-transition-plan-2026-07-07.md` +- `docs/implementation/task-ar.2-epic-technical-design-2026-07-07.md` +- `docs/implementation/task-ar.2-workspace-ui-ux-design-note-2026-07-07.md` +- `plans/task-eng.0.md` +- existing follow-up services and route handlers under `src/features/crm/leads/**`, `src/features/crm/opportunities/**`, and `src/features/crm/quotations/**` +- existing CRM security, audit, and customer detail foundations + +## Foundations Reused +- `src/lib/auth/session.ts` +- `src/features/crm/security/server/service.ts` +- `src/features/foundation/audit-log/service.ts` +- `src/features/foundation/display/server/display-resolver.ts` +- existing customer, lead, opportunity, and quotation detail services for primary-record access validation + +## Implementation Notes +- `crm_activities` is additive and does not replace existing follow-up tables or audit-backed lead follow-up behavior. +- activity visibility reuses resolved CRM scope plus owner/assignee visibility and an internal-only rule for manager/admin style access. +- pricing-sensitive activity content is redacted when tied to quotation or PO context without quotation pricing visibility. +- follow-up adapters are documented as candidate contracts only in this phase. No data migration or dual-write was introduced. + +## Follow-up Gap Analysis +1. Lead follow-up is still audit-log-backed rather than row-backed, so migration needs an adapter that can translate immutable audit entries into activity candidates without rewriting history. +2. Opportunity follow-up is row-backed and is the cleanest EP.1.2 consolidation seam. +3. Quotation follow-up is row-backed and can follow the same adapter path as opportunity follow-up, but pricing visibility rules must stay active when notes expose commercial values. +4. Dashboard and report consumers still read legacy follow-up data today, so EP.1.2 needs continuity checks before any shared activity projection becomes source input. + +## Migration Preparation Report +- prepared: + - shared activity write model + - route-handler boundary + - service-owned lifecycle validation + - repository/read-model split + - projection contract interfaces + - basic activity UI foundation +- deferred intentionally: + - timeline projection + - calendar projection + - notification fan-out + - legacy follow-up storage migration + - dashboard/report dataset swap + +## Verification Plan +- run `npm run typecheck` +- run `npm run db:generate` +- run targeted manual API smoke checks for create, update, assign, complete, cancel, reschedule, delete + +## Residual Risks +- current activity creation UI uses manual primary record id entry because source-record pickers are intentionally deferred to later workspace integration work +- contact primary-record validation remains lightweight and should be revisited when customer workspace activity embedding begins diff --git a/drizzle/0002_empty_jean_grey.sql b/drizzle/0002_empty_jean_grey.sql new file mode 100644 index 0000000..52a1523 --- /dev/null +++ b/drizzle/0002_empty_jean_grey.sql @@ -0,0 +1,41 @@ +CREATE TABLE "crm_activities" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "primary_entity_type" text NOT NULL, + "primary_entity_id" text, + "primary_record_label" text, + "related_records" jsonb DEFAULT '[]'::jsonb NOT NULL, + "customer_id" text, + "contact_id" text, + "lead_id" text, + "opportunity_id" text, + "quotation_id" text, + "activity_type" text NOT NULL, + "subject" text NOT NULL, + "description" text, + "owner_id" text NOT NULL, + "assigned_to_id" text, + "scheduled_start_at" timestamp with time zone, + "scheduled_end_at" timestamp with time zone, + "due_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "cancelled_at" timestamp with time zone, + "skipped_at" timestamp with time zone, + "status" text NOT NULL, + "priority" text NOT NULL, + "outcome_summary" text, + "cancellation_reason" text, + "skip_reason" text, + "next_action" text, + "branch_id" text, + "product_type" text, + "is_internal_only" boolean DEFAULT false NOT NULL, + "is_pricing_sensitive" boolean DEFAULT false NOT NULL, + "parent_activity_id" 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, + "created_by" text NOT NULL, + "updated_by" text NOT NULL +); diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..a6d26fe --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,6662 @@ +{ + "id": "d483bc1d-ec18-4134-9815-5c0153cf4a3f", + "prevId": "3dc70e0a-e0ce-433d-9d4b-cb2ae6455ce7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_notification_deliveries": { + "name": "app_notification_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_id": { + "name": "notification_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_address": { + "name": "recipient_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_notification_dispatches": { + "name": "app_notification_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_key": { + "name": "provider_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "recipient_to": { + "name": "recipient_to", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "recipient_cc": { + "name": "recipient_cc", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "recipient_bcc": { + "name": "recipient_bcc", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_address": { + "name": "recipient_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_retry_count": { + "name": "max_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_notification_dispatches_org_event_channel_recipient_idx": { + "name": "app_notification_dispatches_org_event_channel_recipient_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_notification_events": { + "name": "app_notification_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_notification_events_org_dedupe_idx": { + "name": "app_notification_events_org_dedupe_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_notification_templates": { + "name": "app_notification_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_template": { + "name": "subject_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title_template": { + "name": "title_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_template": { + "name": "body_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_format": { + "name": "body_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'plain'" + }, + "link_template": { + "name": "link_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'th'" + }, + "variables": { + "name": "variables", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "app_notification_templates_org_event_channel_idx": { + "name": "app_notification_templates_org_event_channel_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_notifications": { + "name": "app_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link_url": { + "name": "link_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unread'" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_notifications_recipient_event_idx": { + "name": "app_notifications_recipient_event_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_activities": { + "name": "crm_activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_entity_type": { + "name": "primary_entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_entity_id": { + "name": "primary_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primary_record_label": { + "name": "primary_record_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_records": { + "name": "related_records", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activity_type": { + "name": "activity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_start_at": { + "name": "scheduled_start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scheduled_end_at": { + "name": "scheduled_end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "due_at": { + "name": "due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "skipped_at": { + "name": "skipped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome_summary": { + "name": "outcome_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancellation_reason": { + "name": "cancellation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_internal_only": { + "name": "is_internal_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_pricing_sensitive": { + "name": "is_pricing_sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "parent_activity_id": { + "name": "parent_activity_id", + "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 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_actions": { + "name": "crm_approval_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approval_request_id": { + "name": "approval_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acted_by": { + "name": "acted_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acted_at": { + "name": "acted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_escalation_policies": { + "name": "crm_approval_escalation_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "trigger_after_hours": { + "name": "trigger_after_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_value": { + "name": "target_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_matrices": { + "name": "crm_approval_matrices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "min_amount": { + "name": "min_amount", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_amount": { + "name": "max_amount", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_reminder_policies": { + "name": "crm_approval_reminder_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sla_hours": { + "name": "sla_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "reminder_offsets_hours": { + "name": "reminder_offsets_hours", + "type": "integer[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "calendar_mode": { + "name": "calendar_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'calendar_days'" + }, + "timeout_action": { + "name": "timeout_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_approval_reminder_policies_workflow_step_idx": { + "name": "crm_approval_reminder_policies_workflow_step_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_requests": { + "name": "crm_approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "current_step_started_at": { + "name": "current_step_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_steps": { + "name": "crm_approval_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role_code": { + "name": "role_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approval_mode": { + "name": "approval_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sequential'" + }, + "is_required": { + "name": "is_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sla_hours": { + "name": "sla_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "calendar_mode": { + "name": "calendar_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'calendar_days'" + }, + "timeout_action": { + "name": "timeout_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_steps_workflow_step_idx": { + "name": "crm_approval_steps_workflow_step_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_workflows": { + "name": "crm_approval_workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_workflows_org_code_idx": { + "name": "crm_approval_workflows_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_contact_shares": { + "name": "crm_contact_shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_to_user_id": { + "name": "shared_to_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_by_user_id": { + "name": "shared_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_contact_shares_org_contact_shared_user_idx": { + "name": "crm_contact_shares_org_contact_shared_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shared_to_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customer_contacts": { + "name": "crm_customer_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "department": { + "name": "department", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mobile": { + "name": "mobile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customer_owner_history": { + "name": "crm_customer_owner_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_owner_user_id": { + "name": "old_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_owner_user_id": { + "name": "new_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changed_by": { + "name": "changed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changed_at": { + "name": "changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customers": { + "name": "crm_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "abbr": { + "name": "abbr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_id": { + "name": "tax_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_status": { + "name": "customer_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "province": { + "name": "province", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sub_district": { + "name": "sub_district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fax": { + "name": "fax", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_group": { + "name": "customer_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_sub_group": { + "name": "customer_sub_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_assigned_at": { + "name": "owner_assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_assigned_by": { + "name": "owner_assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_customers_org_code_idx": { + "name": "crm_customers_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_artifacts": { + "name": "crm_document_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artifact_type": { + "name": "artifact_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_version_id": { + "name": "template_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "generated_by": { + "name": "generated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voided_at": { + "name": "voided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "voided_by": { + "name": "voided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "void_reason": { + "name": "void_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_libraries": { + "name": "crm_document_libraries", + "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 + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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_libraries_org_code_idx": { + "name": "crm_document_libraries_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_library_versions": { + "name": "crm_document_library_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_by": { + "name": "archived_by", + "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 + } + }, + "indexes": { + "crm_document_library_versions_library_version_idx": { + "name": "crm_document_library_versions_library_version_idx", + "columns": [ + { + "expression": "library_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_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 + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "preview_image_url": { + "name": "preview_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_document_template_versions_template_version_idx": { + "name": "crm_document_template_versions_template_version_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_templates": { + "name": "crm_document_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_name": { + "name": "template_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_document_templates_org_doc_product_file_name_idx": { + "name": "crm_document_templates_org_doc_product_file_name_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_leads": { + "name": "crm_leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_value": { + "name": "estimated_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "awareness_id": { + "name": "awareness_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_status": { + "name": "followup_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_reason": { + "name": "lost_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "owner_marketing_user_id": { + "name": "owner_marketing_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_sales_owner_id": { + "name": "assigned_sales_owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignment_remark": { + "name": "assignment_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_leads_org_code_idx": { + "name": "crm_leads_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunities": { + "name": "crm_opportunities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requirement": { + "name": "requirement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome_status": { + "name": "outcome_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_value": { + "name": "estimated_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "chance_percent": { + "name": "chance_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expected_close_date": { + "name": "expected_close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "project_close_date": { + "name": "project_close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_date": { + "name": "delivery_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 + }, + "hot_project_auto_suggested": { + "name": "hot_project_auto_suggested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hot_project_manually_overridden": { + "name": "hot_project_manually_overridden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "pipeline_stage": { + "name": "pipeline_stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'lead'" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_won_at": { + "name": "closed_won_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_lost_at": { + "name": "closed_lost_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_by_user_id": { + "name": "closed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "po_number": { + "name": "po_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "po_date": { + "name": "po_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "po_amount": { + "name": "po_amount", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "po_currency": { + "name": "po_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_reason": { + "name": "lost_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_detail": { + "name": "lost_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_competitor": { + "name": "lost_competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_remark": { + "name": "lost_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "no_quotation_reason": { + "name": "no_quotation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_to_user_id": { + "name": "assigned_to_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignment_remark": { + "name": "assignment_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_opportunities_org_code_idx": { + "name": "crm_opportunities_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunity_attachments": { + "name": "crm_opportunity_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunity_customers": { + "name": "crm_opportunity_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunity_followups": { + "name": "crm_opportunity_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_attachments": { + "name": "crm_quotation_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_customers": { + "name": "crm_quotation_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_followups": { + "name": "crm_quotation_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_items": { + "name": "crm_quotation_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_number": { + "name": "item_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_price": { + "name": "unit_price", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount": { + "name": "discount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_rate": { + "name": "tax_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_price": { + "name": "total_price", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_topic_items": { + "name": "crm_quotation_topic_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_topics": { + "name": "crm_quotation_topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_type": { + "name": "topic_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotations": { + "name": "crm_quotations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quotation_date": { + "name": "quotation_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quotation_type": { + "name": "quotation_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attention": { + "name": "attention", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_quotation_id": { + "name": "parent_quotation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision_remark": { + "name": "revision_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exchange_rate": { + "name": "exchange_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "subtotal": { + "name": "subtotal", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount": { + "name": "discount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_rate": { + "name": "tax_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tax_amount": { + "name": "tax_amount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_amount": { + "name": "total_amount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "chance_percent": { + "name": "chance_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "project_close_date": { + "name": "project_close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_date": { + "name": "delivery_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_hot_project": { + "name": "is_hot_project", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hot_project_auto_suggested": { + "name": "hot_project_auto_suggested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hot_project_manually_overridden": { + "name": "hot_project_manually_overridden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "competitor": { + "name": "competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "salesman_id": { + "name": "salesman_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_sent": { + "name": "is_sent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_via": { + "name": "sent_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_artifact_id": { + "name": "approved_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_pdf_url": { + "name": "approved_pdf_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_snapshot": { + "name": "approved_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "approved_template_version_id": { + "name": "approved_template_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_quotations_org_code_idx": { + "name": "crm_quotations_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_report_definitions": { + "name": "crm_report_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "crm_report_definitions_org_code_idx": { + "name": "crm_report_definitions_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_role_profiles": { + "name": "crm_role_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "branch_scope_mode": { + "name": "branch_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'assigned'" + }, + "product_scope_mode": { + "name": "product_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'assigned'" + }, + "ownership_scope": { + "name": "ownership_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'own'" + }, + "approval_authority": { + "name": "approval_authority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_role_profiles_org_code_idx": { + "name": "crm_role_profiles_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_user_role_assignments": { + "name": "crm_user_role_assignments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_profile_id": { + "name": "role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_scope_mode": { + "name": "branch_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + }, + "branch_scope_ids": { + "name": "branch_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "product_type_scope_mode": { + "name": "product_type_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + }, + "product_type_scope_ids": { + "name": "product_type_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_user_role_assignments_org_user_role_idx": { + "name": "crm_user_role_assignments_org_user_role_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_sequences": { + "name": "document_sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "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 + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{prefix}{period}-{running}'" + }, + "reset_policy": { + "name": "reset_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'period'" + }, + "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_branch_product_doc_period_idx": { + "name": "document_sequences_org_branch_product_doc_period_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_configurations": { + "name": "email_configurations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'smtp'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 587 + }, + "secure": { + "name": "secure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_encrypted": { + "name": "password_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to_email": { + "name": "reply_to_email", + "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 + }, + "last_test_status": { + "name": "last_test_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_tested'" + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_error": { + "name": "last_test_error", + "type": "text", + "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 + }, + "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": { + "email_configurations_org_name_idx": { + "name": "email_configurations_org_name_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "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.seed_manifest_items": { + "name": "seed_manifest_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seed_manifest_id": { + "name": "seed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_key": { + "name": "item_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_file": { + "name": "source_file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "imported_count": { + "name": "imported_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_count": { + "name": "updated_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_count": { + "name": "error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "warning_count": { + "name": "warning_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "report_json": { + "name": "report_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "seed_manifest_items_manifest_item_idx": { + "name": "seed_manifest_items_manifest_item_idx", + "columns": [ + { + "expression": "seed_manifest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seed_manifests": { + "name": "seed_manifests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "setup_run_id": { + "name": "setup_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_by": { + "name": "applied_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "report_json": { + "name": "report_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.setup_run_steps": { + "name": "setup_run_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "setup_run_id": { + "name": "setup_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_key": { + "name": "step_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output_json": { + "name": "output_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "errors_json": { + "name": "errors_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "warnings_json": { + "name": "warnings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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()" + } + }, + "indexes": { + "setup_run_steps_run_step_idx": { + "name": "setup_run_steps_run_step_idx", + "columns": [ + { + "expression": "setup_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.setup_runs": { + "name": "setup_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'production'" + }, + "target_organization_id": { + "name": "target_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_by": { + "name": "started_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_by": { + "name": "completed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_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 + }, + "last_error": { + "name": "last_error", + "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()" + } + }, + "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 78e8550..4f8155d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1783350585715, "tag": "0001_yellow_wasp", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1783647090217, + "tag": "0002_empty_jean_grey", + "breakpoints": true } ] } \ No newline at end of file diff --git a/plans/task-ep1.1.md b/plans/task-ep1.1.md new file mode 100644 index 0000000..0550323 --- /dev/null +++ b/plans/task-ep1.1.md @@ -0,0 +1,445 @@ +# EP.1.1 – Activity Domain Foundation + +Status: Planning + +Priority: Critical + +Type: Feature Foundation + +Depends On + +- BU-R.0 Business Constitution +- BU-R.0.1 Workspace & Activity Blueprint +- BU-R.1 Business Capability Audit +- AR.1 Architecture Transition Plan +- AR.2 Epic Technical Design +- AR.2 Workspace UI/UX Design Note +- ENG.0 Engineering Constitution + +--- + +# Objective + +Introduce the shared Activity Domain as the operational foundation of ALLA OS. + +This epic establishes the Activity aggregate, ownership model, business rules, service boundaries, and integration points while preserving all existing CRM modules. + +This phase delivers only the Activity foundation. + +Timeline, Calendar, My Day, Dashboard, and Notification are intentionally excluded and will consume Activity in later epics. + +--- + +# Background + +Business Constitution defines Activity as the shared operational model. + +Architecture Constitution defines Activity as the source domain for future projections. + +Engineering Constitution requires: + +- Preserve existing CRM domains +- Extend rather than replace +- Keep Activity as the single operational write model + +Current implementation already contains several activity-like concepts: + +- Follow-up +- Reminder +- Site Visit +- Meeting +- Internal Task + +These must be audited and unified without breaking existing production behavior. + +--- + +# Review Required + +Review before implementation: + +Business + +- Relationship & Sales Workspace Blueprint +- Activity Blueprint + +Architecture + +- AR.1 Architecture Transition Plan +- AR.2 Epic Technical Design + +Engineering + +- Engineering Constitution + +UI + +- Workspace UI/UX Design Note +- layout.md +- ui-ux-rules.md +- ui-ux-pro-max + +Implementation + +- Existing Follow-up modules +- Existing Customer detail +- Existing Opportunity detail +- Existing Quotation detail +- Existing Notification logic +- Existing Permission model + +--- + +# Scope + +## Part 1 — Activity Domain Model + +Create + +Activity Aggregate + +Define + +- Activity +- Activity Owner +- Activity Assignee +- Activity Type +- Activity Status +- Activity Priority + +Freeze ownership. + +Activity becomes the only operational work entity. + +--- + +## Part 2 — Activity Lifecycle + +Define + +Draft + +Scheduled + +In Progress + +Completed + +Cancelled + +Skipped + +Overdue + +Define + +Allowed transitions + +Completion rules + +Reschedule rules + +Assignment rules + +--- + +## Part 3 — Activity Relationship Model + +Activity may reference + +Customer + +Contact + +Lead + +Opportunity + +Quotation + +PO + +Internal Only + +Exactly one source context is required. + +--- + +## Part 4 — Activity Business Rules + +Freeze + +Assignment + +Ownership + +Visibility + +Permission + +Completion + +Deletion + +Editing + +History + +Determine + +Recurring activities + +Parent / Child activities + +Future dependency support + +--- + +## Part 5 — Activity Service + +Introduce + +Activity Service + +Responsibilities + +Create + +Update + +Assign + +Complete + +Cancel + +Reschedule + +Permission validation + +Audit logging + +Business validation + +Route Handlers remain thin. + +--- + +## Part 6 — Activity Repository + +Create repository abstraction. + +Separate + +Write model + +Read model + +Prepare for future projections. + +--- + +## Part 7 — Activity API + +Introduce REST endpoints + +Create + +Update + +Delete + +Assign + +Complete + +Cancel + +Reschedule + +List + +Get Detail + +API must follow existing CRM conventions. + +--- + +## Part 8 — Permission Model + +Integrate with existing CRM authorization. + +Support + +Owner + +Assignee + +Manager + +Administrator + +Respect + +Organization + +Branch + +Product Type + +Pricing visibility boundaries. + +--- + +## Part 9 — Existing Module Integration + +Integrate Activity with + +Customer + +Lead + +Opportunity + +Quotation + +without changing their ownership. + +Do not migrate Follow-up yet. + +Only establish extension points. + +--- + +## Part 10 — Future Projection Preparation + +Publish integration contracts for + +Timeline + +Calendar + +Notification + +My Day + +Dashboard + +Do NOT implement projections. + +Only define extension interfaces. + +--- + +# UI Scope + +Implement only foundational UI. + +Review + +- layout.md +- ui-ux-rules.md +- ui-ux-pro-max + +Deliver + +- Activity Sheet +- Activity Form +- Activity Detail +- Activity Badge +- Activity Status Badge +- Activity Priority Badge + +Do NOT build + +- Calendar +- Timeline +- My Day +- Manager Workspace + +UI must extend existing CRM patterns. + +--- + +# Deliverables + +1. Activity Domain Model +2. Activity Lifecycle +3. Activity Service +4. Activity Repository +5. Activity API +6. Activity Permission Integration +7. Activity UI Foundation +8. Activity Integration Contracts +9. Existing Follow-up Gap Analysis +10. Migration Preparation Report + +--- + +# Constraints + +Must preserve + +- Customer +- Lead +- Opportunity +- Quotation +- Approval +- RBAC +- Organization +- Existing APIs + +No Calendar. + +No Timeline. + +No Dashboard. + +No Notification Expansion. + +No projection implementation. + +No migration of existing Follow-up records. + +--- + +# Acceptance Criteria + +- Activity becomes the official operational domain. +- Existing CRM behavior remains unchanged. +- Activity is reusable by all future workspaces. +- Existing Follow-up implementation continues working. +- Projection interfaces are prepared without implementation. +- Thin Route Handler pattern is preserved. +- Service-owned business logic is preserved. +- All new UI follows layout.md, ui-ux-rules.md, and ui-ux-pro-max. +- Existing shadcn/ui patterns are reused. +- No breaking changes are introduced. + +--- + +# Success Criteria + +ALLA OS now has a production-ready Activity Foundation. + +Future epics can consume Activity without redesign. + +Ready for + +EP.1.2 – Activity Integration & Follow-up Consolidation. + +## Compatibility Strategy + +During EP.1.1, existing Follow-up implementations remain operational. + +The new Activity Domain must coexist with legacy Follow-up structures. + +No existing API contracts may be removed. + +No database records are migrated. + +No UI flow changes are introduced. + +Activity is introduced as an additive capability only. + +Legacy Follow-up consolidation is deferred to EP.1.2 after Activity Foundation has been validated. diff --git a/src/app/api/crm/activities/[id]/assign/route.ts b/src/app/api/crm/activities/[id]/assign/route.ts new file mode 100644 index 0000000..5e609cb --- /dev/null +++ b/src/app/api/crm/activities/[id]/assign/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { buildCrmSecurityContext } from '@/features/crm/security/server/service'; +import { assignActivity } from '@/features/crm/activities/server/service'; +import { activityAssignSchema } from '@/features/crm/activities/schemas/activity.schema'; +import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types'; +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: CRM_ACTIVITY_PERMISSIONS.reassign + }); + const payload = activityAssignSchema.parse(await request.json()); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const activity = await assignActivity(id, organization.id, session.user.id, payload, accessContext); + await auditAction({ + organizationId: organization.id, + branchId: activity.branchId, + userId: session.user.id, + entityType: 'crm_activity', + entityId: id, + action: 'assign', + afterData: activity + }); + return NextResponse.json(activity); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + return NextResponse.json({ message: 'Unable to assign activity' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/activities/[id]/cancel/route.ts b/src/app/api/crm/activities/[id]/cancel/route.ts new file mode 100644 index 0000000..32d9de3 --- /dev/null +++ b/src/app/api/crm/activities/[id]/cancel/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { buildCrmSecurityContext } from '@/features/crm/security/server/service'; +import { cancelActivity } from '@/features/crm/activities/server/service'; +import { activityCancelSchema } from '@/features/crm/activities/schemas/activity.schema'; +import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types'; +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: CRM_ACTIVITY_PERMISSIONS.cancel + }); + const payload = activityCancelSchema.parse(await request.json()); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const activity = await cancelActivity(id, organization.id, session.user.id, payload, accessContext); + await auditAction({ + organizationId: organization.id, + branchId: activity.branchId, + userId: session.user.id, + entityType: 'crm_activity', + entityId: id, + action: 'cancel', + afterData: activity + }); + return NextResponse.json(activity); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + return NextResponse.json({ message: 'Unable to cancel activity' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/activities/[id]/complete/route.ts b/src/app/api/crm/activities/[id]/complete/route.ts new file mode 100644 index 0000000..cdc0b6a --- /dev/null +++ b/src/app/api/crm/activities/[id]/complete/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { buildCrmSecurityContext } from '@/features/crm/security/server/service'; +import { completeActivity } from '@/features/crm/activities/server/service'; +import { activityCompleteSchema } from '@/features/crm/activities/schemas/activity.schema'; +import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types'; +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: CRM_ACTIVITY_PERMISSIONS.complete + }); + const payload = activityCompleteSchema.parse(await request.json()); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const activity = await completeActivity( + id, + organization.id, + session.user.id, + payload, + accessContext + ); + await auditAction({ + organizationId: organization.id, + branchId: activity.branchId, + userId: session.user.id, + entityType: 'crm_activity', + entityId: id, + action: 'complete', + afterData: activity + }); + return NextResponse.json(activity); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + return NextResponse.json({ message: 'Unable to complete activity' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/activities/[id]/reschedule/route.ts b/src/app/api/crm/activities/[id]/reschedule/route.ts new file mode 100644 index 0000000..f92aca6 --- /dev/null +++ b/src/app/api/crm/activities/[id]/reschedule/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { buildCrmSecurityContext } from '@/features/crm/security/server/service'; +import { rescheduleActivity } from '@/features/crm/activities/server/service'; +import { activityRescheduleSchema } from '@/features/crm/activities/schemas/activity.schema'; +import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types'; +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: CRM_ACTIVITY_PERMISSIONS.update + }); + const payload = activityRescheduleSchema.parse(await request.json()); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const activity = await rescheduleActivity( + id, + organization.id, + session.user.id, + payload, + accessContext + ); + await auditAction({ + organizationId: organization.id, + branchId: activity.branchId, + userId: session.user.id, + entityType: 'crm_activity', + entityId: id, + action: 'reschedule', + afterData: activity + }); + return NextResponse.json(activity); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + return NextResponse.json({ message: 'Unable to reschedule activity' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/activities/[id]/route.ts b/src/app/api/crm/activities/[id]/route.ts new file mode 100644 index 0000000..cf37222 --- /dev/null +++ b/src/app/api/crm/activities/[id]/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { buildCrmSecurityContext } from '@/features/crm/security/server/service'; +import { + deleteActivity, + getActivityById, + updateActivity +} from '@/features/crm/activities/server/service'; +import { activityUpdateSchema } from '@/features/crm/activities/schemas/activity.schema'; +import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session, membership } = await requireOrganizationAccess({ + permission: CRM_ACTIVITY_PERMISSIONS.read + }); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const activity = await getActivityById(id, organization.id, accessContext); + return NextResponse.json(activity); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + return NextResponse.json({ message: 'Unable to load activity' }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session, membership } = await requireOrganizationAccess({ + permission: CRM_ACTIVITY_PERMISSIONS.update + }); + const payload = activityUpdateSchema.parse(await request.json()); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const before = await getActivityById(id, organization.id, accessContext); + const updated = await updateActivity(id, organization.id, session.user.id, payload, accessContext); + await auditUpdate({ + organizationId: organization.id, + branchId: updated.branchId, + userId: session.user.id, + entityType: 'crm_activity', + entityId: id, + beforeData: before, + afterData: updated + }); + return NextResponse.json(updated); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + return NextResponse.json({ message: 'Unable to update activity' }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session, membership } = await requireOrganizationAccess({ + permission: CRM_ACTIVITY_PERMISSIONS.delete + }); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const before = await getActivityById(id, organization.id, accessContext); + await deleteActivity(id, organization.id, session.user.id, accessContext); + await auditDelete({ + organizationId: organization.id, + branchId: before.branchId, + userId: session.user.id, + entityType: 'crm_activity', + entityId: id, + beforeData: before, + afterData: { deletedAt: new Date().toISOString() } + }); + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + return NextResponse.json({ message: 'Unable to delete activity' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/activities/reference/route.ts b/src/app/api/crm/activities/reference/route.ts new file mode 100644 index 0000000..c13f288 --- /dev/null +++ b/src/app/api/crm/activities/reference/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; +import { getActivityReferenceData } from '@/features/crm/activities/server/service'; +import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET() { + try { + const { organization } = await requireOrganizationAccess({ + permission: CRM_ACTIVITY_PERMISSIONS.read + }); + const data = await getActivityReferenceData(organization.id); + return NextResponse.json(data); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + return NextResponse.json({ message: 'Unable to load activity reference data' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/activities/route.ts b/src/app/api/crm/activities/route.ts new file mode 100644 index 0000000..efe1852 --- /dev/null +++ b/src/app/api/crm/activities/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditCreate } from '@/features/foundation/audit-log/service'; +import { buildCrmSecurityContext } from '@/features/crm/security/server/service'; +import { + createActivity, + listActivities +} from '@/features/crm/activities/server/service'; +import { activityMutationSchema } from '@/features/crm/activities/schemas/activity.schema'; +import { + CRM_ACTIVITY_ENTITY_TYPES, + CRM_ACTIVITY_PERMISSIONS, + CRM_ACTIVITY_STATUSES, + CRM_ACTIVITY_TYPES +} from '@/features/crm/activity/types'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +const activityListQuerySchema = z.object({ + search: z.string().optional(), + primaryEntityType: z.enum(CRM_ACTIVITY_ENTITY_TYPES).optional(), + primaryEntityId: z.string().optional(), + activityType: z.enum(CRM_ACTIVITY_TYPES).optional(), + status: z.enum(CRM_ACTIVITY_STATUSES).optional(), + ownerId: z.string().optional(), + assignedToId: z.string().optional() +}); + +export async function GET(request: NextRequest) { + try { + const { organization, session, membership } = await requireOrganizationAccess({ + permission: CRM_ACTIVITY_PERMISSIONS.read + }); + const filters = activityListQuerySchema.parse({ + search: request.nextUrl.searchParams.get('search') ?? undefined, + primaryEntityType: request.nextUrl.searchParams.get('primaryEntityType') ?? undefined, + primaryEntityId: request.nextUrl.searchParams.get('primaryEntityId') ?? undefined, + activityType: request.nextUrl.searchParams.get('activityType') ?? undefined, + status: request.nextUrl.searchParams.get('status') ?? undefined, + ownerId: request.nextUrl.searchParams.get('ownerId') ?? undefined, + assignedToId: request.nextUrl.searchParams.get('assignedToId') ?? undefined + }); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const response = await listActivities(organization.id, filters, accessContext); + return NextResponse.json(response); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + return NextResponse.json({ message: 'Unable to load activities' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const { organization, session, membership } = await requireOrganizationAccess({ + permission: CRM_ACTIVITY_PERMISSIONS.create + }); + const payload = activityMutationSchema.parse(await request.json()); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const created = await createActivity(organization.id, session.user.id, payload, accessContext); + await auditCreate({ + organizationId: organization.id, + branchId: created.branchId, + userId: session.user.id, + entityType: 'crm_activity', + entityId: created.id, + afterData: created + }); + return NextResponse.json(created); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + return NextResponse.json({ message: 'Unable to create activity' }, { status: 500 }); + } +} diff --git a/src/app/dashboard/crm/activities/page.tsx b/src/app/dashboard/crm/activities/page.tsx new file mode 100644 index 0000000..44c2570 --- /dev/null +++ b/src/app/dashboard/crm/activities/page.tsx @@ -0,0 +1,27 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import PageContainer from '@/components/layout/page-container'; +import { ActivityListing } from '@/features/crm/activities/components/activity-listing'; +import { + activitiesQueryOptions, + activityReferenceDataOptions +} from '@/features/crm/activities/api/queries'; +import { getQueryClient } from '@/lib/query-client'; + +export default function ActivitiesPage() { + const queryClient = getQueryClient(); + const filters = {}; + + void queryClient.prefetchQuery(activitiesQueryOptions(filters)); + void queryClient.prefetchQuery(activityReferenceDataOptions()); + + return ( + + + + + + ); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index eb2a2d3..71c226b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -674,6 +674,48 @@ export const crmOpportunityFollowups = pgTable('crm_opportunity_followups', { updatedBy: text('updated_by').notNull() }); +export const crmActivities = pgTable('crm_activities', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + primaryEntityType: text('primary_entity_type').notNull(), + primaryEntityId: text('primary_entity_id'), + primaryRecordLabel: text('primary_record_label'), + relatedRecords: jsonb('related_records').default([]).notNull(), + customerId: text('customer_id'), + contactId: text('contact_id'), + leadId: text('lead_id'), + opportunityId: text('opportunity_id'), + quotationId: text('quotation_id'), + activityType: text('activity_type').notNull(), + subject: text('subject').notNull(), + description: text('description'), + ownerId: text('owner_id').notNull(), + assignedToId: text('assigned_to_id'), + scheduledStartAt: timestamp('scheduled_start_at', { withTimezone: true }), + scheduledEndAt: timestamp('scheduled_end_at', { withTimezone: true }), + dueAt: timestamp('due_at', { withTimezone: true }), + completedAt: timestamp('completed_at', { withTimezone: true }), + cancelledAt: timestamp('cancelled_at', { withTimezone: true }), + skippedAt: timestamp('skipped_at', { withTimezone: true }), + status: text('status').notNull(), + priority: text('priority').notNull(), + outcomeSummary: text('outcome_summary'), + cancellationReason: text('cancellation_reason'), + skipReason: text('skip_reason'), + nextAction: text('next_action'), + branchId: text('branch_id'), + productType: text('product_type'), + isInternalOnly: boolean('is_internal_only').default(false).notNull(), + isPricingSensitive: boolean('is_pricing_sensitive').default(false).notNull(), + parentActivityId: text('parent_activity_id'), + metadata: jsonb('metadata'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + createdBy: text('created_by').notNull(), + updatedBy: text('updated_by').notNull() +}); + export const crmOpportunityCustomers = pgTable('crm_opportunity_customers', { id: text('id').primaryKey(), organizationId: text('organization_id').notNull(), diff --git a/src/features/crm/activities/api/mutations.ts b/src/features/crm/activities/api/mutations.ts new file mode 100644 index 0000000..4d7c60d --- /dev/null +++ b/src/features/crm/activities/api/mutations.ts @@ -0,0 +1,104 @@ +import { mutationOptions } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; +import { + assignActivity, + cancelActivity, + completeActivity, + createActivity, + deleteActivity, + rescheduleActivity, + updateActivity +} from './service'; +import { activityKeys } from './queries'; +import type { + ActivityAssignPayload, + ActivityCancelPayload, + ActivityCompletePayload, + ActivityMutationPayload, + ActivityReschedulePayload, + ActivityUpdatePayload +} from './types'; + +async function invalidateActivityLists() { + await getQueryClient().invalidateQueries({ queryKey: activityKeys.lists() }); +} + +async function invalidateActivityDetail(id: string) { + await getQueryClient().invalidateQueries({ queryKey: activityKeys.detail(id) }); +} + +async function invalidateActivityMutationQueries(id: string) { + await Promise.all([invalidateActivityLists(), invalidateActivityDetail(id)]); +} + +export const createActivityMutation = mutationOptions({ + mutationFn: (payload: ActivityMutationPayload) => createActivity(payload), + onSettled: async (data, error) => { + if (!error) { + await Promise.all([ + invalidateActivityLists(), + data ? invalidateActivityDetail(data.id) : Promise.resolve() + ]); + } + } +}); + +export const updateActivityMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: ActivityUpdatePayload }) => + updateActivity(id, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateActivityMutationQueries(variables.id); + } + } +}); + +export const deleteActivityMutation = mutationOptions({ + mutationFn: ({ id }: { id: string }) => deleteActivity(id), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateActivityLists(); + getQueryClient().removeQueries({ queryKey: activityKeys.detail(variables.id) }); + } + } +}); + +export const completeActivityMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: ActivityCompletePayload }) => + completeActivity(id, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateActivityMutationQueries(variables.id); + } + } +}); + +export const cancelActivityMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: ActivityCancelPayload }) => + cancelActivity(id, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateActivityMutationQueries(variables.id); + } + } +}); + +export const assignActivityMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: ActivityAssignPayload }) => + assignActivity(id, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateActivityMutationQueries(variables.id); + } + } +}); + +export const rescheduleActivityMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: ActivityReschedulePayload }) => + rescheduleActivity(id, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateActivityMutationQueries(variables.id); + } + } +}); diff --git a/src/features/crm/activities/api/queries.ts b/src/features/crm/activities/api/queries.ts new file mode 100644 index 0000000..67d7d61 --- /dev/null +++ b/src/features/crm/activities/api/queries.ts @@ -0,0 +1,30 @@ +import { queryOptions } from '@tanstack/react-query'; +import { getActivities, getActivityById, getActivityReferenceData } from './service'; +import type { ActivityListFilters } from './types'; + +export const activityKeys = { + all: ['crm-activities'] as const, + lists: () => [...activityKeys.all, 'list'] as const, + list: (filters: ActivityListFilters) => [...activityKeys.lists(), filters] as const, + details: () => [...activityKeys.all, 'detail'] as const, + detail: (id: string) => [...activityKeys.details(), id] as const, + reference: () => [...activityKeys.all, 'reference'] as const +}; + +export const activitiesQueryOptions = (filters: ActivityListFilters) => + queryOptions({ + queryKey: activityKeys.list(filters), + queryFn: () => getActivities(filters) + }); + +export const activityByIdOptions = (id: string) => + queryOptions({ + queryKey: activityKeys.detail(id), + queryFn: () => getActivityById(id) + }); + +export const activityReferenceDataOptions = () => + queryOptions({ + queryKey: activityKeys.reference(), + queryFn: () => getActivityReferenceData() + }); diff --git a/src/features/crm/activities/api/service.ts b/src/features/crm/activities/api/service.ts new file mode 100644 index 0000000..d0b9975 --- /dev/null +++ b/src/features/crm/activities/api/service.ts @@ -0,0 +1,103 @@ +import { apiClient } from '@/lib/api-client'; +import type { + ActivityAssignPayload, + ActivityCancelPayload, + ActivityCompletePayload, + ActivityListFilters, + ActivityListResponse, + ActivityMutationPayload, + ActivityRecord, + ActivityReferenceData, + ActivityReschedulePayload, + ActivityUpdatePayload +} from './types'; + +function buildSearchParams(filters: ActivityListFilters): string { + const params = new URLSearchParams(); + + if (filters.search) params.set('search', filters.search); + if (filters.primaryEntityType) params.set('primaryEntityType', filters.primaryEntityType); + if (filters.primaryEntityId) params.set('primaryEntityId', filters.primaryEntityId); + if (filters.activityType) params.set('activityType', filters.activityType); + if (filters.status) params.set('status', filters.status); + if (filters.ownerId) params.set('ownerId', filters.ownerId); + if (filters.assignedToId) params.set('assignedToId', filters.assignedToId); + + return params.toString(); +} + +export async function getActivities(filters: ActivityListFilters): Promise { + const search = buildSearchParams(filters); + return apiClient(`/crm/activities${search ? `?${search}` : ''}`); +} + +export async function getActivityById(id: string): Promise { + return apiClient(`/crm/activities/${id}`); +} + +export async function getActivityReferenceData(): Promise { + return apiClient('/crm/activities/reference'); +} + +export async function createActivity(payload: ActivityMutationPayload): Promise { + return apiClient('/crm/activities', { + method: 'POST', + body: JSON.stringify(payload) + }); +} + +export async function updateActivity( + id: string, + payload: ActivityUpdatePayload +): Promise { + return apiClient(`/crm/activities/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }); +} + +export async function deleteActivity(id: string): Promise<{ success: true }> { + return apiClient<{ success: true }>(`/crm/activities/${id}`, { + method: 'DELETE' + }); +} + +export async function completeActivity( + id: string, + payload: ActivityCompletePayload +): Promise { + return apiClient(`/crm/activities/${id}/complete`, { + method: 'POST', + body: JSON.stringify(payload) + }); +} + +export async function cancelActivity( + id: string, + payload: ActivityCancelPayload +): Promise { + return apiClient(`/crm/activities/${id}/cancel`, { + method: 'POST', + body: JSON.stringify(payload) + }); +} + +export async function assignActivity( + id: string, + payload: ActivityAssignPayload +): Promise { + return apiClient(`/crm/activities/${id}/assign`, { + method: 'POST', + body: JSON.stringify(payload) + }); +} + +export async function rescheduleActivity( + id: string, + payload: ActivityReschedulePayload +): Promise { + return apiClient(`/crm/activities/${id}/reschedule`, { + method: 'POST', + body: JSON.stringify(payload) + }); +} diff --git a/src/features/crm/activities/api/types.ts b/src/features/crm/activities/api/types.ts new file mode 100644 index 0000000..8e268d7 --- /dev/null +++ b/src/features/crm/activities/api/types.ts @@ -0,0 +1,99 @@ +import type { + CrmActivityEntityType, + CrmActivityPriority, + CrmActivityRecord, + CrmActivityRelatedRecord, + CrmActivityStatus, + CrmActivityStoredStatus, + CrmActivityType +} from '@/features/crm/activity/types'; + +export interface ActivityOption { + value: string; + label: string; +} + +export interface ActivityAssignableUser { + id: string; + name: string; + membershipRole: 'admin' | 'user'; + businessRole: string; +} + +export interface ActivityRecord extends CrmActivityRecord { + effectiveStatus: CrmActivityStatus; + ownerName: string | null; + assignedToName: string | null; + createdByName: string | null; + updatedByName: string | null; + canViewPricingSensitiveContent: boolean; + isContentRedacted: boolean; +} + +export interface ActivityListFilters { + search?: string; + primaryEntityType?: CrmActivityEntityType; + primaryEntityId?: string; + activityType?: CrmActivityType; + status?: CrmActivityStatus; + ownerId?: string; + assignedToId?: string; +} + +export interface ActivityListResponse { + items: ActivityRecord[]; + totalItems: number; +} + +export interface ActivityReferenceData { + entityTypes: ActivityOption[]; + activityTypes: ActivityOption[]; + statuses: ActivityOption[]; + priorities: ActivityOption[]; + assignableUsers: ActivityAssignableUser[]; +} + +export interface ActivityMutationPayload { + primaryEntityType: CrmActivityEntityType; + primaryEntityId?: string | null; + relatedRecords?: CrmActivityRelatedRecord[]; + activityType: CrmActivityType; + subject: string; + description?: string | null; + ownerId: string; + assignedToId?: string | null; + scheduledStartAt?: string | null; + scheduledEndAt?: string | null; + dueAt?: string | null; + status: CrmActivityStoredStatus; + priority: CrmActivityPriority; + nextAction?: string | null; + isInternalOnly?: boolean; + isPricingSensitive?: boolean; + metadata?: Record | null; +} + +export interface ActivityUpdatePayload extends Partial { + outcomeSummary?: string | null; + cancellationReason?: string | null; + skipReason?: string | null; +} + +export interface ActivityCompletePayload { + outcomeSummary: string; + nextAction?: string | null; +} + +export interface ActivityCancelPayload { + cancellationReason: string; +} + +export interface ActivityAssignPayload { + assignedToId: string | null; +} + +export interface ActivityReschedulePayload { + scheduledStartAt?: string | null; + scheduledEndAt?: string | null; + dueAt?: string | null; +} diff --git a/src/features/crm/activities/components/activity-badge.tsx b/src/features/crm/activities/components/activity-badge.tsx new file mode 100644 index 0000000..7293959 --- /dev/null +++ b/src/features/crm/activities/components/activity-badge.tsx @@ -0,0 +1,6 @@ +import { Badge } from '@/components/ui/badge'; +import { formatCrmActivityLabel, type CrmActivityType } from '@/features/crm/activity/types'; + +export function ActivityBadge({ activityType }: { activityType: CrmActivityType }) { + return {formatCrmActivityLabel(activityType)}; +} diff --git a/src/features/crm/activities/components/activity-detail.tsx b/src/features/crm/activities/components/activity-detail.tsx new file mode 100644 index 0000000..5a86b33 --- /dev/null +++ b/src/features/crm/activities/components/activity-detail.tsx @@ -0,0 +1,64 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { formatDateTime } from '@/lib/date-format'; +import type { ActivityRecord } from '../api/types'; +import { ActivityBadge } from './activity-badge'; +import { ActivityPriorityBadge } from './activity-priority-badge'; +import { ActivityStatusBadge } from './activity-status-badge'; + +function Field({ label, value }: { label: string; value: string | null | undefined }) { + return ( +
+
{label}
+
{value || '-'}
+
+ ); +} + +export function ActivityDetail({ activity }: { activity: ActivityRecord | null }) { + if (!activity) { + return ( + + + Select an activity to review its detail. + + + ); + } + + return ( + + +
+
+ {activity.subject} + {activity.primaryRecordLabel ?? 'Unlinked activity'} +
+
+ + + +
+
+
+ +
+ + + + + + +
+ +
+ + + + + +
+
+
+ ); +} diff --git a/src/features/crm/activities/components/activity-form-sheet.tsx b/src/features/crm/activities/components/activity-form-sheet.tsx new file mode 100644 index 0000000..5f98529 --- /dev/null +++ b/src/features/crm/activities/components/activity-form-sheet.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Icons } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet'; +import { useAppForm, useFormFields } from '@/components/ui/tanstack-form'; +import { createActivityMutation, updateActivityMutation } from '../api/mutations'; +import type { + ActivityMutationPayload, + ActivityRecord, + ActivityReferenceData +} from '../api/types'; +import { activityFormSchema, type ActivityFormValues } from '../schemas/activity.schema'; + +function dateToIso(value?: string): string | null { + if (!value) { + return null; + } + + return new Date(`${value}T00:00:00.000Z`).toISOString(); +} + +function toDefaultValues(activity?: ActivityRecord): ActivityFormValues { + return { + primaryEntityType: activity?.primaryEntityType ?? 'internal', + primaryEntityId: activity?.primaryEntityId ?? '', + activityType: activity?.activityType ?? 'follow_up', + subject: activity?.subject ?? '', + description: activity?.description ?? '', + ownerId: activity?.ownerId ?? '', + assignedToId: activity?.assignedToId ?? '', + scheduledDate: activity?.scheduledStartAt?.slice(0, 10) ?? '', + dueDate: activity?.dueAt?.slice(0, 10) ?? '', + status: activity?.status ?? 'draft', + priority: activity?.priority ?? 'normal', + nextAction: activity?.nextAction ?? '', + isInternalOnly: activity?.isInternalOnly ?? false, + isPricingSensitive: activity?.isPricingSensitive ?? false + }; +} + +function toPayload(values: ActivityFormValues): ActivityMutationPayload { + return { + primaryEntityType: values.primaryEntityType, + primaryEntityId: values.primaryEntityId?.trim() ? values.primaryEntityId.trim() : null, + activityType: values.activityType, + subject: values.subject.trim(), + description: values.description?.trim() || null, + ownerId: values.ownerId, + assignedToId: values.assignedToId?.trim() ? values.assignedToId : null, + scheduledStartAt: dateToIso(values.scheduledDate), + dueAt: dateToIso(values.dueDate), + status: values.status, + priority: values.priority, + nextAction: values.nextAction?.trim() || null, + isInternalOnly: values.isInternalOnly, + isPricingSensitive: values.isPricingSensitive + }; +} + +export function ActivityFormSheet({ + activity, + open, + onOpenChange, + referenceData, + defaultPrimaryEntityType, + defaultPrimaryEntityId +}: { + activity?: ActivityRecord; + open: boolean; + onOpenChange: (open: boolean) => void; + referenceData: ActivityReferenceData; + defaultPrimaryEntityType?: ActivityFormValues['primaryEntityType']; + defaultPrimaryEntityId?: string; +}) { + const isEdit = Boolean(activity); + const defaultValues = useMemo(() => { + const values = toDefaultValues(activity); + if (!activity && defaultPrimaryEntityType) { + values.primaryEntityType = defaultPrimaryEntityType; + } + if (!activity && defaultPrimaryEntityId) { + values.primaryEntityId = defaultPrimaryEntityId; + } + return values; + }, [activity, defaultPrimaryEntityId, defaultPrimaryEntityType]); + + const { FormTextField, FormTextareaField, FormDatePickerField, FormSelectField, FormCheckboxField } = + useFormFields(); + + const createMutation = useMutation({ + ...createActivityMutation, + onSuccess: async () => { + toast.success('Activity created'); + onOpenChange(false); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Unable to create activity'); + } + }); + + const updateMutation = useMutation({ + ...updateActivityMutation, + onSuccess: async () => { + toast.success('Activity updated'); + onOpenChange(false); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Unable to update activity'); + } + }); + + const isPending = createMutation.isPending || updateMutation.isPending; + + const form = useAppForm({ + defaultValues, + validators: { + onSubmit: activityFormSchema + }, + onSubmit: async ({ value }) => { + const payload = toPayload(value); + if (isEdit && activity) { + await updateMutation.mutateAsync({ id: activity.id, values: payload }); + return; + } + await createMutation.mutateAsync(payload); + } + }); + + useEffect(() => { + form.reset(defaultValues); + }, [defaultValues, form, open]); + + return ( + + + + {isEdit ? 'Edit Activity' : 'Create Activity'} + + Foundation form for shared operational activity records. + + +
+ + +
+ + ({ + value: item.value, + label: item.label + }))} + /> + ({ + value: item.value, + label: item.label + }))} + disabled={Boolean(defaultPrimaryEntityType)} + /> + + ({ + value: item.id, + label: item.name + }))} + /> + ({ + value: item.id, + label: item.name + }))} + /> + ({ + value: item.value, + label: item.label + }))} + /> + ({ + value: item.value, + label: item.label + }))} + /> + + +
+ +
+
+ +
+ + +
+
+
+
+ + + + +
+
+ ); +} diff --git a/src/features/crm/activities/components/activity-listing.tsx b/src/features/crm/activities/components/activity-listing.tsx new file mode 100644 index 0000000..0b96224 --- /dev/null +++ b/src/features/crm/activities/components/activity-listing.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { Icons } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import type { ActivityListFilters } from '../api/types'; +import { activitiesQueryOptions, activityReferenceDataOptions } from '../api/queries'; +import { ActivityBadge } from './activity-badge'; +import { ActivityDetail } from './activity-detail'; +import { ActivityFormSheet } from './activity-form-sheet'; +import { ActivityPriorityBadge } from './activity-priority-badge'; +import { ActivityStatusBadge } from './activity-status-badge'; + +export function ActivityListing({ initialFilters }: { initialFilters: ActivityListFilters }) { + const [sheetOpen, setSheetOpen] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const { data } = useSuspenseQuery(activitiesQueryOptions(initialFilters)); + const { data: referenceData } = useSuspenseQuery(activityReferenceDataOptions()); + + const selectedActivity = useMemo( + () => data.items.find((item) => item.id === (selectedId ?? data.items[0]?.id)) ?? null, + [data.items, selectedId] + ); + + return ( +
+
+ +
+
+
+ {!data.items.length ? ( + + + No activities created yet. + + + ) : ( + data.items.map((item) => ( + + )) + )} +
+ +
+ { + setSheetOpen(open); + }} + referenceData={referenceData} + /> +
+ ); +} diff --git a/src/features/crm/activities/components/activity-priority-badge.tsx b/src/features/crm/activities/components/activity-priority-badge.tsx new file mode 100644 index 0000000..08de636 --- /dev/null +++ b/src/features/crm/activities/components/activity-priority-badge.tsx @@ -0,0 +1,16 @@ +import { Badge } from '@/components/ui/badge'; +import { formatCrmActivityLabel, type CrmActivityPriority } from '@/features/crm/activity/types'; + +const PRIORITY_VARIANTS: Record< + CrmActivityPriority, + 'secondary' | 'outline' | 'default' | 'destructive' +> = { + low: 'outline', + normal: 'secondary', + high: 'default', + critical: 'destructive' +}; + +export function ActivityPriorityBadge({ priority }: { priority: CrmActivityPriority }) { + return {formatCrmActivityLabel(priority)}; +} diff --git a/src/features/crm/activities/components/activity-status-badge.tsx b/src/features/crm/activities/components/activity-status-badge.tsx new file mode 100644 index 0000000..c47f187 --- /dev/null +++ b/src/features/crm/activities/components/activity-status-badge.tsx @@ -0,0 +1,16 @@ +import { Badge } from '@/components/ui/badge'; +import { formatCrmActivityLabel, type CrmActivityStatus } from '@/features/crm/activity/types'; + +const STATUS_VARIANTS: Record = { + draft: 'outline', + scheduled: 'secondary', + in_progress: 'default', + completed: 'secondary', + cancelled: 'destructive', + skipped: 'outline', + overdue: 'destructive' +}; + +export function ActivityStatusBadge({ status }: { status: CrmActivityStatus }) { + return {formatCrmActivityLabel(status)}; +} diff --git a/src/features/crm/activities/schemas/activity.schema.ts b/src/features/crm/activities/schemas/activity.schema.ts new file mode 100644 index 0000000..693a1ae --- /dev/null +++ b/src/features/crm/activities/schemas/activity.schema.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; +import { + CRM_ACTIVITY_ENTITY_TYPES, + CRM_ACTIVITY_PRIORITIES, + CRM_ACTIVITY_STORED_STATUSES, + CRM_ACTIVITY_TYPES +} from '@/features/crm/activity/types'; + +const isoDateTimeSchema = z + .string() + .datetime({ offset: true }) + .or(z.literal('')) + .nullable() + .optional() + .transform((value) => (value ? value : null)); + +export const activityRelatedRecordSchema = z.object({ + entityType: z.enum(CRM_ACTIVITY_ENTITY_TYPES), + entityId: z.string().min(1, 'Related record id is required') +}); + +export const activityMutationSchema = z.object({ + primaryEntityType: z.enum(CRM_ACTIVITY_ENTITY_TYPES), + primaryEntityId: z.string().nullable().optional(), + relatedRecords: z.array(activityRelatedRecordSchema).optional(), + activityType: z.enum(CRM_ACTIVITY_TYPES), + subject: z.string().trim().min(1, 'Subject is required'), + description: z.string().nullable().optional(), + ownerId: z.string().min(1, 'Owner is required'), + assignedToId: z.string().nullable().optional(), + scheduledStartAt: isoDateTimeSchema, + scheduledEndAt: isoDateTimeSchema, + dueAt: isoDateTimeSchema, + status: z.enum(CRM_ACTIVITY_STORED_STATUSES), + priority: z.enum(CRM_ACTIVITY_PRIORITIES), + nextAction: z.string().nullable().optional(), + isInternalOnly: z.boolean().optional(), + isPricingSensitive: z.boolean().optional(), + metadata: z.record(z.string(), z.unknown()).nullable().optional() +}); + +export const activityUpdateSchema = activityMutationSchema + .partial() + .extend({ + outcomeSummary: z.string().nullable().optional(), + cancellationReason: z.string().nullable().optional(), + skipReason: z.string().nullable().optional() + }); + +export const activityCompleteSchema = z.object({ + outcomeSummary: z.string().trim().min(1, 'Outcome summary is required'), + nextAction: z.string().nullable().optional() +}); + +export const activityCancelSchema = z.object({ + cancellationReason: z.string().trim().min(1, 'Cancellation reason is required') +}); + +export const activityAssignSchema = z.object({ + assignedToId: z.string().nullable() +}); + +export const activityRescheduleSchema = z.object({ + scheduledStartAt: isoDateTimeSchema, + scheduledEndAt: isoDateTimeSchema, + dueAt: isoDateTimeSchema +}); + +export const activityFormSchema = z.object({ + primaryEntityType: z.enum(CRM_ACTIVITY_ENTITY_TYPES), + primaryEntityId: z.string().optional(), + activityType: z.enum(CRM_ACTIVITY_TYPES), + subject: z.string().trim().min(1, 'Subject is required'), + description: z.string().optional(), + ownerId: z.string().min(1, 'Owner is required'), + assignedToId: z.string().optional(), + scheduledDate: z.string().optional(), + dueDate: z.string().optional(), + status: z.enum(CRM_ACTIVITY_STORED_STATUSES), + priority: z.enum(CRM_ACTIVITY_PRIORITIES), + nextAction: z.string().optional(), + isInternalOnly: z.boolean(), + isPricingSensitive: z.boolean() +}); + +export type ActivityFormValues = z.infer; diff --git a/src/features/crm/activities/server/adapters/lead-followup.adapter.ts b/src/features/crm/activities/server/adapters/lead-followup.adapter.ts new file mode 100644 index 0000000..e8f51db --- /dev/null +++ b/src/features/crm/activities/server/adapters/lead-followup.adapter.ts @@ -0,0 +1,6 @@ +import type { LeadFollowupSummary } from '@/features/crm/leads/types'; + +export interface LeadFollowupAdapterCandidate { + source: 'lead_followup'; + followup: LeadFollowupSummary; +} diff --git a/src/features/crm/activities/server/adapters/opportunity-followup.adapter.ts b/src/features/crm/activities/server/adapters/opportunity-followup.adapter.ts new file mode 100644 index 0000000..7d59b14 --- /dev/null +++ b/src/features/crm/activities/server/adapters/opportunity-followup.adapter.ts @@ -0,0 +1,6 @@ +import type { OpportunityFollowupRecord } from '@/features/crm/opportunities/api/types'; + +export interface OpportunityFollowupAdapterCandidate { + source: 'opportunity_followup'; + followup: OpportunityFollowupRecord; +} diff --git a/src/features/crm/activities/server/adapters/quotation-followup.adapter.ts b/src/features/crm/activities/server/adapters/quotation-followup.adapter.ts new file mode 100644 index 0000000..75d5f40 --- /dev/null +++ b/src/features/crm/activities/server/adapters/quotation-followup.adapter.ts @@ -0,0 +1,6 @@ +import type { QuotationFollowupRecord } from '@/features/crm/quotations/api/types'; + +export interface QuotationFollowupAdapterCandidate { + source: 'quotation_followup'; + followup: QuotationFollowupRecord; +} diff --git a/src/features/crm/activities/server/integration-contracts.ts b/src/features/crm/activities/server/integration-contracts.ts new file mode 100644 index 0000000..bc7414d --- /dev/null +++ b/src/features/crm/activities/server/integration-contracts.ts @@ -0,0 +1,32 @@ +import type { CrmActivityEntityType, CrmActivityRecord, CrmActivityStatus } from '@/features/crm/activity/types'; + +export type ActivityIntegrationEventType = + | 'activity.created' + | 'activity.updated' + | 'activity.assigned' + | 'activity.completed' + | 'activity.cancelled' + | 'activity.rescheduled' + | 'activity.deleted'; + +export interface ActivityProjectionEnvelope { + eventType: ActivityIntegrationEventType; + organizationId: string; + activityId: string; + primaryRecord: { + entityType: CrmActivityEntityType; + entityId: string | null; + }; + effectiveStatus: CrmActivityStatus; + occurredAt: string; + actorUserId: string; +} + +export interface ActivityProjectionContract { + activity: CrmActivityRecord; + envelope: ActivityProjectionEnvelope; +} + +export interface ActivityProjectionPublisher { + publish(contract: ActivityProjectionContract): Promise; +} diff --git a/src/features/crm/activities/server/read-model.ts b/src/features/crm/activities/server/read-model.ts new file mode 100644 index 0000000..9941058 --- /dev/null +++ b/src/features/crm/activities/server/read-model.ts @@ -0,0 +1,98 @@ +import type { ActivityRow } from './repository'; +import { resolveUserDisplays } from '@/features/foundation/display/server/display-resolver'; +import { canViewQuotationPricing, type CrmSecurityContext } from '@/features/crm/security/server/service'; +import { resolveCrmActivityStatus } from '@/features/crm/activity/types'; +import type { ActivityRecord } from '../api/types'; + +function toRecordMetadata(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + return value as Record; +} + +function canViewPricingForRow(context: CrmSecurityContext, row: ActivityRow): boolean { + if (!row.isPricingSensitive) { + return true; + } + + if (!row.quotationId && row.primaryEntityType !== 'quotation' && row.primaryEntityType !== 'purchase_order') { + return true; + } + + return canViewQuotationPricing(context); +} + +export async function hydrateActivityRecords( + rows: ActivityRow[], + context: CrmSecurityContext +): Promise { + const userIds = [...new Set(rows.flatMap((row) => [row.ownerId, row.assignedToId, row.createdBy, row.updatedBy]).filter((value): value is string => Boolean(value)))]; + const userMap = await resolveUserDisplays(userIds); + + return rows.map((row) => { + const effectiveStatus = resolveCrmActivityStatus({ + status: row.status as ActivityRecord['status'], + scheduledStartAt: row.scheduledStartAt?.toISOString() ?? null, + dueAt: row.dueAt?.toISOString() ?? null, + completedAt: row.completedAt?.toISOString() ?? null, + cancelledAt: row.cancelledAt?.toISOString() ?? null, + skippedAt: row.skippedAt?.toISOString() ?? null + }); + + const canViewPricingSensitiveContent = canViewPricingForRow(context, row); + const shouldRedact = row.isPricingSensitive && !canViewPricingSensitiveContent; + + return { + id: row.id, + organizationId: row.organizationId, + primaryEntityType: row.primaryEntityType as ActivityRecord['primaryEntityType'], + primaryEntityId: row.primaryEntityId ?? null, + primaryRecordLabel: row.primaryRecordLabel ?? null, + relatedRecords: Array.isArray(row.relatedRecords) + ? (row.relatedRecords as ActivityRecord['relatedRecords']) + : [], + customerId: row.customerId ?? null, + contactId: row.contactId ?? null, + leadId: row.leadId ?? null, + opportunityId: row.opportunityId ?? null, + quotationId: row.quotationId ?? null, + activityType: row.activityType as ActivityRecord['activityType'], + subject: row.subject, + description: shouldRedact ? null : row.description ?? null, + ownerId: row.ownerId, + assignedToId: row.assignedToId ?? null, + scheduledStartAt: row.scheduledStartAt?.toISOString() ?? null, + scheduledEndAt: row.scheduledEndAt?.toISOString() ?? null, + dueAt: row.dueAt?.toISOString() ?? null, + completedAt: row.completedAt?.toISOString() ?? null, + cancelledAt: row.cancelledAt?.toISOString() ?? null, + skippedAt: row.skippedAt?.toISOString() ?? null, + status: row.status as ActivityRecord['status'], + effectiveStatus, + priority: row.priority as ActivityRecord['priority'], + outcomeSummary: shouldRedact ? null : row.outcomeSummary ?? null, + cancellationReason: row.cancellationReason ?? null, + skipReason: row.skipReason ?? null, + nextAction: shouldRedact ? null : row.nextAction ?? null, + branchId: row.branchId ?? null, + productType: row.productType ?? null, + isInternalOnly: row.isInternalOnly, + isPricingSensitive: row.isPricingSensitive, + parentActivityId: row.parentActivityId ?? null, + metadata: toRecordMetadata(row.metadata), + createdBy: row.createdBy, + updatedBy: row.updatedBy, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + ownerName: userMap.get(row.ownerId)?.name ?? null, + assignedToName: row.assignedToId ? (userMap.get(row.assignedToId)?.name ?? null) : null, + createdByName: userMap.get(row.createdBy)?.name ?? null, + updatedByName: userMap.get(row.updatedBy)?.name ?? null, + canViewPricingSensitiveContent, + isContentRedacted: shouldRedact + }; + }); +} diff --git a/src/features/crm/activities/server/repository.ts b/src/features/crm/activities/server/repository.ts new file mode 100644 index 0000000..b3c8ead --- /dev/null +++ b/src/features/crm/activities/server/repository.ts @@ -0,0 +1,93 @@ +import { and, desc, eq, ilike, isNull, or, type SQL } from 'drizzle-orm'; +import { crmActivities } from '@/db/schema'; +import { db } from '@/lib/db'; + +export type ActivityRow = typeof crmActivities.$inferSelect; +export type InsertActivityRow = typeof crmActivities.$inferInsert; + +export interface ActivityListQuery { + organizationId: string; + search?: string; + primaryEntityType?: string; + primaryEntityId?: string; + activityType?: string; + ownerId?: string; + assignedToId?: string; +} + +export async function insertActivity(values: InsertActivityRow): Promise { + const [created] = await db.insert(crmActivities).values(values).returning(); + return created; +} + +export async function updateActivityRow( + id: string, + organizationId: string, + values: Partial +): Promise { + const [updated] = await db + .update(crmActivities) + .set(values) + .where(and(eq(crmActivities.id, id), eq(crmActivities.organizationId, organizationId))) + .returning(); + + return updated; +} + +export async function getActivityRow(id: string, organizationId: string): Promise { + const [row] = await db + .select() + .from(crmActivities) + .where( + and( + eq(crmActivities.id, id), + eq(crmActivities.organizationId, organizationId), + isNull(crmActivities.deletedAt) + ) + ) + .limit(1); + + return row; +} + +export async function listActivityRows(query: ActivityListQuery): Promise { + const filters: SQL[] = [ + eq(crmActivities.organizationId, query.organizationId), + isNull(crmActivities.deletedAt) + ]; + + if (query.search) { + filters.push( + or( + ilike(crmActivities.subject, `%${query.search}%`), + ilike(crmActivities.description, `%${query.search}%`) + )! + ); + } + + if (query.primaryEntityType) { + filters.push(eq(crmActivities.primaryEntityType, query.primaryEntityType)); + } + + if (query.primaryEntityId) { + filters.push(eq(crmActivities.primaryEntityId, query.primaryEntityId)); + } + + if (query.activityType) { + filters.push(eq(crmActivities.activityType, query.activityType)); + } + + if (query.ownerId) { + filters.push(eq(crmActivities.ownerId, query.ownerId)); + } + + if (query.assignedToId) { + filters.push(eq(crmActivities.assignedToId, query.assignedToId)); + } + + return db + .select() + .from(crmActivities) + .where(and(...filters)) + .orderBy(desc(crmActivities.updatedAt), desc(crmActivities.createdAt)); +} diff --git a/src/features/crm/activities/server/service.ts b/src/features/crm/activities/server/service.ts new file mode 100644 index 0000000..3173c54 --- /dev/null +++ b/src/features/crm/activities/server/service.ts @@ -0,0 +1,782 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import { memberships, users, crmContactShares, crmCustomerContacts, crmCustomers } from '@/db/schema'; +import { db } from '@/lib/db'; +import { AuthError } from '@/lib/auth/session'; +import { + CRM_ACTIVITY_ENTITY_TYPES, + CRM_ACTIVITY_PERMISSIONS, + CRM_ACTIVITY_PRIORITIES, + CRM_ACTIVITY_STATUSES, + CRM_ACTIVITY_TYPES, + formatCrmActivityLabel, + type CrmActivityEntityType, + type CrmActivityRelatedRecord, + type CrmActivityStoredStatus +} from '@/features/crm/activity/types'; +import { getCustomerDetail, type CustomerAccessContext } from '@/features/crm/customers/server/service'; +import { getLeadById, type LeadAccessContext } from '@/features/crm/leads/server/service'; +import { + getOpportunityDetail, + type OpportunityAccessContext +} from '@/features/crm/opportunities/server/service'; +import { + getQuotationDetail, + type QuotationAccessContext +} from '@/features/crm/quotations/server/service'; +import { + canAccessContact, + canAccessScopedCrmRecord, + type CrmSecurityContext +} from '@/features/crm/security/server/service'; +import type { + ActivityAssignPayload, + ActivityAssignableUser, + ActivityCancelPayload, + ActivityCompletePayload, + ActivityListFilters, + ActivityListResponse, + ActivityMutationPayload, + ActivityOption, + ActivityRecord, + ActivityReferenceData, + ActivityReschedulePayload, + ActivityUpdatePayload +} from '../api/types'; +import { getActivityRow, insertActivity, listActivityRows, updateActivityRow } from './repository'; +import { hydrateActivityRecords } from './read-model'; + +type ActivityAccessContext = CrmSecurityContext; + +interface PrimaryRecordContext { + primaryEntityId: string | null; + primaryRecordLabel: string | null; + branchId: string | null; + productType: string | null; + customerId: string | null; + contactId: string | null; + leadId: string | null; + opportunityId: string | null; + quotationId: string | null; +} + +function isManagerLike(context: ActivityAccessContext): boolean { + const roles = context.businessRoles ?? [context.businessRole]; + return ( + context.membershipRole === 'admin' || + context.ownershipScope === 'organization' || + context.ownershipScope === 'team' || + roles.some((role) => role.includes('manager') || role === 'crm_admin') + ); +} + +function assertActivityPermission( + context: ActivityAccessContext, + permission: string, + message = 'Forbidden' +) { + if (!context.permissions.includes(permission)) { + throw new AuthError(message, 403); + } +} + +function toDateOrNull(value?: string | null): Date | null { + if (!value) { + return null; + } + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function uniqueRelatedRecords( + records: CrmActivityRelatedRecord[] | undefined +): CrmActivityRelatedRecord[] { + const map = new Map(); + for (const record of records ?? []) { + map.set(`${record.entityType}:${record.entityId}`, record); + } + return [...map.values()]; +} + +function validateRelatedRecords( + primaryEntityType: CrmActivityEntityType, + primaryEntityId: string | null, + relatedRecords: CrmActivityRelatedRecord[] +) { + for (const record of relatedRecords) { + if (record.entityType === primaryEntityType && record.entityId === primaryEntityId) { + throw new AuthError('Related records must not duplicate the primary record', 400); + } + } +} + +function assertTransitionAllowed( + currentStatus: CrmActivityStoredStatus, + nextStatus: CrmActivityStoredStatus +) { + const allowed: Record = { + draft: ['draft', 'scheduled', 'cancelled'], + scheduled: ['scheduled', 'in_progress', 'completed', 'cancelled', 'skipped'], + in_progress: ['in_progress', 'completed', 'cancelled', 'skipped'], + completed: ['completed'], + cancelled: ['cancelled'], + skipped: ['skipped'] + }; + + if (!allowed[currentStatus].includes(nextStatus)) { + throw new AuthError(`Activity cannot transition from ${currentStatus} to ${nextStatus}`, 400); + } +} + +async function assertMembershipUserBelongsToOrganization(userId: string, organizationId: string) { + const [membership] = await db + .select({ + userId: memberships.userId, + role: memberships.role, + businessRole: memberships.businessRole, + name: users.name + }) + .from(memberships) + .innerJoin(users, eq(users.id, memberships.userId)) + .where(and(eq(memberships.organizationId, organizationId), eq(memberships.userId, userId))) + .limit(1); + + if (!membership) { + throw new AuthError('User not found in organization', 404); + } + + return membership; +} + +async function listAssignableUsers(organizationId: string): Promise { + const rows = await db + .select({ + id: users.id, + name: users.name, + membershipRole: memberships.role, + businessRole: memberships.businessRole + }) + .from(memberships) + .innerJoin(users, eq(users.id, memberships.userId)) + .where(eq(memberships.organizationId, organizationId)); + + return rows.map((row) => ({ + id: row.id, + name: row.name, + membershipRole: row.membershipRole === 'admin' ? 'admin' : 'user', + businessRole: row.businessRole + })); +} + +async function resolvePrimaryRecordContext(input: { + organizationId: string; + context: ActivityAccessContext; + primaryEntityType: CrmActivityEntityType; + primaryEntityId?: string | null; +}): Promise { + const { organizationId, context, primaryEntityType, primaryEntityId } = input; + + if (primaryEntityType === 'internal') { + return { + primaryEntityId: null, + primaryRecordLabel: 'Internal Only', + branchId: null, + productType: null, + customerId: null, + contactId: null, + leadId: null, + opportunityId: null, + quotationId: null + }; + } + + if (!primaryEntityId) { + throw new AuthError('Primary record id is required for this activity type', 400); + } + + if (primaryEntityType === 'customer') { + const customer = await getCustomerDetail(primaryEntityId, organizationId, context as CustomerAccessContext); + return { + primaryEntityId, + primaryRecordLabel: customer.name, + branchId: customer.branchId, + productType: null, + customerId: customer.id, + contactId: null, + leadId: null, + opportunityId: null, + quotationId: null + }; + } + + if (primaryEntityType === 'contact') { + const [contact] = await db + .select({ + id: crmCustomerContacts.id, + name: crmCustomerContacts.name, + customerId: crmCustomerContacts.customerId, + createdBy: crmCustomerContacts.createdBy, + ownerUserId: crmCustomers.ownerUserId, + sharedToUserIds: crmContactShares.sharedToUserId, + branchId: crmCustomers.branchId + }) + .from(crmCustomerContacts) + .innerJoin(crmCustomers, eq(crmCustomers.id, crmCustomerContacts.customerId)) + .leftJoin( + crmContactShares, + and( + eq(crmContactShares.contactId, crmCustomerContacts.id), + isNull(crmContactShares.deletedAt) + ) + ) + .where( + and( + eq(crmCustomerContacts.id, primaryEntityId), + eq(crmCustomerContacts.organizationId, organizationId), + isNull(crmCustomerContacts.deletedAt) + ) + ) + .limit(1); + + if (!contact) { + throw new AuthError('Contact not found', 404); + } + + if ( + !canAccessContact(context, { + branchId: contact.branchId, + createdBy: contact.createdBy, + ownerUserId: contact.ownerUserId, + sharedToUserIds: contact.sharedToUserIds ? [contact.sharedToUserIds] : [] + }) + ) { + throw new AuthError('Forbidden', 403); + } + + return { + primaryEntityId, + primaryRecordLabel: contact.name, + branchId: contact.branchId ?? null, + productType: null, + customerId: contact.customerId, + contactId: contact.id, + leadId: null, + opportunityId: null, + quotationId: null + }; + } + + if (primaryEntityType === 'lead') { + const lead = await getLeadById(primaryEntityId, organizationId, context as LeadAccessContext); + return { + primaryEntityId, + primaryRecordLabel: lead.code, + branchId: lead.branchId, + productType: lead.productType, + customerId: lead.customerId, + contactId: lead.contactId, + leadId: lead.id, + opportunityId: null, + quotationId: null + }; + } + + if (primaryEntityType === 'opportunity') { + const opportunity = await getOpportunityDetail( + primaryEntityId, + organizationId, + context as OpportunityAccessContext + ); + return { + primaryEntityId, + primaryRecordLabel: `${opportunity.code} ${opportunity.title}`.trim(), + branchId: opportunity.branchId, + productType: opportunity.productType, + customerId: opportunity.customerId, + contactId: null, + leadId: null, + opportunityId: opportunity.id, + quotationId: null + }; + } + + if (primaryEntityType === 'quotation') { + const quotation = await getQuotationDetail( + primaryEntityId, + organizationId, + context as QuotationAccessContext + ); + return { + primaryEntityId, + primaryRecordLabel: quotation.code, + branchId: quotation.branchId, + productType: quotation.quotationType, + customerId: quotation.customerId, + contactId: quotation.contactId, + leadId: null, + opportunityId: quotation.opportunityId, + quotationId: quotation.id + }; + } + + const opportunity = await getOpportunityDetail( + primaryEntityId, + organizationId, + context as OpportunityAccessContext + ); + if (!opportunity.poNumber) { + throw new AuthError('Purchase order activity requires an opportunity with a PO number', 400); + } + + return { + primaryEntityId, + primaryRecordLabel: opportunity.poNumber, + branchId: opportunity.branchId, + productType: opportunity.productType, + customerId: opportunity.customerId, + contactId: null, + leadId: null, + opportunityId: opportunity.id, + quotationId: null + }; +} + +function assertActivityVisibility(context: ActivityAccessContext, activity: ActivityRecord) { + const isDirectUser = + activity.createdBy === context.userId || + activity.ownerId === context.userId || + activity.assignedToId === context.userId; + + if (activity.isInternalOnly) { + if (!isDirectUser && !isManagerLike(context)) { + throw new AuthError('Forbidden', 403); + } + return; + } + + if ( + isDirectUser || + canAccessScopedCrmRecord(context, { + branchId: activity.branchId, + productType: activity.productType, + createdBy: activity.createdBy, + ownerUserId: activity.ownerId + }) + ) { + return; + } + + throw new AuthError('Forbidden', 403); +} + +async function getHydratedActivityOrThrow(id: string, organizationId: string, context: ActivityAccessContext) { + const row = await getActivityRow(id, organizationId); + if (!row) { + throw new AuthError('Activity not found', 404); + } + + const [activity] = await hydrateActivityRecords([row], context); + if (!activity) { + throw new AuthError('Activity not found', 404); + } + + assertActivityVisibility(context, activity); + return activity; +} + +function mapOption(value: string): ActivityOption { + return { value, label: formatCrmActivityLabel(value) }; +} + +function filterByEffectiveStatus(items: ActivityRecord[], status?: string) { + if (!status) { + return items; + } + + return items.filter((item) => item.effectiveStatus === status); +} + +async function buildActivityWriteValues( + organizationId: string, + userId: string, + context: ActivityAccessContext, + payload: ActivityMutationPayload | ActivityUpdatePayload, + current?: ActivityRecord +) { + const primaryEntityType = payload.primaryEntityType ?? current?.primaryEntityType; + if (!primaryEntityType || !CRM_ACTIVITY_ENTITY_TYPES.includes(primaryEntityType)) { + throw new AuthError('Invalid primary entity type', 400); + } + + const primaryContext = await resolvePrimaryRecordContext({ + organizationId, + context, + primaryEntityType, + primaryEntityId: + payload.primaryEntityId === undefined ? current?.primaryEntityId ?? null : payload.primaryEntityId + }); + + const relatedRecords = uniqueRelatedRecords( + payload.relatedRecords ?? current?.relatedRecords ?? [] + ); + validateRelatedRecords(primaryEntityType, primaryContext.primaryEntityId, relatedRecords); + + const ownerId = payload.ownerId ?? current?.ownerId; + if (!ownerId) { + throw new AuthError('Owner is required', 400); + } + await assertMembershipUserBelongsToOrganization(ownerId, organizationId); + + const assignedToId = + payload.assignedToId === undefined ? current?.assignedToId ?? null : payload.assignedToId; + if (assignedToId) { + await assertMembershipUserBelongsToOrganization(assignedToId, organizationId); + } + + const status = (payload.status ?? current?.status ?? 'draft') as CrmActivityStoredStatus; + if (!CRM_ACTIVITY_STATUSES.includes(status)) { + throw new AuthError('Invalid activity status', 400); + } + + if (!CRM_ACTIVITY_PRIORITIES.includes((payload.priority ?? current?.priority ?? 'normal') as typeof CRM_ACTIVITY_PRIORITIES[number])) { + throw new AuthError('Invalid activity priority', 400); + } + + return { + organizationId, + primaryEntityType, + primaryEntityId: primaryContext.primaryEntityId, + primaryRecordLabel: primaryContext.primaryRecordLabel, + relatedRecords, + customerId: primaryContext.customerId, + contactId: primaryContext.contactId, + leadId: primaryContext.leadId, + opportunityId: primaryContext.opportunityId, + quotationId: primaryContext.quotationId, + activityType: (payload.activityType ?? current?.activityType ?? 'follow_up') as ActivityRecord['activityType'], + subject: (payload.subject ?? current?.subject ?? '').trim(), + description: + payload.description === undefined ? current?.description ?? null : payload.description?.trim() || null, + ownerId, + assignedToId, + scheduledStartAt: toDateOrNull( + payload.scheduledStartAt === undefined + ? current?.scheduledStartAt ?? null + : payload.scheduledStartAt + ), + scheduledEndAt: toDateOrNull( + payload.scheduledEndAt === undefined ? current?.scheduledEndAt ?? null : payload.scheduledEndAt + ), + dueAt: toDateOrNull(payload.dueAt === undefined ? current?.dueAt ?? null : payload.dueAt), + status, + priority: (payload.priority ?? current?.priority ?? 'normal') as ActivityRecord['priority'], + nextAction: + payload.nextAction === undefined ? current?.nextAction ?? null : payload.nextAction?.trim() || null, + branchId: primaryContext.branchId, + productType: primaryContext.productType, + isInternalOnly: + payload.isInternalOnly === undefined ? current?.isInternalOnly ?? false : payload.isInternalOnly, + isPricingSensitive: + payload.isPricingSensitive === undefined + ? current?.isPricingSensitive ?? false + : payload.isPricingSensitive, + metadata: + payload.metadata === undefined ? current?.metadata ?? null : payload.metadata, + updatedBy: userId + }; +} + +export async function getActivityReferenceData( + organizationId: string +): Promise { + const assignableUsers = await listAssignableUsers(organizationId); + + return { + entityTypes: CRM_ACTIVITY_ENTITY_TYPES.map(mapOption), + activityTypes: CRM_ACTIVITY_TYPES.map(mapOption), + statuses: CRM_ACTIVITY_STATUSES.filter((status) => status !== 'overdue').map(mapOption), + priorities: CRM_ACTIVITY_PRIORITIES.map(mapOption), + assignableUsers + }; +} + +export async function listActivities( + organizationId: string, + filters: ActivityListFilters, + context: ActivityAccessContext +): Promise { + assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.read); + + const rows = await listActivityRows({ + organizationId, + search: filters.search, + primaryEntityType: filters.primaryEntityType, + primaryEntityId: filters.primaryEntityId, + activityType: filters.activityType, + ownerId: filters.ownerId, + assignedToId: filters.assignedToId + }); + + const hydrated = await hydrateActivityRecords(rows, context); + const visible = hydrated.filter((item) => { + try { + assertActivityVisibility(context, item); + return true; + } catch { + return false; + } + }); + const filtered = filterByEffectiveStatus(visible, filters.status); + + return { + items: filtered, + totalItems: filtered.length + }; +} + +export async function getActivityById( + id: string, + organizationId: string, + context: ActivityAccessContext +): Promise { + assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.read); + return getHydratedActivityOrThrow(id, organizationId, context); +} + +export async function createActivity( + organizationId: string, + userId: string, + payload: ActivityMutationPayload, + context: ActivityAccessContext +): Promise { + assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.create); + + const values = await buildActivityWriteValues(organizationId, userId, context, payload); + if (!values.subject) { + throw new AuthError('Subject is required', 400); + } + + const created = await insertActivity({ + id: crypto.randomUUID(), + organizationId, + primaryEntityType: values.primaryEntityType, + primaryEntityId: values.primaryEntityId, + primaryRecordLabel: values.primaryRecordLabel, + relatedRecords: values.relatedRecords, + customerId: values.customerId, + contactId: values.contactId, + leadId: values.leadId, + opportunityId: values.opportunityId, + quotationId: values.quotationId, + activityType: values.activityType, + subject: values.subject, + description: values.description, + ownerId: values.ownerId, + assignedToId: values.assignedToId, + scheduledStartAt: values.scheduledStartAt, + scheduledEndAt: values.scheduledEndAt, + dueAt: values.dueAt, + completedAt: values.status === 'completed' ? new Date() : null, + cancelledAt: values.status === 'cancelled' ? new Date() : null, + skippedAt: values.status === 'skipped' ? new Date() : null, + status: values.status, + priority: values.priority, + outcomeSummary: null, + cancellationReason: null, + skipReason: null, + nextAction: values.nextAction, + branchId: values.branchId, + productType: values.productType, + isInternalOnly: values.isInternalOnly, + isPricingSensitive: values.isPricingSensitive, + parentActivityId: null, + metadata: values.metadata, + createdBy: userId, + updatedBy: userId + }); + + return getActivityById(created.id, organizationId, context); +} + +export async function updateActivity( + id: string, + organizationId: string, + userId: string, + payload: ActivityUpdatePayload, + context: ActivityAccessContext +): Promise { + assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.update); + + const current = await getHydratedActivityOrThrow(id, organizationId, context); + assertTransitionAllowed( + current.status, + (payload.status ?? current.status) as CrmActivityStoredStatus + ); + + const values = await buildActivityWriteValues(organizationId, userId, context, payload, current); + const nextStatus = values.status; + + await updateActivityRow(id, organizationId, { + primaryEntityType: values.primaryEntityType, + primaryEntityId: values.primaryEntityId, + primaryRecordLabel: values.primaryRecordLabel, + relatedRecords: values.relatedRecords, + customerId: values.customerId, + contactId: values.contactId, + leadId: values.leadId, + opportunityId: values.opportunityId, + quotationId: values.quotationId, + activityType: values.activityType, + subject: values.subject, + description: values.description, + ownerId: values.ownerId, + assignedToId: values.assignedToId, + scheduledStartAt: values.scheduledStartAt, + scheduledEndAt: values.scheduledEndAt, + dueAt: values.dueAt, + status: nextStatus, + priority: values.priority, + outcomeSummary: + payload.outcomeSummary === undefined + ? current.outcomeSummary + : payload.outcomeSummary?.trim() || null, + cancellationReason: + payload.cancellationReason === undefined + ? current.cancellationReason + : payload.cancellationReason?.trim() || null, + skipReason: + payload.skipReason === undefined ? current.skipReason : payload.skipReason?.trim() || null, + nextAction: values.nextAction, + branchId: values.branchId, + productType: values.productType, + isInternalOnly: values.isInternalOnly, + isPricingSensitive: values.isPricingSensitive, + metadata: values.metadata, + completedAt: nextStatus === 'completed' ? new Date() : null, + cancelledAt: nextStatus === 'cancelled' ? new Date() : null, + skippedAt: nextStatus === 'skipped' ? new Date() : null, + updatedAt: new Date(), + updatedBy: userId + }); + + return getActivityById(id, organizationId, context); +} + +export async function completeActivity( + id: string, + organizationId: string, + userId: string, + payload: ActivityCompletePayload, + context: ActivityAccessContext +): Promise { + assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.complete); + const current = await getHydratedActivityOrThrow(id, organizationId, context); + assertTransitionAllowed(current.status, 'completed'); + + await updateActivityRow(id, organizationId, { + status: 'completed', + outcomeSummary: payload.outcomeSummary.trim(), + nextAction: payload.nextAction?.trim() || null, + completedAt: new Date(), + updatedAt: new Date(), + updatedBy: userId + }); + + return getActivityById(id, organizationId, context); +} + +export async function cancelActivity( + id: string, + organizationId: string, + userId: string, + payload: ActivityCancelPayload, + context: ActivityAccessContext +): Promise { + assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.cancel); + const current = await getHydratedActivityOrThrow(id, organizationId, context); + assertTransitionAllowed(current.status, 'cancelled'); + + await updateActivityRow(id, organizationId, { + status: 'cancelled', + cancellationReason: payload.cancellationReason.trim(), + cancelledAt: new Date(), + updatedAt: new Date(), + updatedBy: userId + }); + + return getActivityById(id, organizationId, context); +} + +export async function assignActivity( + id: string, + organizationId: string, + userId: string, + payload: ActivityAssignPayload, + context: ActivityAccessContext +): Promise { + assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.reassign); + await getHydratedActivityOrThrow(id, organizationId, context); + + if (payload.assignedToId) { + await assertMembershipUserBelongsToOrganization(payload.assignedToId, organizationId); + } + + await updateActivityRow(id, organizationId, { + assignedToId: payload.assignedToId, + updatedAt: new Date(), + updatedBy: userId + }); + + return getActivityById(id, organizationId, context); +} + +export async function rescheduleActivity( + id: string, + organizationId: string, + userId: string, + payload: ActivityReschedulePayload, + context: ActivityAccessContext +): Promise { + assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.update); + const current = await getHydratedActivityOrThrow(id, organizationId, context); + + if (!payload.scheduledStartAt && !payload.dueAt) { + throw new AuthError('Reschedule requires at least one date value', 400); + } + + const nextStatus = current.status === 'draft' ? 'scheduled' : current.status; + assertTransitionAllowed(current.status, nextStatus); + + await updateActivityRow(id, organizationId, { + scheduledStartAt: + payload.scheduledStartAt === undefined + ? toDateOrNull(current.scheduledStartAt) + : toDateOrNull(payload.scheduledStartAt), + scheduledEndAt: + payload.scheduledEndAt === undefined + ? toDateOrNull(current.scheduledEndAt) + : toDateOrNull(payload.scheduledEndAt), + dueAt: payload.dueAt === undefined ? toDateOrNull(current.dueAt) : toDateOrNull(payload.dueAt), + status: nextStatus, + updatedAt: new Date(), + updatedBy: userId + }); + + return getActivityById(id, organizationId, context); +} + +export async function deleteActivity( + id: string, + organizationId: string, + userId: string, + context: ActivityAccessContext +) { + assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.delete); + await getHydratedActivityOrThrow(id, organizationId, context); + + await updateActivityRow(id, organizationId, { + deletedAt: new Date(), + updatedAt: new Date(), + updatedBy: userId + }); +} diff --git a/src/features/crm/activity/types.ts b/src/features/crm/activity/types.ts index f7a2b27..427994f 100644 --- a/src/features/crm/activity/types.ts +++ b/src/features/crm/activity/types.ts @@ -1,60 +1,100 @@ +import { PERMISSIONS } from '@/lib/auth/rbac'; + export const CRM_ACTIVITY_ENTITY_TYPES = [ + 'customer', + 'contact', 'lead', 'opportunity', 'quotation', - 'customer', - 'contact', 'purchase_order', - 'service_report' + 'internal' ] as const; export const CRM_ACTIVITY_TYPES = [ - 'site_visit', + 'follow_up', 'meeting', + 'visit', 'phone_call', 'email_follow_up', - 'quotation_follow_up', 'site_survey', - 'requirement_discussion', + 'presentation', 'internal_task', + 'reminder_action', 'other' ] as const; export const CRM_ACTIVITY_STATUSES = [ - 'planned', + 'draft', + 'scheduled', 'in_progress', 'completed', 'cancelled', + 'skipped', 'overdue' ] as const; -export const CRM_ACTIVITY_PRIORITIES = ['low', 'medium', 'high', 'urgent'] as const; +export const CRM_ACTIVITY_STORED_STATUSES = [ + 'draft', + 'scheduled', + 'in_progress', + 'completed', + 'cancelled', + 'skipped' +] as const; + +export const CRM_ACTIVITY_PRIORITIES = ['low', 'normal', 'high', 'critical'] as const; export type CrmActivityEntityType = (typeof CRM_ACTIVITY_ENTITY_TYPES)[number]; export type CrmActivityType = (typeof CRM_ACTIVITY_TYPES)[number]; export type CrmActivityStatus = (typeof CRM_ACTIVITY_STATUSES)[number]; export type CrmActivityPriority = (typeof CRM_ACTIVITY_PRIORITIES)[number]; +export type CrmActivityStoredStatus = (typeof CRM_ACTIVITY_STORED_STATUSES)[number]; + +export interface CrmActivityRelatedRecord { + entityType: CrmActivityEntityType; + entityId: string; +} export interface CrmActivityRecord { id: string; organizationId: string; - entityType: CrmActivityEntityType; - entityId: string; + primaryEntityType: CrmActivityEntityType; + primaryEntityId: string | null; + primaryRecordLabel: string | null; + relatedRecords: CrmActivityRelatedRecord[]; + customerId: string | null; + contactId: string | null; + leadId: string | null; + opportunityId: string | null; + quotationId: string | null; activityType: CrmActivityType; subject: string; description: string | null; - ownerId: string | null; + ownerId: string; assignedToId: string | null; - dueDate: string | null; - dueTime: string | null; + scheduledStartAt: string | null; + scheduledEndAt: string | null; + dueAt: string | null; completedAt: string | null; - status: Exclude; + cancelledAt: string | null; + skippedAt: string | null; + status: CrmActivityStoredStatus; priority: CrmActivityPriority; - outcome: string | null; + outcomeSummary: string | null; + cancellationReason: string | null; + skipReason: string | null; nextAction: string | null; + branchId: string | null; + productType: string | null; + isInternalOnly: boolean; + isPricingSensitive: boolean; + parentActivityId: string | null; + metadata: Record | null; createdBy: string; + updatedBy: string; createdAt: string; updatedAt: string; + deletedAt: string | null; } export const CRM_ACTIVITY_PERMISSIONS = { @@ -62,25 +102,52 @@ export const CRM_ACTIVITY_PERMISSIONS = { create: PERMISSIONS.crmActivityCreate, update: PERMISSIONS.crmActivityUpdate, complete: PERMISSIONS.crmActivityComplete, + cancel: PERMISSIONS.crmActivityUpdate, + reassign: PERMISSIONS.crmActivityUpdate, delete: PERMISSIONS.crmActivityDelete, auditLogRead: PERMISSIONS.crmAuditLogRead } as const; +function parseDate(value?: string | null): number | null { + if (!value) { + return null; + } + + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + export function resolveCrmActivityStatus(input: { - status: Exclude; - dueDate?: string | null; + status: CrmActivityStoredStatus; + scheduledStartAt?: string | null; + dueAt?: string | null; completedAt?: string | null; + cancelledAt?: string | null; + skippedAt?: string | null; now?: Date; }): CrmActivityStatus { - if (input.completedAt || input.status === 'completed' || input.status === 'cancelled') { + if ( + input.completedAt || + input.cancelledAt || + input.skippedAt || + input.status === 'completed' || + input.status === 'cancelled' || + input.status === 'skipped' + ) { return input.status; } - if (!input.dueDate) { + const compareAt = parseDate(input.dueAt) ?? parseDate(input.scheduledStartAt); + if (compareAt === null) { return input.status; } - const today = (input.now ?? new Date()).toISOString().slice(0, 10); - return input.dueDate < today ? 'overdue' : input.status; + return compareAt < (input.now ?? new Date()).getTime() ? 'overdue' : input.status; +} + +export function formatCrmActivityLabel(value: string): string { + return value + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); } -import { PERMISSIONS } from '@/lib/auth/rbac';