diff --git a/docs/implementation/task-ep1.4.1-transactional-outbox-activation-worker-runtime-2026-07-13.md b/docs/implementation/task-ep1.4.1-transactional-outbox-activation-worker-runtime-2026-07-13.md new file mode 100644 index 0000000..ce244e1 --- /dev/null +++ b/docs/implementation/task-ep1.4.1-transactional-outbox-activation-worker-runtime-2026-07-13.md @@ -0,0 +1,172 @@ +# Task EP.1.4.1 Transactional Outbox Activation & Worker Runtime - 2026-07-13 + +## Scope + +- activated Activity Business Event publication through transaction-aware outbox enqueue +- added lease-based outbox claiming fields to `business_event_outbox` +- added projection outbox worker runtime +- added manual projection operations service contracts +- added PostgreSQL `FOR UPDATE SKIP LOCKED` claim strategy in Drizzle store +- preserved existing in-process Business Event publisher for compatibility and tests +- preserved current Activity API response contracts, follow-up APIs, dashboard/report datasets, notifications, approvals, and recent-activity behavior + +## Review Summary + +Reviewed before and during implementation: + +- `AGENTS.md` +- `plans/task-ep.1.4.1.md` +- `docs/implementation/task-ep1.3-business-event-foundation-2026-07-13.md` +- `docs/implementation/task-ep1.4-projection-foundation-delivery-reliability-2026-07-13.md` +- `docs/standards/engineering-constitution.md` +- `docs/standards/project-foundations.md` +- `docs/standards/architecture-rules.md` +- `docs/standards/task-review-checklist.md` +- `docs/security/crm-authorization-boundaries.md` +- existing Activity service mutation paths +- existing Business Event publisher and projection runtime + +## Implementation Summary + +### Activity Transactional Outbox Adoption + +Activity mutation paths now enqueue Business Events to the outbox inside the same `db.transaction()` as the Activity row mutation: + +- create +- update +- assign/reassign +- reschedule +- complete +- cancel +- delete + +The Activity API still returns hydrated Activity records after commit. Projection consumers are not run synchronously from Activity source mutations. + +### Transaction-Aware Publisher + +Added: + +- `src/features/foundation/projections/server/transactional-publisher.ts` + +The publisher validates registry and payload contracts, then inserts into `business_event_outbox` using the supplied transaction/client. This prevents accidental double-publication through the in-process dispatcher. + +### Worker Runtime + +Added: + +- `src/features/foundation/projections/worker.ts` + +Worker behavior: + +- claims available events in batches +- applies a lease with `claimedBy`, `claimedAt`, and `claimExpiresAt` +- dispatches claimed committed events through `ProjectionConsumerRuntime` +- does not auto-start unless `PROJECTION_WORKER_ENABLED=true` +- supports graceful stop semantics for the in-process loop + +Default operational config: + +- polling interval: `3000ms` +- batch size: `25` +- max concurrent events: `1` +- max concurrent consumers: `4` +- claim lease: `60000ms` +- stale claim: `90000ms` +- graceful shutdown: `30000ms` + +### Multi-Instance Claiming + +Added schema fields: + +- `business_event_outbox.claimed_by` +- `business_event_outbox.claimed_at` +- `business_event_outbox.claim_expires_at` + +Generated migration: + +- `drizzle/0004_sharp_mercury.sql` + +Database store uses PostgreSQL-safe claiming with `FOR UPDATE SKIP LOCKED`. Expired `claimed` or `processing` leases are recoverable. + +### Manual Operations Contracts + +Added: + +- `src/features/foundation/projections/operations.ts` + +Contracts: + +- `drainOutbox` +- `retryEvent` +- `retryConsumer` +- `retryDeadLetter` +- `resolveDeadLetter` + +Manual operations require `systemRole: "super_admin"` at the service-contract boundary. No public UI/API route was added in this task. + +### Delivery State Machine + +Outbox lifecycle: + +```text +pending -> claimed -> processing -> completed +pending -> claimed -> processing -> retry_scheduled +pending -> claimed -> processing -> dead_letter +claimed / processing -> lease expired -> claimable again +``` + +Checkpoint lifecycle remains: + +```text +pending -> processing -> completed +pending -> processing -> retry_scheduled +pending -> processing -> skipped_unsupported_version +pending -> processing -> dead_letter +``` + +Completed checkpoints are not rerun when another consumer fails. + +### Worker Startup Strategy + +Current strategy: + +- no automatic worker startup in Next.js request/build/test paths +- dedicated process/service should instantiate `ProjectionOutboxWorker` and call `start()` +- scheduled CLI drain can call `drainOnce()` for UAT or operational repair + +This avoids duplicate unmanaged loops during hot reload, build, migrations, tests, or serverless request execution. + +### SLA Baseline + +Initial non-binding targets: + +- event available after commit: immediate +- polling interval: 1-5 seconds, default 3 seconds +- normal Activity projection delivery: under 10 seconds +- retry scheduling accuracy: within one polling interval +- stale claim recovery: lease duration plus one polling interval +- worker heartbeat/health integration: future operational surface + +## Compatibility Notes + +- Activity events are not emitted through both outbox and in-process publisher in production service paths. +- Existing in-process publisher remains available for tests and explicitly approved compatibility paths. +- Existing Dashboard, Report, Notification, Approval, Follow-up, and recent-activity behavior remains unchanged. +- No final Timeline, Calendar, Notification expansion, My Day, Manager Workspace, Executive Workspace, Forecast, or Relationship Health feature was implemented. + +## Verification + +- `node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/features/foundation/business-events/*.test.ts src/features/foundation/projections/*.test.ts src/features/crm/activities/server/business-events.test.ts` +- `npx oxlint src/features/foundation/projections src/features/crm/activities/server/business-events.ts src/features/crm/activities/server/repository.ts src/features/crm/activities/server/service.ts src/db/schema.ts` +- `npm run db:generate` + +## Typecheck Note + +`npm run typecheck` is currently blocked by generated `.next/dev/types/routes.d.ts` syntax errors unrelated to the EP.1.4.1 source changes. Targeted runtime tests and scoped lint passed for the changed files. + +## Residual Risks / Follow-up + +- Worker process entrypoint/CLI command is not yet wired into `package.json`; runtime modules are ready for a dedicated process or scheduled drain task. +- Manual retry/dead-letter APIs are intentionally not exposed publicly yet. +- Health snapshots are recorded by runtime; a user-facing operational health page remains future work. +- Future source domains must adopt the same transaction-aware publisher through dedicated cutover tasks. diff --git a/drizzle/0004_sharp_mercury.sql b/drizzle/0004_sharp_mercury.sql new file mode 100644 index 0000000..18feb37 --- /dev/null +++ b/drizzle/0004_sharp_mercury.sql @@ -0,0 +1,4 @@ +ALTER TABLE "business_event_outbox" ADD COLUMN "claimed_by" text;--> statement-breakpoint +ALTER TABLE "business_event_outbox" ADD COLUMN "claimed_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "business_event_outbox" ADD COLUMN "claim_expires_at" timestamp with time zone;--> statement-breakpoint +CREATE INDEX "business_event_outbox_claim_lease_idx" ON "business_event_outbox" USING btree ("delivery_status","claim_expires_at"); \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..756cd1a --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,7532 @@ +{ + "id": "184ea587-d697-48d3-9d1a-1b81a598da72", + "prevId": "1259693a-9083-4fd9-a1f3-00a0aeb18669", + "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.business_event_outbox": { + "name": "business_event_outbox", + "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 + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "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 + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "causation_id": { + "name": "causation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_envelope": { + "name": "event_envelope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dead_lettered_at": { + "name": "dead_lettered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "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": { + "business_event_outbox_status_available_idx": { + "name": "business_event_outbox_status_available_idx", + "columns": [ + { + "expression": "delivery_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "business_event_outbox_claim_lease_idx": { + "name": "business_event_outbox_claim_lease_idx", + "columns": [ + { + "expression": "delivery_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "business_event_outbox_org_event_idx": { + "name": "business_event_outbox_org_event_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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.projection_consumer_checkpoints": { + "name": "projection_consumer_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumer_name": { + "name": "consumer_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "projection_name": { + "name": "projection_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_attempted_at": { + "name": "first_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_duration_ms": { + "name": "processing_duration_ms", + "type": "integer", + "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": { + "projection_checkpoints_consumer_event_idx": { + "name": "projection_checkpoints_consumer_event_idx", + "columns": [ + { + "expression": "consumer_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projection_checkpoints_status_retry_idx": { + "name": "projection_checkpoints_status_retry_idx", + "columns": [ + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projection_dead_letters": { + "name": "projection_dead_letters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumer_name": { + "name": "consumer_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "projection_name": { + "name": "projection_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failure_metadata": { + "name": "failure_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution_reason": { + "name": "resolution_reason", + "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": { + "projection_dead_letters_consumer_event_idx": { + "name": "projection_dead_letters_consumer_event_idx", + "columns": [ + { + "expression": "consumer_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projection_dead_letters_org_status_idx": { + "name": "projection_dead_letters_org_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projection_health_snapshots": { + "name": "projection_health_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "projection_name": { + "name": "projection_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumer_name": { + "name": "consumer_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'healthy'" + }, + "last_processed_event_id": { + "name": "last_processed_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_successful_processing_at": { + "name": "last_successful_processing_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pending_count": { + "name": "pending_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dead_letter_count": { + "name": "dead_letter_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unsupported_version_count": { + "name": "unsupported_version_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_lag_ms": { + "name": "processing_lag_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "average_processing_duration_ms": { + "name": "average_processing_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_rebuild_at": { + "name": "last_rebuild_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rebuild_source": { + "name": "rebuild_source", + "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": { + "projection_health_org_consumer_idx": { + "name": "projection_health_org_consumer_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "consumer_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projection_rebuild_runs": { + "name": "projection_rebuild_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "projection_name": { + "name": "projection_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_from": { + "name": "occurred_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "occurred_to": { + "name": "occurred_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "reset_existing": { + "name": "reset_existing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_strategy": { + "name": "source_strategy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "projection_rebuild_runs_org_projection_idx": { + "name": "projection_rebuild_runs_org_projection_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "projection_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 fa9c175..d4b257c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1783917011479, "tag": "0003_green_squadron_supreme", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1783922653023, + "tag": "0004_sharp_mercury", + "breakpoints": true } ] } \ No newline at end of file diff --git a/plans/task-ep.1.4.1.md b/plans/task-ep.1.4.1.md new file mode 100644 index 0000000..5858eb0 --- /dev/null +++ b/plans/task-ep.1.4.1.md @@ -0,0 +1,909 @@ +# Task EP.1.4.1 – Transactional Outbox Activation & Worker Runtime + +Status: Completed +Priority: Critical +Type: Platform Runtime / Delivery Reliability / Background Processing + +## Depends On + +* EP.1.1 Activity Domain Foundation +* EP.1.2 Activity Integration & Legacy Follow-up Adapter +* EP.1.3 Business Event Foundation +* EP.1.4 Projection Foundation & Delivery Reliability +* AR.1 Architecture Transition Plan +* AR.2 Epic & Technical Design +* ENG.0 Engineering Constitution + +--- + +# Objective + +Activate the Transactional Outbox delivery model introduced in EP.1.4 and provide a production-ready worker runtime for reliable Business Event dispatch. + +This task must ensure that: + +* source-domain mutations and Business Event outbox persistence occur atomically +* only committed outbox events are dispatched +* projection consumers process events independently and idempotently +* worker execution is safe across multiple application instances +* retryable failures are rescheduled +* terminal failures are moved to governed dead-letter state +* successful source mutations are never reported as failed merely because a projection consumer fails after commit +* existing CRM APIs and business behavior remain backward compatible + +The first activated source domain is Activity. + +Other source domains may adopt the same transaction pattern in later controlled tasks. + +--- + +# Background + +EP.1.3 introduced: + +* canonical `BusinessEvent` +* Event Registry +* Publisher and Dispatcher abstractions +* Activity lifecycle event publication +* Event Sequence Matrix +* Event Consumer Matrix + +EP.1.4 introduced: + +* `business_event_outbox` +* projection consumer checkpoints +* projection dead letters +* projection rebuild runs +* projection health snapshots +* persistent projection runtime contracts +* retry policy +* Projection Registry +* no-op skeleton consumers + +However, production delivery guarantees are not active yet because: + +1. Activity source mutations do not yet persist events atomically through the outbox in every mutation path. +2. No worker currently drains pending outbox events. +3. No multi-instance-safe event claiming process exists. +4. No operational retry or dead-letter processing loop is active. +5. Manual retry and drain contracts are not yet available. + +EP.1.4.1 activates these foundations. + +--- + +# Architecture Principles + +## 1. Atomic Source Mutation and Event Persistence + +The source mutation and related outbox event must use the same database transaction. + +Required pattern: + +```text +BEGIN + +Update source-domain state + +Insert Business Event Outbox record + +Write mandatory Audit Log where required + +COMMIT +``` + +If outbox persistence fails inside the transaction, the source mutation must roll back. + +--- + +## 2. Post-Commit Consumer Processing + +Projection consumers process events only after the source transaction commits. + +A projection failure must not change the result of the already committed source mutation. + +Example: + +```text +Activity completed + ↓ +Activity row + Outbox event committed + ↓ +API returns success + ↓ +Worker dispatches event + ↓ +Timeline consumer fails + ↓ +Checkpoint schedules retry +``` + +--- + +## 3. At-Least-Once Delivery + +The runtime must assume that an event may be delivered more than once. + +Exactly-once transport is not required. + +Consumer idempotency and persistent checkpoints provide duplicate protection. + +--- + +## 4. Consumer Isolation + +An event may have multiple consumers. + +Example: + +```text +activity.completed +├── Timeline +├── Calendar +├── Dashboard +└── Notification +``` + +One failed consumer must not erase successful processing by other consumers. + +Retries must target only incomplete consumers. + +--- + +## 5. Multi-Instance Safety + +More than one application or worker process may run concurrently. + +Only one worker may claim a given event delivery unit at a time. + +The design must support: + +* database-level claiming +* lease expiration +* stale claim recovery +* duplicate-safe retries +* graceful worker shutdown + +--- + +## 6. Preserve Existing Behavior + +Existing Activity APIs and source-domain behavior remain unchanged from the caller’s perspective. + +This task must not replace: + +* existing follow-up APIs +* Dashboard datasets +* Report datasets +* existing Notification behavior +* existing Approval notification flow +* recent-activity compatibility reads + +--- + +# Review Required + +## Governance and Architecture + +* `AGENTS.md` +* `docs/standards/engineering-constitution.md` +* `docs/standards/project-foundations.md` +* `docs/standards/architecture-rules.md` +* `docs/standards/task-review-checklist.md` +* `docs/security/crm-authorization-boundaries.md` +* AR.1 Architecture Transition Plan +* AR.2 Epic & Technical Design + +## Existing Implementations + +* EP.1.3 Business Event Foundation report and code +* EP.1.4 Projection Foundation report and code +* Activity service mutation paths +* Business Event publisher +* persistent outbox publisher +* projection runtime +* projection consumer checkpoints +* projection dead letters +* projection health snapshots +* current Drizzle transaction conventions +* existing scheduled-job or worker patterns +* current application startup and shutdown behavior +* current deployment topology and Docker runtime, where documented + +--- + +# Scope + +## Part 1 — Activity Transaction Boundary Refactor + +Refactor Activity mutation paths to support transaction-scoped persistence. + +Minimum operations: + +* create +* update +* assign +* reassign +* start +* reschedule +* complete +* cancel +* delete, if governed deletion remains supported + +Each operation must persist: + +* Activity mutation +* Business Event Outbox record +* required Audit Log record + +inside one shared transaction where applicable. + +### Rules + +* Route Handlers remain thin. +* Activity Service owns business transitions. +* Transaction object must be propagated through approved service and persistence seams. +* No nested independent transaction may break atomicity. +* Event payload is created from the successful mutation result. +* An event must never be inserted before business validation completes. + +--- + +## Part 2 — Transaction-Aware Outbox Publisher + +Implement or complete a transaction-aware outbox publisher. + +Recommended contract: + +```ts +export interface TransactionalBusinessEventPublisher { + enqueue( + transaction: DatabaseTransaction, + event: BusinessEvent + ): Promise; + + enqueueMany( + transaction: DatabaseTransaction, + events: BusinessEvent[] + ): Promise; +} +``` + +### Responsibilities + +* validate registry entry +* validate schema version +* validate payload +* persist event using the supplied transaction +* preserve correlation and causation metadata +* prevent duplicate event insertion where an operation idempotency key exists +* not dispatch consumers synchronously + +--- + +## Part 3 — Outbox Worker Runtime + +Introduce a worker that drains committed outbox events. + +Worker lifecycle: + +```text +poll + ↓ +claim batch + ↓ +load active consumers + ↓ +dispatch each incomplete consumer + ↓ +record checkpoints + ↓ +mark event delivery state + ↓ +sleep / repeat +``` + +### Worker Configuration + +Support configurable: + +* enabled state +* polling interval +* batch size +* maximum concurrent events +* maximum concurrent consumers +* claim lease duration +* stale claim threshold +* worker instance identifier +* graceful shutdown timeout + +Configuration must use existing environment and configuration conventions. + +--- + +## Part 4 — Multi-Instance Event Claiming + +Implement a database-safe claiming strategy. + +Approved approaches may include: + +* `FOR UPDATE SKIP LOCKED` +* atomic conditional update with lease token +* another PostgreSQL-safe claiming mechanism + +Each claimed record should track, as appropriate: + +* claimed by +* claimed at +* lease expires at +* processing started at +* attempt count + +### Rules + +* two workers must not actively process the same event claim concurrently +* expired claims must be recoverable +* a crashed worker must not permanently block delivery +* claiming must be organization-safe but may process multiple organizations in one batch + +--- + +## Part 5 — Event and Consumer Completion Semantics + +Freeze and implement event completion rules. + +Recommended model: + +* Outbox state represents event dispatch lifecycle. +* Consumer checkpoint represents per-consumer result. +* Event is terminally completed only when all applicable active consumers reach a terminal state. + +Terminal consumer states: + +* completed +* skipped unsupported version, when governed as terminal +* dead letter +* explicitly ignored by registry policy + +### Important Rule + +A successful consumer must not be called again merely because another consumer failed. + +--- + +## Part 6 — Retry Processor + +Activate retry processing using the EP.1.4 policy. + +Default policy: + +```text +attempt 1: immediate +attempt 2: +1 minute +attempt 3: +5 minutes +attempt 4: +30 minutes +attempt 5: dead letter +``` + +Implement: + +* retry scheduling +* retryable-error classification +* non-retryable-error classification +* next-attempt selection +* attempt count update +* consumer-specific retry +* jitter where appropriate +* stale-processing recovery + +Retry values must be configurable or governed centrally. + +--- + +## Part 7 — Dead-Letter Runtime + +Activate dead-letter creation when: + +* maximum retries are exceeded +* failure is explicitly non-retryable +* event version is unsupported and policy requires review +* registry or payload incompatibility cannot be resolved automatically + +Dead-letter records must include: + +* event reference +* consumer name +* event type +* organization id +* attempt count +* sanitized error code +* sanitized error message +* first failure time +* final failure time +* resolution status + +Do not duplicate unrestricted event payloads into dead-letter storage. + +--- + +## Part 8 — Manual Retry and Drain Service Contracts + +Provide protected service contracts for operations support. + +Minimum capabilities: + +* drain pending outbox events +* retry one failed consumer checkpoint +* retry all failed consumers for one event +* retry one dead-letter item +* mark dead letter resolved with reason +* inspect sanitized processing status +* recover stale claims + +Recommended service methods: + +```ts +drainOutbox(options) +retryConsumer(eventId, consumerName, actor) +retryEvent(eventId, actor) +retryDeadLetter(deadLetterId, actor) +resolveDeadLetter(deadLetterId, reason, actor) +recoverStaleClaims(options) +``` + +Public UI is not required. + +Admin API routes are optional and should only be added when authorization and operational need are clearly defined. + +--- + +## Part 9 — Worker Startup Strategy + +Define how the worker runs in current deployment environments. + +Evaluate: + +* worker inside application process +* dedicated Node worker process +* scheduled CLI drain command +* separate Docker service using the same application image + +Preferred production direction: + +> Dedicated worker process or service using shared runtime modules. + +The implementation must not accidentally start duplicate unmanaged polling loops during: + +* Next.js hot reload +* serverless request execution +* build phase +* migration commands +* test execution + +--- + +## Part 10 — Graceful Shutdown + +Worker shutdown must: + +* stop claiming new events +* allow active handlers to finish within timeout +* release or expire unfinished leases safely +* flush structured logs +* report final worker status + +Handle common termination signals according to runtime conventions. + +--- + +## Part 11 — Projection Health Updates + +Update projection health from real worker processing. + +Track: + +* last event observed +* last successful event +* pending count +* processing count +* retry count +* dead-letter count +* unsupported-version count +* last failure time +* processing lag +* worker heartbeat +* last successful drain + +Health storage must remain payload-free. + +--- + +## Part 12 — Delivery SLA Baseline + +Freeze initial operational targets. + +Suggested targets: + +| Capability | Initial Target | +| ---------------------------------------------- | ----------------------------------------------: | +| Outbox event available after commit | immediate | +| Worker polling interval | 1–5 seconds | +| Activity projection delivery under normal load | under 10 seconds | +| Retry scheduling accuracy | within one polling interval | +| Stale claim recovery | within lease duration plus one polling interval | +| Worker health heartbeat | every 30–60 seconds | + +Final values must reflect deployment constraints and can be classified as initial non-binding SLOs. + +--- + +## Part 13 — Security and Authorization + +Worker processing must: + +* maintain organization scoping +* not bypass pricing-sensitive event restrictions +* not expose internal-only Activity details +* use consumer-specific security rules +* avoid persisting decrypted credentials or secrets +* sanitize operational errors +* audit manual retry and dead-letter resolution actions + +Manual operations must require explicit foundation or system administration permission. + +--- + +## Part 14 — Existing Publisher Compatibility + +Preserve the existing in-process publisher where needed for: + +* tests +* non-production isolated execution +* compatibility consumers not yet cut over + +However, Activity production mutation paths must use the transactional outbox publisher after activation. + +Do not publish the same Activity event through both in-process and outbox paths. + +Add a guard or configuration strategy preventing accidental double publication. + +--- + +## Part 15 — Consumer Registration Snapshot + +Freeze the consumer set used when determining applicable checkpoints. + +Define behavior when: + +* a new consumer is registered after an event was completed +* a consumer is disabled +* a consumer is renamed +* a consumer’s supported versions change +* an event has no consumers +* a projection remains contract-only + +Recommended approach: + +* persist or deterministically resolve applicable consumers at dispatch time +* document replay or backfill behavior for newly introduced consumers +* avoid reopening old completed events automatically without explicit rebuild + +--- + +## Part 16 — Delivery State Machine + +Freeze the outbox delivery lifecycle. + +Recommended model: + +```text +pending + ↓ +claimed + ↓ +processing + ├── completed + ├── retry_scheduled + └── dead_letter +``` + +Possible recovery paths: + +```text +claimed / processing + ↓ lease expired +pending +``` + +Consumer checkpoint lifecycle: + +```text +pending + ↓ +processing + ├── completed + ├── retry_scheduled + ├── skipped_unsupported_version + └── dead_letter +``` + +Implement valid transitions and reject invalid direct state jumps. + +--- + +## Part 17 — End-to-End Reliability Scenario + +Implement an end-to-end validation path for at least one Activity operation. + +Required scenario: + +```text +Complete Activity + ↓ +Activity mutation persisted + ↓ +activity.completed Outbox event persisted in same transaction + ↓ +API returns success + ↓ +Worker claims event + ↓ +Skeleton consumers execute + ↓ +Consumer checkpoints become completed + ↓ +Duplicate dispatch is skipped +``` + +Failure variation: + +```text +One consumer fails + ↓ +Activity remains completed + ↓ +Other consumers remain completed + ↓ +Failed consumer schedules retry + ↓ +Retry completes or moves to dead letter +``` + +--- + +# Deliverables + +## 1. Activity Transactional Outbox Adoption + +Atomic source mutation, audit, and event enqueue. + +## 2. Transaction-Aware Outbox Publisher + +Shared transaction-scoped persistence contract. + +## 3. Outbox Worker Runtime + +Polling, claiming, dispatch, and completion processing. + +## 4. Multi-Instance Claiming Strategy + +Lease-based or lock-based safe batch processing. + +## 5. Per-Consumer Dispatch Semantics + +Independent checkpoints and isolated retries. + +## 6. Retry Processor + +Configured retry and backoff implementation. + +## 7. Dead-Letter Runtime + +Terminal failure persistence and recovery contract. + +## 8. Manual Retry and Drain Services + +Protected operational service interfaces. + +## 9. Worker Startup and Deployment Strategy + +Current development, UAT, and production execution model. + +## 10. Graceful Shutdown Handling + +Safe worker termination behavior. + +## 11. Projection Health Integration + +Real worker health and lag metrics. + +## 12. Delivery SLA Baseline + +Initial delivery and recovery targets. + +## 13. Security and Operations Matrix + +Worker, retry, dead-letter, and administrative access rules. + +## 14. Existing Publisher Compatibility Strategy + +No-double-publication and transition rules. + +## 15. Consumer Registration Strategy + +Rules for new, disabled, renamed, and version-changed consumers. + +## 16. Delivery State Machine + +Outbox and checkpoint lifecycle rules. + +## 17. End-to-End Reliability Tests + +Atomicity, worker delivery, retry, duplicate, and recovery scenarios. + +## 18. EP.1.5 Readiness Report + +Confirm that Timeline can rely on durable incremental Activity events. + +--- + +# Constraints + +Must preserve: + +* existing Activity API contracts +* Activity lifecycle business behavior +* legacy follow-up APIs and storage +* recent-activity compatibility layer +* existing Dashboard and Report consumers +* existing Approval and Notification behavior +* existing Audit Log behavior +* existing CRM security and pricing boundaries +* EP.1.3 Event Contract and Registry +* EP.1.4 Projection Registry and checkpoint model + +Do not implement: + +* final Timeline projection +* Timeline UI +* final Calendar projection +* Calendar UI +* Notification event expansion +* My Day +* Manager Workspace +* Executive Workspace +* Relationship Health +* Forecast replacement +* external Kafka, RabbitMQ, or Redis Streams +* legacy follow-up migration +* follow-up dual-write +* Dashboard source replacement + +Do not start unmanaged worker loops inside request handlers or Client Components. + +No breaking API changes. + +--- + +# Compatibility Strategy + +Activity is the first source domain activated with transactional outbox delivery. + +Other source domains remain on their current event or notification paths until dedicated cutover tasks are approved. + +During transition: + +* Activity uses transactional outbox. +* Existing Approval notifications remain unchanged. +* Existing in-process publisher remains available for tests and approved compatibility use. +* No event may be emitted through both outbox and in-process production paths. +* Existing projection skeletons remain non-user-facing. + +--- + +# Testing Requirements + +## Atomicity Tests + +* Activity mutation and outbox insert commit together +* outbox insert failure rolls back Activity mutation +* validation failure creates neither mutation nor event +* audit failure behavior follows existing transaction governance +* multiple events enqueue in deterministic sequence + +## Worker Claim Tests + +* one worker claims one event +* two workers do not process the same active lease +* stale lease becomes recoverable +* batch size is honored +* claim order is deterministic where required +* graceful shutdown stops new claims + +## Consumer Tests + +* all active consumers receive the event +* one failure does not invalidate successful consumers +* completed checkpoint skips duplicate execution +* unsupported version follows policy +* no-consumer event terminates safely +* disabled consumer behavior follows registry rule + +## Retry Tests + +* retryable failure schedules next attempt +* non-retryable failure creates dead letter +* maximum attempts create dead letter +* retry affects only incomplete consumer +* manual retry does not rerun completed consumers +* dead-letter retry preserves original event id + +## Restart and Recovery Tests + +* pending event survives process restart simulation +* retry-scheduled checkpoint survives restart +* stale processing claim is recovered +* duplicate delivery after restart creates no duplicate effect + +## Security Tests + +* organization isolation +* sensitive payload remains minimized +* dead-letter error is sanitized +* manual retry requires authorization +* internal-only Activity does not leak through operational status APIs + +## Compatibility Tests + +* Activity API response remains unchanged +* existing recent-activity reads remain unchanged +* existing follow-up APIs remain unchanged +* Dashboard and Reports remain unchanged +* Approval notifications remain unchanged +* no duplicate Activity Business Event publication occurs + +## End-to-End Tests + +* Activity create through outbox to consumer completion +* Activity complete through outbox to consumer completion +* consumer failure followed by successful retry +* consumer failure reaching dead letter +* duplicate dispatch skipped +* stale claim recovery + +--- + +# Acceptance Criteria + +* Activity source mutations persist outbox events atomically. +* Event persistence failure inside the transaction rolls back the Activity mutation. +* Worker dispatch occurs only after transaction commit. +* Activity APIs return success even when a non-owning consumer later fails. +* Outbox events are claimed safely in multi-instance execution. +* Consumer checkpoints are persistent and idempotent. +* Successful consumers are not rerun because another consumer failed. +* Retry scheduling and backoff operate according to policy. +* Terminal failures create sanitized dead-letter records. +* Manual retry and stale-claim recovery service contracts exist. +* Worker can shut down gracefully. +* Projection health reflects actual worker state. +* No Activity event is double-published through in-process and outbox publishers. +* Existing Follow-up, Dashboard, Report, Approval, Notification, and recent-activity behavior remains unchanged. +* End-to-end atomicity, dispatch, duplicate, retry, and recovery tests pass. +* TypeScript, migration checks, targeted lint, and worker/runtime tests pass. + +--- + +# Success Criteria + +ALLA OS has an active, production-ready Transactional Outbox delivery path for Activity Business Events. + +Source mutations and event persistence are atomic. + +Projection delivery is post-commit, independently retryable, idempotent, observable, and safe across process restarts and multiple worker instances. + +Future Timeline and Calendar projections can rely on durable incremental Activity events without coupling projection failures to source-domain mutation results. + +Ready for: + +# EP.1.5 – Timeline Projection Foundation diff --git a/src/db/schema.ts b/src/db/schema.ts index c8a8ebb..bd1f290 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -467,6 +467,9 @@ export const businessEventOutbox = pgTable( deliveryStatus: text('delivery_status').default('pending').notNull(), attemptCount: integer('attempt_count').default(0).notNull(), availableAt: timestamp('available_at', { withTimezone: true }).defaultNow().notNull(), + claimedBy: text('claimed_by'), + claimedAt: timestamp('claimed_at', { withTimezone: true }), + claimExpiresAt: timestamp('claim_expires_at', { withTimezone: true }), processingStartedAt: timestamp('processing_started_at', { withTimezone: true }), dispatchedAt: timestamp('dispatched_at', { withTimezone: true }), failedAt: timestamp('failed_at', { withTimezone: true }), @@ -482,6 +485,10 @@ export const businessEventOutbox = pgTable( table.deliveryStatus, table.availableAt ), + claimLeaseIdx: index('business_event_outbox_claim_lease_idx').on( + table.deliveryStatus, + table.claimExpiresAt + ), organizationEventIdx: index('business_event_outbox_org_event_idx').on( table.organizationId, table.eventType diff --git a/src/features/crm/activities/server/business-events.ts b/src/features/crm/activities/server/business-events.ts index 176bb62..81ca13e 100644 --- a/src/features/crm/activities/server/business-events.ts +++ b/src/features/crm/activities/server/business-events.ts @@ -1,9 +1,10 @@ import { - createBusinessEvent, - publishBusinessEvent, - type BusinessEvent +createBusinessEvent, +publishBusinessEvent, +type BusinessEvent } from '../../../foundation/business-events/index.ts'; import type { ActivityRecord } from '../api/types.ts'; +import type { ActivityRow } from './repository'; export type ActivityBusinessEventType = | 'activity.created' @@ -16,13 +17,90 @@ export type ActivityBusinessEventType = | 'activity.deleted'; interface PublishActivityBusinessEventInput { - eventType: ActivityBusinessEventType; - activity: ActivityRecord; +eventType: ActivityBusinessEventType; +activity: ActivityRecord; actorUserId: string; correlationId?: string; causationId?: string | null; payload?: Record; - previousActivity?: ActivityRecord | null; +previousActivity?: ActivityRecord | null; +} + +interface EnqueueActivityBusinessEventInput extends PublishActivityBusinessEventInput { +client: Parameters< +import('../../../foundation/projections/server/transactional-publisher.ts').TransactionalBusinessEventPublisher['enqueue'] +>[0]; +} + +function toIso(value: Date | null): string | null { +return value?.toISOString() ?? null; +} + +function toMetadata(value: unknown): Record | null { +return value && typeof value === 'object' && !Array.isArray(value) +? (value as Record) +: null; +} + +export function activityRowToBusinessEventRecord(row: ActivityRow): ActivityRecord { +const metadata = toMetadata(row.metadata); +const scheduledStartAt = toIso(row.scheduledStartAt); +const dueAt = toIso(row.dueAt); +const completedAt = toIso(row.completedAt); +const cancelledAt = toIso(row.cancelledAt); +const skippedAt = toIso(row.skippedAt); + +return { +id: row.id, +organizationId: row.organizationId, +primaryEntityType: row.primaryEntityType as ActivityRecord['primaryEntityType'], +primaryEntityId: row.primaryEntityId, +primaryRecordLabel: row.primaryRecordLabel, +relatedRecords: Array.isArray(row.relatedRecords) +? (row.relatedRecords as ActivityRecord['relatedRecords']) +: [], +customerId: row.customerId, +contactId: row.contactId, +leadId: row.leadId, +opportunityId: row.opportunityId, +quotationId: row.quotationId, +activityType: row.activityType as ActivityRecord['activityType'], +subject: row.subject, +description: row.description, +ownerId: row.ownerId, +assignedToId: row.assignedToId, +scheduledStartAt, +scheduledEndAt: toIso(row.scheduledEndAt), +dueAt, +completedAt, +cancelledAt, +skippedAt, +status: row.status as ActivityRecord['status'], +priority: row.priority as ActivityRecord['priority'], +outcomeSummary: row.outcomeSummary, +cancellationReason: row.cancellationReason, +skipReason: row.skipReason, +nextAction: row.nextAction, +branchId: row.branchId, +productType: row.productType, +isInternalOnly: row.isInternalOnly, +isPricingSensitive: row.isPricingSensitive, +parentActivityId: row.parentActivityId, +metadata, +createdBy: row.createdBy, +updatedBy: row.updatedBy, +createdAt: row.createdAt.toISOString(), +updatedAt: row.updatedAt.toISOString(), +deletedAt: toIso(row.deletedAt), +activityCode: typeof metadata?.activityCode === 'string' ? metadata.activityCode : null, +effectiveStatus: row.status as ActivityRecord['effectiveStatus'], +ownerName: null, +assignedToName: null, +createdByName: null, +updatedByName: null, +canViewPricingSensitiveContent: false, +isContentRedacted: row.isPricingSensitive || row.isInternalOnly +}; } function compactRelatedRecords(activity: ActivityRecord) { @@ -112,3 +190,15 @@ export async function publishActivityBusinessEvent( return event; } + +export async function enqueueActivityBusinessEvent( + input: EnqueueActivityBusinessEventInput +): Promise { +const { transactionalBusinessEventPublisher } = await import( +'../../../foundation/projections/server/transactional-publisher.ts' +); +const event = createActivityBusinessEvent(input); +await transactionalBusinessEventPublisher.enqueue(input.client, event); + + return event; +} diff --git a/src/features/crm/activities/server/repository.ts b/src/features/crm/activities/server/repository.ts index fcc6a2d..efac0de 100644 --- a/src/features/crm/activities/server/repository.ts +++ b/src/features/crm/activities/server/repository.ts @@ -5,6 +5,7 @@ import type { CrmActivityEntityType } from '@/features/crm/activity/types'; export type ActivityRow = typeof crmActivities.$inferSelect; export type InsertActivityRow = typeof crmActivities.$inferInsert; +type ActivityDbClient = Pick; export interface ActivityListQuery { organizationId: string; @@ -24,8 +25,11 @@ export interface ActivityContextQuery { limit?: number; } -export async function insertActivity(values: InsertActivityRow): Promise { - const [created] = await db.insert(crmActivities).values(values).returning(); +export async function insertActivity( + values: InsertActivityRow, + client: ActivityDbClient = db +): Promise { + const [created] = await client.insert(crmActivities).values(values).returning(); return created; } @@ -33,9 +37,10 @@ export async function insertActivity(values: InsertActivityRow): Promise + values: Partial, + client: ActivityDbClient = db ): Promise { - const [updated] = await db + const [updated] = await client .update(crmActivities) .set(values) .where(and(eq(crmActivities.id, id), eq(crmActivities.organizationId, organizationId))) diff --git a/src/features/crm/activities/server/service.ts b/src/features/crm/activities/server/service.ts index 6283443..cc91569 100644 --- a/src/features/crm/activities/server/service.ts +++ b/src/features/crm/activities/server/service.ts @@ -46,7 +46,10 @@ import type { } from '../api/types'; import { getActivityRow, insertActivity, listActivityRows, updateActivityRow } from './repository'; import { hydrateActivityRecords } from './read-model'; -import { publishActivityBusinessEvent } from './business-events'; +import { + activityRowToBusinessEventRecord, + enqueueActivityBusinessEvent +} from './business-events'; type ActivityAccessContext = CrmSecurityContext; @@ -608,63 +611,73 @@ export async function createActivity( } }; - 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, - createdBy: userId, - updatedBy: userId + const created = await db.transaction(async (tx) => { + const createdRow = 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, + createdBy: userId, + updatedBy: userId + }, + tx + ); + const eventActivity = activityRowToBusinessEventRecord(createdRow); + const correlationId = crypto.randomUUID(); + + await enqueueActivityBusinessEvent({ + client: tx, + eventType: 'activity.created', + activity: eventActivity, + actorUserId: userId, + correlationId + }); + + if (eventActivity.assignedToId && eventActivity.assignedToId !== eventActivity.ownerId) { + await enqueueActivityBusinessEvent({ + client: tx, + eventType: 'activity.assigned', + activity: eventActivity, + actorUserId: userId, + correlationId + }); + } + + return createdRow; }); const activity = await getActivityById(created.id, organizationId, context); - const correlationId = crypto.randomUUID(); - - await publishActivityBusinessEvent({ - eventType: 'activity.created', - activity, - actorUserId: userId, - correlationId - }); - - if (activity.assignedToId && activity.assignedToId !== activity.ownerId) { - await publishActivityBusinessEvent({ - eventType: 'activity.assigned', - activity, - actorUserId: userId, - correlationId - }); - } return activity; } @@ -688,8 +701,9 @@ export async function updateActivity( await assertActivityTypeValue(organizationId, values.activityType); const nextStatus = values.status; - await updateActivityRow(id, organizationId, { - primaryEntityType: values.primaryEntityType, +const activityEventRecord = await db.transaction(async (tx) => { +const updatedRow = await updateActivityRow(id, organizationId, { +primaryEntityType: values.primaryEntityType, primaryEntityId: values.primaryEntityId, primaryRecordLabel: values.primaryRecordLabel, relatedRecords: values.relatedRecords, @@ -727,66 +741,76 @@ export async function updateActivity( completedAt: nextStatus === 'completed' ? new Date() : null, cancelledAt: nextStatus === 'cancelled' ? new Date() : null, skippedAt: nextStatus === 'skipped' ? new Date() : null, - updatedAt: new Date(), - updatedBy: userId - }); +updatedAt: new Date(), +updatedBy: userId +}, tx); - const activity = await getActivityById(id, organizationId, context); - const correlationId = crypto.randomUUID(); +const nextActivity = activityRowToBusinessEventRecord(updatedRow); +const correlationId = crypto.randomUUID(); - if (current.assignedToId !== activity.assignedToId) { - await publishActivityBusinessEvent({ - eventType: current.assignedToId ? 'activity.reassigned' : 'activity.assigned', - activity, - previousActivity: current, - actorUserId: userId, - correlationId +if (current.assignedToId !== nextActivity.assignedToId) { +await enqueueActivityBusinessEvent({ +client: tx, +eventType: current.assignedToId ? 'activity.reassigned' : 'activity.assigned', +activity: nextActivity, +previousActivity: current, +actorUserId: userId, +correlationId }); } if ( - current.scheduledStartAt !== activity.scheduledStartAt || - current.scheduledEndAt !== activity.scheduledEndAt || - current.dueAt !== activity.dueAt - ) { - await publishActivityBusinessEvent({ - eventType: 'activity.rescheduled', - activity, - previousActivity: current, - actorUserId: userId, - correlationId +current.scheduledStartAt !== nextActivity.scheduledStartAt || +current.scheduledEndAt !== nextActivity.scheduledEndAt || +current.dueAt !== nextActivity.dueAt +) { +await enqueueActivityBusinessEvent({ +client: tx, +eventType: 'activity.rescheduled', +activity: nextActivity, +previousActivity: current, +actorUserId: userId, +correlationId }); } - if (current.status !== 'in_progress' && activity.status === 'in_progress') { - await publishActivityBusinessEvent({ - eventType: 'activity.started', - activity, - previousActivity: current, - actorUserId: userId, - correlationId +if (current.status !== 'in_progress' && nextActivity.status === 'in_progress') { +await enqueueActivityBusinessEvent({ +client: tx, +eventType: 'activity.started', +activity: nextActivity, +previousActivity: current, +actorUserId: userId, +correlationId }); } - if (current.status !== 'completed' && activity.status === 'completed') { - await publishActivityBusinessEvent({ - eventType: 'activity.completed', - activity, - previousActivity: current, - actorUserId: userId, - correlationId +if (current.status !== 'completed' && nextActivity.status === 'completed') { +await enqueueActivityBusinessEvent({ +client: tx, +eventType: 'activity.completed', +activity: nextActivity, +previousActivity: current, +actorUserId: userId, +correlationId }); } - if (current.status !== 'cancelled' && activity.status === 'cancelled') { - await publishActivityBusinessEvent({ - eventType: 'activity.cancelled', - activity, - previousActivity: current, - actorUserId: userId, - correlationId - }); - } +if (current.status !== 'cancelled' && nextActivity.status === 'cancelled') { +await enqueueActivityBusinessEvent({ +client: tx, +eventType: 'activity.cancelled', +activity: nextActivity, +previousActivity: current, +actorUserId: userId, +correlationId +}); +} + +return nextActivity; +}); + +const activity = await getActivityById(activityEventRecord.id, organizationId, context); return activity; } @@ -802,24 +826,28 @@ export async function completeActivity( 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 - }); +const completedEventRecord = await db.transaction(async (tx) => { +const updatedRow = await updateActivityRow(id, organizationId, { +status: 'completed', +outcomeSummary: payload.outcomeSummary.trim(), +nextAction: payload.nextAction?.trim() || null, +completedAt: new Date(), +updatedAt: new Date(), +updatedBy: userId +}, tx); +const nextActivity = activityRowToBusinessEventRecord(updatedRow); +await enqueueActivityBusinessEvent({ +client: tx, +eventType: 'activity.completed', +activity: nextActivity, +previousActivity: current, +actorUserId: userId +}); +return nextActivity; +}); - const activity = await getActivityById(id, organizationId, context); - await publishActivityBusinessEvent({ - eventType: 'activity.completed', - activity, - previousActivity: current, - actorUserId: userId - }); - - return activity; +const activity = await getActivityById(completedEventRecord.id, organizationId, context); +return activity; } export async function cancelActivity( @@ -833,23 +861,27 @@ export async function cancelActivity( 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 - }); +const cancelledEventRecord = await db.transaction(async (tx) => { +const updatedRow = await updateActivityRow(id, organizationId, { +status: 'cancelled', +cancellationReason: payload.cancellationReason.trim(), +cancelledAt: new Date(), +updatedAt: new Date(), +updatedBy: userId +}, tx); +const nextActivity = activityRowToBusinessEventRecord(updatedRow); +await enqueueActivityBusinessEvent({ +client: tx, +eventType: 'activity.cancelled', +activity: nextActivity, +previousActivity: current, +actorUserId: userId +}); +return nextActivity; +}); - const activity = await getActivityById(id, organizationId, context); - await publishActivityBusinessEvent({ - eventType: 'activity.cancelled', - activity, - previousActivity: current, - actorUserId: userId - }); - - return activity; +const activity = await getActivityById(cancelledEventRecord.id, organizationId, context); +return activity; } export async function assignActivity( @@ -866,23 +898,27 @@ export async function assignActivity( await assertMembershipUserBelongsToOrganization(payload.assignedToId, organizationId); } - await updateActivityRow(id, organizationId, { - assignedToId: payload.assignedToId, - updatedAt: new Date(), - updatedBy: userId - }); +const assignedEventRecord = await db.transaction(async (tx) => { +const updatedRow = await updateActivityRow(id, organizationId, { +assignedToId: payload.assignedToId, +updatedAt: new Date(), +updatedBy: userId +}, tx); +const nextActivity = activityRowToBusinessEventRecord(updatedRow); +if (current.assignedToId !== nextActivity.assignedToId) { +await enqueueActivityBusinessEvent({ +client: tx, +eventType: current.assignedToId ? 'activity.reassigned' : 'activity.assigned', +activity: nextActivity, +previousActivity: current, +actorUserId: userId +}); +} +return nextActivity; +}); - const activity = await getActivityById(id, organizationId, context); - if (current.assignedToId !== activity.assignedToId) { - await publishActivityBusinessEvent({ - eventType: current.assignedToId ? 'activity.reassigned' : 'activity.assigned', - activity, - previousActivity: current, - actorUserId: userId - }); - } - - return activity; +const activity = await getActivityById(assignedEventRecord.id, organizationId, context); +return activity; } export async function rescheduleActivity( @@ -902,9 +938,10 @@ export async function rescheduleActivity( const nextStatus = current.status === 'draft' ? 'scheduled' : current.status; assertTransitionAllowed(current.status, nextStatus); - await updateActivityRow(id, organizationId, { - scheduledStartAt: - payload.scheduledStartAt === undefined +const rescheduledEventRecord = await db.transaction(async (tx) => { +const updatedRow = await updateActivityRow(id, organizationId, { +scheduledStartAt: +payload.scheduledStartAt === undefined ? toDateOrNull(current.scheduledStartAt) : toDateOrNull(payload.scheduledStartAt), scheduledEndAt: @@ -912,20 +949,23 @@ export async function rescheduleActivity( ? toDateOrNull(current.scheduledEndAt) : toDateOrNull(payload.scheduledEndAt), dueAt: payload.dueAt === undefined ? toDateOrNull(current.dueAt) : toDateOrNull(payload.dueAt), - status: nextStatus, - updatedAt: new Date(), - updatedBy: userId - }); +status: nextStatus, +updatedAt: new Date(), +updatedBy: userId +}, tx); +const nextActivity = activityRowToBusinessEventRecord(updatedRow); +await enqueueActivityBusinessEvent({ +client: tx, +eventType: 'activity.rescheduled', +activity: nextActivity, +previousActivity: current, +actorUserId: userId +}); +return nextActivity; +}); - const activity = await getActivityById(id, organizationId, context); - await publishActivityBusinessEvent({ - eventType: 'activity.rescheduled', - activity, - previousActivity: current, - actorUserId: userId - }); - - return activity; +const activity = await getActivityById(rescheduledEventRecord.id, organizationId, context); +return activity; } export async function deleteActivity( @@ -937,15 +977,18 @@ export async function deleteActivity( assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.delete); const current = await getHydratedActivityOrThrow(id, organizationId, context); - await updateActivityRow(id, organizationId, { - deletedAt: new Date(), - updatedAt: new Date(), - updatedBy: userId - }); +await db.transaction(async (tx) => { +await updateActivityRow(id, organizationId, { +deletedAt: new Date(), +updatedAt: new Date(), +updatedBy: userId +}, tx); - await publishActivityBusinessEvent({ - eventType: 'activity.deleted', - activity: current, - actorUserId: userId - }); +await enqueueActivityBusinessEvent({ +client: tx, +eventType: 'activity.deleted', +activity: current, +actorUserId: userId +}); +}); } diff --git a/src/features/foundation/projections/index.ts b/src/features/foundation/projections/index.ts index 86d9e08..d8b46ad 100644 --- a/src/features/foundation/projections/index.ts +++ b/src/features/foundation/projections/index.ts @@ -1,9 +1,11 @@ export * from './consumers.ts'; export * from './matrices.ts'; export * from './memory-store.ts'; +export * from './operations.ts'; export * from './publisher.ts'; export * from './rebuild.ts'; export * from './registry.ts'; export * from './retry-policy.ts'; export * from './runtime.ts'; export * from './types.ts'; +export * from './worker.ts'; diff --git a/src/features/foundation/projections/memory-store.ts b/src/features/foundation/projections/memory-store.ts index 48b418a..b7809f6 100644 --- a/src/features/foundation/projections/memory-store.ts +++ b/src/features/foundation/projections/memory-store.ts @@ -2,6 +2,7 @@ import type { BusinessEvent } from '../business-events/types.ts'; import type { BeginProjectionCheckpointInput, BusinessEventDeliveryRecord, + ClaimBusinessEventDeliveriesInput, CompleteProjectionCheckpointInput, CreateProjectionDeadLetterInput, FailProjectionCheckpointInput, @@ -31,12 +32,47 @@ export class InMemoryProjectionRuntimeStore implements ProjectionRuntimeStore { status: 'pending', attemptCount: 0, availableAt: new Date().toISOString(), + claimedBy: null, + claimedAt: null, + claimExpiresAt: null, event }; this.deliveries.set(event.eventId, record); return record; } + async claimAvailableEvents( + input: ClaimBusinessEventDeliveriesInput + ): Promise { + const claimable = [...this.deliveries.values()] + .filter((delivery) => { + if (delivery.status === 'pending' || delivery.status === 'retry_scheduled') { + return new Date(delivery.availableAt).getTime() <= input.now.getTime(); + } + + if (delivery.status === 'claimed' || delivery.status === 'processing') { + return ( + delivery.claimExpiresAt !== null && + delivery.claimExpiresAt !== undefined && + new Date(delivery.claimExpiresAt).getTime() <= input.now.getTime() + ); + } + + return false; + }) + .toSorted((a, b) => a.availableAt.localeCompare(b.availableAt)) + .slice(0, input.batchSize); + + for (const delivery of claimable) { + delivery.status = 'claimed'; + delivery.claimedBy = input.workerId; + delivery.claimedAt = input.now.toISOString(); + delivery.claimExpiresAt = input.leaseExpiresAt.toISOString(); + } + + return claimable; + } + async markDeliveryProcessing(eventId: string): Promise { const delivery = this.requireDelivery(eventId); delivery.status = 'processing'; diff --git a/src/features/foundation/projections/operations.ts b/src/features/foundation/projections/operations.ts new file mode 100644 index 0000000..b1d669c --- /dev/null +++ b/src/features/foundation/projections/operations.ts @@ -0,0 +1,101 @@ +import { ProjectionConsumerRuntime } from './runtime.ts'; +import { ProjectionOutboxWorker, type ProjectionWorkerDrainResult } from './worker.ts'; +import type { BusinessEvent } from '../business-events/types.ts'; +import type { ProjectionConsumer, ProjectionRuntimeStore } from './types.ts'; + +export interface ProjectionManualActor { + userId: string; + organizationId: string; + systemRole?: string; +} + +export interface ProjectionManualRetryResult { + status: 'accepted' | 'completed'; + eventId?: string; + consumerName?: string; + message: string; +} + +export class ProjectionOperationsService { + private readonly store: ProjectionRuntimeStore; + private readonly consumers: ProjectionConsumer[]; + + constructor(options: { store: ProjectionRuntimeStore; consumers: ProjectionConsumer[] }) { + this.store = options.store; + this.consumers = options.consumers; + } + + async drainOutbox(worker: ProjectionOutboxWorker): Promise { + return worker.drainOnce(); + } + + async retryEvent(event: BusinessEvent, actor: ProjectionManualActor): Promise { + assertProjectionOperator(actor); + const runtime = new ProjectionConsumerRuntime({ + store: this.store, + consumers: this.consumers + }); + await runtime.processEvent(event); + + return { + status: 'completed', + eventId: event.eventId, + message: 'Retry completed for incomplete projection consumers' + }; + } + + async retryConsumer( + event: BusinessEvent, + consumerName: string, + actor: ProjectionManualActor + ): Promise { + assertProjectionOperator(actor); + const consumer = this.consumers.find((item) => item.consumerName === consumerName); + if (!consumer) { + throw new Error(`Unknown projection consumer: ${consumerName}`); + } + + const runtime = new ProjectionConsumerRuntime({ + store: this.store, + consumers: [consumer] + }); + await runtime.processEvent(event); + + return { + status: 'completed', + eventId: event.eventId, + consumerName, + message: 'Retry completed for selected projection consumer' + }; + } + + async retryDeadLetter(deadLetterId: string, actor: ProjectionManualActor): Promise { + assertProjectionOperator(actor); + return { + status: 'accepted', + message: `Dead-letter retry accepted for ${deadLetterId}` + }; + } + + async resolveDeadLetter( + deadLetterId: string, + reason: string, + actor: ProjectionManualActor + ): Promise { + assertProjectionOperator(actor); + if (!reason.trim()) { + throw new Error('Dead-letter resolution reason is required'); + } + + return { + status: 'accepted', + message: `Dead-letter ${deadLetterId} resolved` + }; + } +} + +function assertProjectionOperator(actor: ProjectionManualActor): void { + if (actor.systemRole !== 'super_admin') { + throw new Error('Projection operation requires system administration access'); + } +} diff --git a/src/features/foundation/projections/runtime.test.ts b/src/features/foundation/projections/runtime.test.ts index 2298cd7..0f3a815 100644 --- a/src/features/foundation/projections/runtime.test.ts +++ b/src/features/foundation/projections/runtime.test.ts @@ -8,6 +8,7 @@ import { ProjectionOutboxBusinessEventPublisher } from './publisher.ts'; import { ProjectionConsumerError } from './retry-policy.ts'; import { ProjectionConsumerRuntime } from './runtime.ts'; import type { ProjectionConsumer } from './types.ts'; +import { ProjectionOutboxWorker } from './worker.ts'; function activityEvent(overrides: Record = {}) { return createBusinessEvent({ @@ -135,7 +136,7 @@ test('consumer failure schedules retry without throwing or blocking unrelated co const runtime = new ProjectionConsumerRuntime({ store, consumers: [failing, healthy] }); const result = await runtime.processEvent(event); - assert.equal(result.status, 'failed'); + assert.equal(result.status, 'retry_scheduled'); assert.deepEqual(handled, ['healthy']); assert.equal( [...store.checkpoints.values()].some((checkpoint) => checkpoint.status === 'retry_scheduled'), @@ -188,3 +189,57 @@ test('non-retryable failure creates dead letter', async () => { assert.equal(store.deadLetters.length, 1); assert.equal(store.deadLetters[0]?.failure.code, 'projection_invariant_failed'); }); + +test('worker claims available events and drains through runtime', async () => { + const store = new InMemoryProjectionRuntimeStore(); + const event = activityEvent(); + await store.persistEvent(event); + + const worker = new ProjectionOutboxWorker({ + store, + consumers: INITIAL_PROJECTION_CONSUMERS, + config: { + enabled: false, + workerId: 'worker-a', + batchSize: 10, + claimLeaseMs: 60_000 + } + }); + + const result = await worker.drainOnce(); + + assert.equal(result.claimedCount, 1); + assert.equal(result.processedCount, 1); + assert.equal(store.deliveries.get(event.eventId)?.claimedBy, 'worker-a'); + assert.equal(store.deliveries.get(event.eventId)?.status, 'completed'); +}); + +test('worker claim honors active lease and recovers stale lease', async () => { + const store = new InMemoryProjectionRuntimeStore(); + const event = activityEvent(); + await store.persistEvent(event); + + const firstClaim = await store.claimAvailableEvents({ + workerId: 'worker-a', + batchSize: 1, + leaseExpiresAt: new Date('2099-07-13T00:01:00.000Z'), + now: new Date('2099-07-13T00:00:00.000Z') + }); + const blockedClaim = await store.claimAvailableEvents({ + workerId: 'worker-b', + batchSize: 1, + leaseExpiresAt: new Date('2099-07-13T00:01:30.000Z'), + now: new Date('2099-07-13T00:00:30.000Z') + }); + const recoveredClaim = await store.claimAvailableEvents({ + workerId: 'worker-b', + batchSize: 1, + leaseExpiresAt: new Date('2099-07-13T00:03:00.000Z'), + now: new Date('2099-07-13T00:02:00.000Z') + }); + + assert.equal(firstClaim.length, 1); + assert.equal(blockedClaim.length, 0); + assert.equal(recoveredClaim.length, 1); + assert.equal(recoveredClaim[0]?.claimedBy, 'worker-b'); +}); diff --git a/src/features/foundation/projections/runtime.ts b/src/features/foundation/projections/runtime.ts index 7155d27..30c233d 100644 --- a/src/features/foundation/projections/runtime.ts +++ b/src/features/foundation/projections/runtime.ts @@ -88,7 +88,12 @@ export class ProjectionConsumerRuntime { }, this.now() ); - return { eventId: event.eventId, eventType: event.eventType, status: 'failed', consumerResults }; + return { + eventId: event.eventId, + eventType: event.eventType, + status: 'retry_scheduled', + consumerResults + }; } await this.store.markDeliveryCompleted(event.eventId, this.now()); diff --git a/src/features/foundation/projections/server/store.ts b/src/features/foundation/projections/server/store.ts index d0428eb..d392157 100644 --- a/src/features/foundation/projections/server/store.ts +++ b/src/features/foundation/projections/server/store.ts @@ -1,4 +1,4 @@ -import { and, eq } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import { businessEventOutbox, projectionConsumerCheckpoints, @@ -10,6 +10,7 @@ import type { BusinessEvent } from '../../business-events/types.ts'; import type { BeginProjectionCheckpointInput, BusinessEventDeliveryRecord, + ClaimBusinessEventDeliveriesInput, CompleteProjectionCheckpointInput, CreateProjectionDeadLetterInput, FailProjectionCheckpointInput, @@ -47,6 +48,43 @@ export class DrizzleProjectionRuntimeStore implements ProjectionRuntimeStore { return mapDelivery(created); } + async claimAvailableEvents( + input: ClaimBusinessEventDeliveriesInput + ): Promise { + const result = await db.execute(sql` + WITH claimable AS ( + SELECT id + FROM business_event_outbox + WHERE + ( + delivery_status IN ('pending', 'retry_scheduled') + AND available_at <= ${input.now} + ) + OR ( + delivery_status IN ('claimed', 'processing') + AND claim_expires_at IS NOT NULL + AND claim_expires_at <= ${input.now} + ) + ORDER BY available_at ASC, created_at ASC, id ASC + LIMIT ${input.batchSize} + FOR UPDATE SKIP LOCKED + ) + UPDATE business_event_outbox + SET + delivery_status = 'claimed', + claimed_by = ${input.workerId}, + claimed_at = ${input.now}, + claim_expires_at = ${input.leaseExpiresAt}, + updated_at = ${input.now} + FROM claimable + WHERE business_event_outbox.id = claimable.id + RETURNING business_event_outbox.* + `); + + const rows = Array.isArray(result) ? result : result.rows; + return rows.map((row) => mapDelivery(row as typeof businessEventOutbox.$inferSelect)); + } + async markDeliveryProcessing(eventId: string, now: Date): Promise { const existing = await this.getDelivery(eventId); await db @@ -65,7 +103,7 @@ export class DrizzleProjectionRuntimeStore implements ProjectionRuntimeStore { } async markDeliveryFailed(eventId: string, error: ProjectionFailure, now: Date): Promise { - await updateDeliveryStatus(eventId, 'failed', now, { + await updateDeliveryStatus(eventId, 'retry_scheduled', now, { failedAt: now, lastErrorCode: error.code, lastErrorMessage: error.message @@ -253,7 +291,7 @@ export class DrizzleProjectionRuntimeStore implements ProjectionRuntimeStore { async function updateDeliveryStatus( eventId: string, - deliveryStatus: 'completed' | 'failed' | 'dead_letter', + deliveryStatus: 'completed' | 'retry_scheduled' | 'dead_letter', now: Date, values: Partial ): Promise { @@ -272,6 +310,9 @@ function mapDelivery(row: typeof businessEventOutbox.$inferSelect): BusinessEven status: row.deliveryStatus as BusinessEventDeliveryRecord['status'], attemptCount: row.attemptCount, availableAt: row.availableAt.toISOString(), + claimedBy: row.claimedBy, + claimedAt: row.claimedAt?.toISOString() ?? null, + claimExpiresAt: row.claimExpiresAt?.toISOString() ?? null, event: row.eventEnvelope as BusinessEvent }; } diff --git a/src/features/foundation/projections/server/transactional-publisher.ts b/src/features/foundation/projections/server/transactional-publisher.ts new file mode 100644 index 0000000..c081f9b --- /dev/null +++ b/src/features/foundation/projections/server/transactional-publisher.ts @@ -0,0 +1,46 @@ +import { businessEventOutbox } from '../../../../db/schema.ts'; +import { db } from '../../../../lib/db.ts'; +import { + assertBusinessEventPayload, + assertRegisteredBusinessEvent +} from '../../business-events/registry.ts'; +import type { BusinessEvent } from '../../business-events/types.ts'; + +type OutboxDatabaseClient = Pick; + +export interface TransactionalBusinessEventPublisher { + enqueue(client: OutboxDatabaseClient, event: BusinessEvent): Promise; + enqueueMany(client: OutboxDatabaseClient, events: BusinessEvent[]): Promise; +} + +export class DrizzleTransactionalBusinessEventPublisher + implements TransactionalBusinessEventPublisher +{ + async enqueue(client: OutboxDatabaseClient, event: BusinessEvent): Promise { + assertRegisteredBusinessEvent(event.eventType); + assertBusinessEventPayload(event.eventType, event.payload); + + await client.insert(businessEventOutbox).values({ + id: event.eventId, + organizationId: event.organizationId, + eventType: event.eventType, + schemaVersion: event.schemaVersion, + entityType: event.entityType, + entityId: event.entityId, + correlationId: event.correlationId, + causationId: event.causationId ?? null, + eventEnvelope: event, + deliveryStatus: 'pending', + occurredAt: new Date(event.occurredAt) + }); + } + + async enqueueMany(client: OutboxDatabaseClient, events: BusinessEvent[]): Promise { + for (const event of events) { + await this.enqueue(client, event); + } + } +} + +export const transactionalBusinessEventPublisher = + new DrizzleTransactionalBusinessEventPublisher(); diff --git a/src/features/foundation/projections/types.ts b/src/features/foundation/projections/types.ts index 5d4ff4e..2525595 100644 --- a/src/features/foundation/projections/types.ts +++ b/src/features/foundation/projections/types.ts @@ -2,8 +2,10 @@ import type { BusinessEvent } from '../business-events/types.ts'; export type BusinessEventDeliveryStatus = | 'pending' + | 'claimed' | 'processing' | 'completed' + | 'retry_scheduled' | 'failed' | 'dead_letter'; @@ -66,6 +68,9 @@ export interface BusinessEventDeliveryRecord { status: BusinessEventDeliveryStatus; attemptCount: number; availableAt: string; + claimedBy?: string | null; + claimedAt?: string | null; + claimExpiresAt?: string | null; event: BusinessEvent; } @@ -86,6 +91,7 @@ export interface ProjectionCheckpointRecord { export interface ProjectionRuntimeStore { persistEvent(event: BusinessEvent): Promise; + claimAvailableEvents(input: ClaimBusinessEventDeliveriesInput): Promise; markDeliveryProcessing(eventId: string, now: Date): Promise; markDeliveryCompleted(eventId: string, now: Date): Promise; markDeliveryFailed(eventId: string, error: ProjectionFailure, now: Date): Promise; @@ -99,6 +105,13 @@ export interface ProjectionRuntimeStore { recordHealthSnapshot(input: ProjectionHealthSnapshotInput): Promise; } +export interface ClaimBusinessEventDeliveriesInput { + workerId: string; + batchSize: number; + leaseExpiresAt: Date; + now: Date; +} + export interface BeginProjectionCheckpointInput { organizationId: string; consumerName: string; diff --git a/src/features/foundation/projections/worker.ts b/src/features/foundation/projections/worker.ts new file mode 100644 index 0000000..b3400e2 --- /dev/null +++ b/src/features/foundation/projections/worker.ts @@ -0,0 +1,128 @@ +import { INITIAL_PROJECTION_CONSUMERS } from './consumers.ts'; +import { ProjectionConsumerRuntime } from './runtime.ts'; +import type { + BusinessEventDeliveryRecord, + ProjectionConsumer, + ProjectionRuntimeResult, + ProjectionRuntimeStore +} from './types.ts'; + +export interface ProjectionWorkerConfig { + enabled: boolean; + pollingIntervalMs: number; + batchSize: number; + maxConcurrentEvents: number; + maxConcurrentConsumers: number; + claimLeaseMs: number; + staleClaimMs: number; + workerId: string; + gracefulShutdownMs: number; +} + +export interface ProjectionWorkerDrainResult { + workerId: string; + claimedCount: number; + processedCount: number; + results: ProjectionRuntimeResult[]; +} + +export const DEFAULT_PROJECTION_WORKER_CONFIG: ProjectionWorkerConfig = { + enabled: process.env.PROJECTION_WORKER_ENABLED === 'true', + pollingIntervalMs: Number(process.env.PROJECTION_WORKER_POLLING_INTERVAL_MS ?? 3000), + batchSize: Number(process.env.PROJECTION_WORKER_BATCH_SIZE ?? 25), + maxConcurrentEvents: Number(process.env.PROJECTION_WORKER_MAX_CONCURRENT_EVENTS ?? 1), + maxConcurrentConsumers: Number(process.env.PROJECTION_WORKER_MAX_CONCURRENT_CONSUMERS ?? 4), + claimLeaseMs: Number(process.env.PROJECTION_WORKER_CLAIM_LEASE_MS ?? 60_000), + staleClaimMs: Number(process.env.PROJECTION_WORKER_STALE_CLAIM_MS ?? 90_000), + workerId: + process.env.PROJECTION_WORKER_ID ?? + `projection-worker-${process.pid}-${crypto.randomUUID().slice(0, 8)}`, + gracefulShutdownMs: Number(process.env.PROJECTION_WORKER_GRACEFUL_SHUTDOWN_MS ?? 30_000) +}; + +interface ProjectionOutboxWorkerOptions { + store: ProjectionRuntimeStore; + consumers?: ProjectionConsumer[]; + config?: Partial; + now?: () => Date; +} + +export class ProjectionOutboxWorker { + private readonly store: ProjectionRuntimeStore; + private readonly runtime: ProjectionConsumerRuntime; + private readonly config: ProjectionWorkerConfig; + private readonly now: () => Date; + private stopping = false; + private timer: NodeJS.Timeout | null = null; + + constructor(options: ProjectionOutboxWorkerOptions) { + this.store = options.store; + this.config = { ...DEFAULT_PROJECTION_WORKER_CONFIG, ...options.config }; + this.now = options.now ?? (() => new Date()); + this.runtime = new ProjectionConsumerRuntime({ + store: this.store, + consumers: options.consumers ?? INITIAL_PROJECTION_CONSUMERS, + now: this.now + }); + } + + async drainOnce(): Promise { + const now = this.now(); + const claimed = await this.store.claimAvailableEvents({ + workerId: this.config.workerId, + batchSize: this.config.batchSize, + leaseExpiresAt: new Date(now.getTime() + this.config.claimLeaseMs), + now + }); + + const results: ProjectionRuntimeResult[] = []; + + for (const delivery of claimed) { + if (this.stopping) { + break; + } + + results.push(await this.processDelivery(delivery)); + } + + return { + workerId: this.config.workerId, + claimedCount: claimed.length, + processedCount: results.length, + results + }; + } + + start(): void { + if (!this.config.enabled || this.timer) { + return; + } + + const tick = async () => { + if (this.stopping) { + return; + } + + await this.drainOnce(); + this.timer = setTimeout(tick, this.config.pollingIntervalMs); + }; + + this.timer = setTimeout(tick, 0); + } + + async stop(): Promise { + this.stopping = true; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + private async processDelivery( + delivery: BusinessEventDeliveryRecord + ): Promise { + return this.runtime.processEvent(delivery.event); + } +}