diff --git a/docs/implementation/task-p.7.2-implementation.md b/docs/implementation/task-p.7.2-implementation.md new file mode 100644 index 0000000..20c89e2 --- /dev/null +++ b/docs/implementation/task-p.7.2-implementation.md @@ -0,0 +1,167 @@ +# Task P.7.2 Implementation Report + +## Scope + +This round implements the first working slice of the SMTP / Email Configuration Platform for ALLA OS. + +Delivered scope: + +- organization-scoped email configuration persistence +- encrypted SMTP password storage +- CRUD APIs for email configurations +- SMTP connection test +- send test email action +- setup readiness / verification integration +- setup wizard optional email status card +- dashboard-native email administration page +- notification runtime integration so email sending uses the stored organization configuration + +## Review Summary + +Reviewed before implementation: + +- [AGENTS.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/AGENTS.md) +- [docs/standards/project-foundations.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/project-foundations.md) +- [docs/standards/architecture-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/architecture-rules.md) +- [docs/standards/ui-ux-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/ui-ux-rules.md) +- [docs/standards/task-review-checklist.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/task-review-checklist.md) +- [docs/setup/verification-matrix.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/setup/verification-matrix.md) +- [docs/setup/setup-wizard-blueprint.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/setup/setup-wizard-blueprint.md) +- [docs/setup/installation-profiles.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/setup/installation-profiles.md) +- [docs/implementation/task-d72-setup-readiness-verification-apis.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-d72-setup-readiness-verification-apis.md) +- [docs/implementation/task-d76-dashboard-setup-wizard-ui.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-d76-dashboard-setup-wizard-ui.md) +- [docs/implementation/task-f4-notification-framework-foundation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-f4-notification-framework-foundation.md) +- [docs/implementation/task-p.7.1-implementation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.7.1-implementation.md) + +## Architecture Notes + +The implementation intentionally extends the existing notification foundation instead of creating a second outbound-email stack. + +Key decisions: + +- persisted email configuration is organization-scoped in a new `email_configurations` table +- secret encryption is isolated in a reusable helper at [src/features/foundation/secrets/server/crypto.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/secrets/server/crypto.ts) +- the notification runtime keeps using the existing provider factory, but the email provider now resolves organization SMTP config from the new foundation +- setup readiness and setup verification both read from the same persisted source of truth instead of environment-only `SMTP_*` variables +- the setup wizard keeps email as an optional operator step, matching the task requirement that email must not block first-run setup by default + +## Main Changes + +### 1. Data Model And Secret Handling + +Added: + +- [src/db/schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts) +- [drizzle/0001_yellow_wasp.sql](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/drizzle/0001_yellow_wasp.sql) +- [src/features/foundation/secrets/server/crypto.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/secrets/server/crypto.ts) + +Behavior: + +- SMTP passwords are stored only as encrypted ciphertext +- API responses never return decrypted or raw password values +- UI only receives `hasPassword` +- missing or weak `EMAIL_CONFIG_ENCRYPTION_KEY` blocks encryption-dependent save paths + +### 2. Email Foundation Services + +Added: + +- [src/features/foundation/email/server/email-provider.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/server/email-provider.ts) +- [src/features/foundation/email/server/smtp-provider.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/server/smtp-provider.ts) +- [src/features/foundation/email/server/email-config.service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/server/email-config.service.ts) +- [src/features/foundation/email/server/email-test.service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/server/email-test.service.ts) + +Behavior: + +- create, update, list, fetch, and delete email configs +- track default / active config +- test connection and test send update `last_test_status`, `last_tested_at`, and safe `last_test_error` +- audit events are written for create, update, delete, and test outcomes + +### 3. Notification Runtime Integration + +Updated: + +- [src/features/foundation/notifications/server/email-provider.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/email-provider.ts) +- [src/features/foundation/notifications/server/provider-factory.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/provider-factory.ts) +- [src/features/foundation/notifications/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/service.ts) + +Behavior: + +- email dispatches now resolve SMTP settings from the active organization configuration +- the provider factory now receives `organizationId` +- existing notification send / resend flows continue using the established foundation contract + +### 4. Setup Integration + +Updated: + +- [src/features/setup/server/readiness.service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/setup/server/readiness.service.ts) +- [src/features/setup/server/verification.service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/setup/server/verification.service.ts) +- [src/features/setup/components/setup-wizard.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/setup/components/setup-wizard.tsx) +- [src/features/foundation/email/components/setup-email-card.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/components/setup-email-card.tsx) + +Behavior: + +- readiness and verification now check persisted organization email config +- no configuration returns `WARNING`, not `FAIL`, so setup remains non-blocking by default +- a lightweight email status card is shown inside the setup wizard and links to the email admin page + +### 5. Admin APIs And UI + +Added APIs: + +- [src/app/api/notifications/email-configurations/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/email-configurations/route.ts) +- [src/app/api/notifications/email-configurations/[id]/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/email-configurations/[id]/route.ts) +- [src/app/api/notifications/email-configurations/[id]/test/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/email-configurations/[id]/test/route.ts) +- [src/app/api/notifications/email-configurations/[id]/send-test/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/email-configurations/[id]/send-test/route.ts) + +Added client layer: + +- [src/features/foundation/email/api/types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/api/types.ts) +- [src/features/foundation/email/api/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/api/service.ts) +- [src/features/foundation/email/api/queries.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/api/queries.ts) +- [src/features/foundation/email/api/mutations.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/api/mutations.ts) + +Added page: + +- [src/app/dashboard/administration/email/page.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/administration/email/page.tsx) +- [src/features/foundation/email/components/email-configuration-page.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/components/email-configuration-page.tsx) +- [src/config/nav-config.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/config/nav-config.ts) + +UI notes: + +- layout follows dashboard-native card rhythm rather than a separate visual system +- top summary area keeps status scannable +- quick verification panel gives one stable place for test-recipient entry +- config cards separate transport, sender identity, secret state, and test state clearly +- operator notes are intentionally calm and low-noise per task request + +## Validation + +Executed: + +- `npm run typecheck -- --pretty false` +- `npm run db:generate` +- `npm run test:setup` +- `npx oxlint src/features/foundation/email src/features/foundation/secrets src/features/foundation/notifications/server/email-provider.ts src/features/foundation/notifications/server/provider-factory.ts src/features/setup/server/readiness.service.ts src/features/setup/server/verification.service.ts src/features/setup/components/setup-wizard.tsx src/app/api/notifications/email-configurations src/app/dashboard/administration/email/page.tsx src/config/nav-config.ts` + +Results: + +- TypeScript passed +- Drizzle migration generated successfully +- setup test suite passed +- targeted lint passed + +## Known Gaps After This Round + +- no dedicated profile-level rule yet to force email as a blocking setup requirement for selected installation profiles +- no password reset or invitation flow integration yet; this round only builds the platform and runtime foundation +- no seeded email templates or notification-template management UI specific to outbound email yet +- no per-configuration preview panel for rendered notification payloads yet +- no explicit soft-delete or archived state; delete currently removes the configuration row + +## Notes + +- This implementation assumes `EMAIL_CONFIG_ENCRYPTION_KEY` will be added to runtime environments before operators save SMTP passwords. +- Existing unrelated dirty files in the worktree were left untouched. diff --git a/drizzle/0001_yellow_wasp.sql b/drizzle/0001_yellow_wasp.sql new file mode 100644 index 0000000..ac6d4d0 --- /dev/null +++ b/drizzle/0001_yellow_wasp.sql @@ -0,0 +1,25 @@ +CREATE TABLE "email_configurations" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "provider_type" text DEFAULT 'smtp' NOT NULL, + "name" text NOT NULL, + "host" text NOT NULL, + "port" integer DEFAULT 587 NOT NULL, + "secure" boolean DEFAULT false NOT NULL, + "username" text, + "password_encrypted" text, + "from_email" text NOT NULL, + "from_name" text, + "reply_to_email" text, + "is_default" boolean DEFAULT false NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "last_test_status" text DEFAULT 'not_tested' NOT NULL, + "last_tested_at" timestamp with time zone, + "last_test_error" text, + "created_by" text NOT NULL, + "updated_by" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "email_configurations_org_name_idx" ON "email_configurations" USING btree ("organization_id","name"); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..2cc3f6a --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,6410 @@ +{ + "id": "3dc70e0a-e0ce-433d-9d4b-cb2ae6455ce7", + "prevId": "6c217ceb-57e5-410e-9132-610c819272f2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_notification_deliveries": { + "name": "app_notification_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_id": { + "name": "notification_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_address": { + "name": "recipient_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_notification_dispatches": { + "name": "app_notification_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_key": { + "name": "provider_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "recipient_to": { + "name": "recipient_to", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "recipient_cc": { + "name": "recipient_cc", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "recipient_bcc": { + "name": "recipient_bcc", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_address": { + "name": "recipient_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_retry_count": { + "name": "max_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_notification_dispatches_org_event_channel_recipient_idx": { + "name": "app_notification_dispatches_org_event_channel_recipient_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_notification_events": { + "name": "app_notification_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_notification_events_org_dedupe_idx": { + "name": "app_notification_events_org_dedupe_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_notification_templates": { + "name": "app_notification_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_template": { + "name": "subject_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title_template": { + "name": "title_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_template": { + "name": "body_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_format": { + "name": "body_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'plain'" + }, + "link_template": { + "name": "link_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'th'" + }, + "variables": { + "name": "variables", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "app_notification_templates_org_event_channel_idx": { + "name": "app_notification_templates_org_event_channel_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_notifications": { + "name": "app_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link_url": { + "name": "link_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unread'" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_notifications_recipient_event_idx": { + "name": "app_notifications_recipient_event_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_actions": { + "name": "crm_approval_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approval_request_id": { + "name": "approval_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acted_by": { + "name": "acted_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acted_at": { + "name": "acted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_escalation_policies": { + "name": "crm_approval_escalation_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "trigger_after_hours": { + "name": "trigger_after_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_value": { + "name": "target_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_matrices": { + "name": "crm_approval_matrices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "min_amount": { + "name": "min_amount", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_amount": { + "name": "max_amount", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_reminder_policies": { + "name": "crm_approval_reminder_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sla_hours": { + "name": "sla_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "reminder_offsets_hours": { + "name": "reminder_offsets_hours", + "type": "integer[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "calendar_mode": { + "name": "calendar_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'calendar_days'" + }, + "timeout_action": { + "name": "timeout_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_approval_reminder_policies_workflow_step_idx": { + "name": "crm_approval_reminder_policies_workflow_step_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_requests": { + "name": "crm_approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "current_step_started_at": { + "name": "current_step_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_steps": { + "name": "crm_approval_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role_code": { + "name": "role_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approval_mode": { + "name": "approval_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sequential'" + }, + "is_required": { + "name": "is_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sla_hours": { + "name": "sla_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "calendar_mode": { + "name": "calendar_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'calendar_days'" + }, + "timeout_action": { + "name": "timeout_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_steps_workflow_step_idx": { + "name": "crm_approval_steps_workflow_step_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_workflows": { + "name": "crm_approval_workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_workflows_org_code_idx": { + "name": "crm_approval_workflows_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_contact_shares": { + "name": "crm_contact_shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_to_user_id": { + "name": "shared_to_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_by_user_id": { + "name": "shared_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_contact_shares_org_contact_shared_user_idx": { + "name": "crm_contact_shares_org_contact_shared_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shared_to_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customer_contacts": { + "name": "crm_customer_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "department": { + "name": "department", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mobile": { + "name": "mobile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customer_owner_history": { + "name": "crm_customer_owner_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_owner_user_id": { + "name": "old_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_owner_user_id": { + "name": "new_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changed_by": { + "name": "changed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changed_at": { + "name": "changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customers": { + "name": "crm_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "abbr": { + "name": "abbr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_id": { + "name": "tax_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_status": { + "name": "customer_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "province": { + "name": "province", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sub_district": { + "name": "sub_district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fax": { + "name": "fax", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_group": { + "name": "customer_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_sub_group": { + "name": "customer_sub_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_assigned_at": { + "name": "owner_assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_assigned_by": { + "name": "owner_assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_customers_org_code_idx": { + "name": "crm_customers_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_artifacts": { + "name": "crm_document_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artifact_type": { + "name": "artifact_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_version_id": { + "name": "template_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "generated_by": { + "name": "generated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voided_at": { + "name": "voided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "voided_by": { + "name": "voided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "void_reason": { + "name": "void_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_libraries": { + "name": "crm_document_libraries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_document_libraries_org_code_idx": { + "name": "crm_document_libraries_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_library_versions": { + "name": "crm_document_library_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_by": { + "name": "archived_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_document_library_versions_library_version_idx": { + "name": "crm_document_library_versions_library_version_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_mappings": { + "name": "crm_document_template_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_version_id": { + "name": "template_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "placeholder_key": { + "name": "placeholder_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_type": { + "name": "data_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sheet_name": { + "name": "sheet_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_value": { + "name": "default_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format_mask": { + "name": "format_mask", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_document_template_mappings_version_placeholder_idx": { + "name": "crm_document_template_mappings_version_placeholder_idx", + "columns": [ + { + "expression": "template_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "placeholder_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_table_columns": { + "name": "crm_document_template_table_columns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mapping_id": { + "name": "mapping_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "column_name": { + "name": "column_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_field": { + "name": "source_field", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "column_letter": { + "name": "column_letter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "format_mask": { + "name": "format_mask", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_document_template_table_columns_mapping_name_idx": { + "name": "crm_document_template_table_columns_mapping_name_idx", + "columns": [ + { + "expression": "mapping_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "column_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_versions": { + "name": "crm_document_template_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_json": { + "name": "schema_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "preview_image_url": { + "name": "preview_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_document_template_versions_template_version_idx": { + "name": "crm_document_template_versions_template_version_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_templates": { + "name": "crm_document_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_name": { + "name": "template_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_document_templates_org_doc_product_file_name_idx": { + "name": "crm_document_templates_org_doc_product_file_name_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_leads": { + "name": "crm_leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_value": { + "name": "estimated_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "awareness_id": { + "name": "awareness_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_status": { + "name": "followup_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_reason": { + "name": "lost_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "owner_marketing_user_id": { + "name": "owner_marketing_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_sales_owner_id": { + "name": "assigned_sales_owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignment_remark": { + "name": "assignment_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_leads_org_code_idx": { + "name": "crm_leads_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunities": { + "name": "crm_opportunities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requirement": { + "name": "requirement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome_status": { + "name": "outcome_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_value": { + "name": "estimated_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "chance_percent": { + "name": "chance_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expected_close_date": { + "name": "expected_close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "project_close_date": { + "name": "project_close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_date": { + "name": "delivery_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "competitor": { + "name": "competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_hot_project": { + "name": "is_hot_project", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hot_project_auto_suggested": { + "name": "hot_project_auto_suggested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hot_project_manually_overridden": { + "name": "hot_project_manually_overridden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "pipeline_stage": { + "name": "pipeline_stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'lead'" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_won_at": { + "name": "closed_won_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_lost_at": { + "name": "closed_lost_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_by_user_id": { + "name": "closed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "po_number": { + "name": "po_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "po_date": { + "name": "po_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "po_amount": { + "name": "po_amount", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "po_currency": { + "name": "po_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_reason": { + "name": "lost_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_detail": { + "name": "lost_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_competitor": { + "name": "lost_competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_remark": { + "name": "lost_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "no_quotation_reason": { + "name": "no_quotation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_to_user_id": { + "name": "assigned_to_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignment_remark": { + "name": "assignment_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_opportunities_org_code_idx": { + "name": "crm_opportunities_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunity_attachments": { + "name": "crm_opportunity_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunity_customers": { + "name": "crm_opportunity_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunity_followups": { + "name": "crm_opportunity_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_attachments": { + "name": "crm_quotation_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_customers": { + "name": "crm_quotation_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_followups": { + "name": "crm_quotation_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_items": { + "name": "crm_quotation_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_number": { + "name": "item_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_price": { + "name": "unit_price", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount": { + "name": "discount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_rate": { + "name": "tax_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_price": { + "name": "total_price", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_topic_items": { + "name": "crm_quotation_topic_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_topics": { + "name": "crm_quotation_topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_type": { + "name": "topic_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotations": { + "name": "crm_quotations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quotation_date": { + "name": "quotation_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quotation_type": { + "name": "quotation_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attention": { + "name": "attention", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_quotation_id": { + "name": "parent_quotation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision_remark": { + "name": "revision_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exchange_rate": { + "name": "exchange_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "subtotal": { + "name": "subtotal", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount": { + "name": "discount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_rate": { + "name": "tax_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tax_amount": { + "name": "tax_amount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_amount": { + "name": "total_amount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "chance_percent": { + "name": "chance_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "project_close_date": { + "name": "project_close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_date": { + "name": "delivery_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_hot_project": { + "name": "is_hot_project", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hot_project_auto_suggested": { + "name": "hot_project_auto_suggested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hot_project_manually_overridden": { + "name": "hot_project_manually_overridden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "competitor": { + "name": "competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "salesman_id": { + "name": "salesman_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_sent": { + "name": "is_sent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_via": { + "name": "sent_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_artifact_id": { + "name": "approved_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_pdf_url": { + "name": "approved_pdf_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_snapshot": { + "name": "approved_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "approved_template_version_id": { + "name": "approved_template_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_quotations_org_code_idx": { + "name": "crm_quotations_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_report_definitions": { + "name": "crm_report_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "crm_report_definitions_org_code_idx": { + "name": "crm_report_definitions_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_role_profiles": { + "name": "crm_role_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "branch_scope_mode": { + "name": "branch_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'assigned'" + }, + "product_scope_mode": { + "name": "product_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'assigned'" + }, + "ownership_scope": { + "name": "ownership_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'own'" + }, + "approval_authority": { + "name": "approval_authority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_role_profiles_org_code_idx": { + "name": "crm_role_profiles_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_user_role_assignments": { + "name": "crm_user_role_assignments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_profile_id": { + "name": "role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_scope_mode": { + "name": "branch_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + }, + "branch_scope_ids": { + "name": "branch_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "product_type_scope_mode": { + "name": "product_type_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + }, + "product_type_scope_ids": { + "name": "product_type_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_user_role_assignments_org_user_role_idx": { + "name": "crm_user_role_assignments_org_user_role_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_sequences": { + "name": "document_sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_number": { + "name": "current_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "padding_length": { + "name": "padding_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{prefix}{period}-{running}'" + }, + "reset_policy": { + "name": "reset_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'period'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_sequences_org_branch_product_doc_period_idx": { + "name": "document_sequences_org_branch_product_doc_period_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_configurations": { + "name": "email_configurations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'smtp'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 587 + }, + "secure": { + "name": "secure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_encrypted": { + "name": "password_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to_email": { + "name": "reply_to_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_status": { + "name": "last_test_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_tested'" + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_error": { + "name": "last_test_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_configurations_org_name_idx": { + "name": "email_configurations_org_name_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "business_role": { + "name": "business_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sales_support'" + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "branch_scope_ids": { + "name": "branch_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "product_type_scope_ids": { + "name": "product_type_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ms_options": { + "name": "ms_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ms_options_org_category_code_idx": { + "name": "ms_options_org_category_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "products_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seed_manifest_items": { + "name": "seed_manifest_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seed_manifest_id": { + "name": "seed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_key": { + "name": "item_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_file": { + "name": "source_file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "imported_count": { + "name": "imported_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_count": { + "name": "updated_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_count": { + "name": "error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "warning_count": { + "name": "warning_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "report_json": { + "name": "report_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "seed_manifest_items_manifest_item_idx": { + "name": "seed_manifest_items_manifest_item_idx", + "columns": [ + { + "expression": "seed_manifest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seed_manifests": { + "name": "seed_manifests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "setup_run_id": { + "name": "setup_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_by": { + "name": "applied_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "report_json": { + "name": "report_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.setup_run_steps": { + "name": "setup_run_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "setup_run_id": { + "name": "setup_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_key": { + "name": "step_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output_json": { + "name": "output_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "errors_json": { + "name": "errors_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "warnings_json": { + "name": "warnings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "setup_run_steps_run_step_idx": { + "name": "setup_run_steps_run_step_idx", + "columns": [ + { + "expression": "setup_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.setup_runs": { + "name": "setup_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'production'" + }, + "target_organization_id": { + "name": "target_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_by": { + "name": "started_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_by": { + "name": "completed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tr_audit_logs": { + "name": "tr_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_data": { + "name": "before_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_data": { + "name": "after_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "system_role": { + "name": "system_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 82baf40..78e8550 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1783326039269, "tag": "0000_classy_unicorn", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1783350585715, + "tag": "0001_yellow_wasp", + "breakpoints": true } ] } \ No newline at end of file diff --git a/plans/task-p.7.2.md b/plans/task-p.7.2.md new file mode 100644 index 0000000..8e19f63 --- /dev/null +++ b/plans/task-p.7.2.md @@ -0,0 +1,192 @@ +# Task P.7.2 โ€“ SMTP / Email Configuration Platform + +## Objective + +Implement a secure SMTP / Email Configuration Platform for ALLA OS. + +The goal is to let administrators configure, verify, and use outbound email safely for system notifications such as approval alerts, password reset, invitation emails, setup warnings, and future workflow notifications. + +This task must integrate with the existing Setup Platform as an optional readiness/verification step. + +Email configuration must not block First-run Setup unless explicitly required by the selected installation profile. + +--- + +## Review Before Implementation + +Review: + +- AGENTS.md +- docs/standards/project-foundations.md +- docs/standards/architecture-rules.md +- docs/standards/ui-ux-rules.md +- docs/standards/task-review-checklist.md +- docs/setup/verification-matrix.md +- docs/setup/setup-wizard-blueprint.md +- docs/setup/installation-profiles.md +- src/features/setup/** +- src/features/foundation/notifications/** +- src/app/api/setup/readiness/route.ts +- src/app/api/setup/verify/route.ts +- src/db/schema.ts +- existing Auth.js password reset / invitation flow if present + +--- + +## Scope + +Create email configuration foundation. + +Required features: + +- SMTP configuration CRUD +- Secure secret handling +- SMTP connection test +- Send test email +- Email provider readiness check +- Integration with Setup Readiness / Verification +- Optional Setup Wizard step +- Audit-safe reporting +- No secret exposure in API responses + +--- + +## Data Model + +Add table: + +email_configurations + +Suggested fields: + +id + +organization_id nullable + +provider_type + +name + +host + +port + +secure + +username + +password_encrypted + +from_email + +from_name + +reply_to_email + +is_default + +is_active + +last_test_status + +last_tested_at + +last_test_error + +created_by + +updated_by + +created_at + +updated_at + +Provider type values: + +smtp + +future values: + +sendgrid +ses +mailgun +microsoft_graph + +--- + +## Secret Handling + +SMTP password must never be stored as plain text. + +Use existing encryption utility if available. + +If none exists, create foundation crypto helper: + +src/features/foundation/secrets/server/crypto.ts + +Rules: + +- encryption key from environment variable +- missing key blocks saving password +- API never returns password_encrypted +- UI shows password as "configured" or "not configured" +- password can be replaced, not read + +Required environment: + +EMAIL_CONFIG_ENCRYPTION_KEY + +Suggested validation: + +- minimum 32 bytes +- base64 or hex recommended + +--- + +## Server Services + +Create: + +src/features/foundation/email/server/email-config.service.ts +src/features/foundation/email/server/email-test.service.ts +src/features/foundation/email/server/email-provider.ts +src/features/foundation/email/server/smtp-provider.ts + +Responsibilities: + +email-config.service.ts + +- list configs +- get config +- create config +- update config +- delete/deactivate config +- set default config +- validate config +- mask secret output + +email-test.service.ts + +- test SMTP connection +- send test email +- update last_test_status +- update last_tested_at +- store safe last_test_error + +email-provider.ts + +- provider interface + +smtp-provider.ts + +- nodemailer implementation or existing mail library integration + +--- + +## Provider Interface + +```ts +export interface EmailProvider { + testConnection(config: EmailProviderConfig): Promise; + sendEmail(input: SendEmailInput): Promise; +} diff --git a/setup/ALLA-ONVALLA/users.csv b/setup/ALLA-ONVALLA/users.csv index 85237c7..a3e2aab 100644 --- a/setup/ALLA-ONVALLA/users.csv +++ b/setup/ALLA-ONVALLA/users.csv @@ -1,40 +1,26 @@ email,name,system_role,password,active_organization_code,image_url,reset_password super_admin@allaos.local,System Administrator,super_admin,ChangeMe123!,ALLA,,FALSE - gm.uat@allaos.local,General Manager,user,ChangeMe123!,ALLA,,FALSE ceo.uat@allaos.local,Chief Executive Officer,user,ChangeMe123!,ALLA,,FALSE - head_mk.uat@allaos.local,Head of Marketing,user,ChangeMe123!,ALLA,,FALSE mk.uat@allaos.local,Marketing User,user,ChangeMe123!,ALLA,,FALSE - manager.uat@allaos.local,Sales Director,user,ChangeMe123!,ALLA,,FALSE - mgr_crane.uat@allaos.local,Crane Sales Manager,user,ChangeMe123!,ALLA,,FALSE sales_crane.uat@allaos.local,Crane Sales,user,ChangeMe123!,ALLA,,FALSE - mgr_dock.uat@allaos.local,Dock Door Sales Manager,user,ChangeMe123!,ALLA,,FALSE sales_dock.uat@allaos.local,Dock Door Sales,user,ChangeMe123!,ALLA,,FALSE - mgr_solar.uat@allaos.local,Solar Sales Manager,user,ChangeMe123!,ALLA,,FALSE sales_solar.uat@allaos.local,Solar Sales,user,ChangeMe123!,ALLA,,FALSE - sales.uat@allaos.local,General Sales,user,ChangeMe123!,ALLA,,FALSE - gm.onvalla@allaos.local,General Manager,user,ChangeMe123!,ONVALLA,,FALSE ceo.onvalla@allaos.local,Chief Executive Officer,user,ChangeMe123!,ONVALLA,,FALSE - head_mk.onvalla@allaos.local,Head of Marketing,user,ChangeMe123!,ONVALLA,,FALSE mk.onvalla@allaos.local,Marketing User,user,ChangeMe123!,ONVALLA,,FALSE - manager.onvalla@allaos.local,Sales Director,user,ChangeMe123!,ONVALLA,,FALSE - mgr_crane.onvalla@allaos.local,Crane Sales Manager,user,ChangeMe123!,ONVALLA,,FALSE sales_crane.onvalla@allaos.local,Crane Sales,user,ChangeMe123!,ONVALLA,,FALSE - mgr_dock.onvalla@allaos.local,Dock Door Sales Manager,user,ChangeMe123!,ONVALLA,,FALSE sales_dock.onvalla@allaos.local,Dock Door Sales,user,ChangeMe123!,ONVALLA,,FALSE - mgr_solar.onvalla@allaos.local,Solar Sales Manager,user,ChangeMe123!,ONVALLA,,FALSE sales_solar.onvalla@allaos.local,Solar Sales,user,ChangeMe123!,ONVALLA,,FALSE - sales.onvalla@allaos.local,General Sales,user,ChangeMe123!,ONVALLA,,FALSE diff --git a/src/app/api/notifications/email-configurations/[id]/route.ts b/src/app/api/notifications/email-configurations/[id]/route.ts new file mode 100644 index 0000000..c2f757e --- /dev/null +++ b/src/app/api/notifications/email-configurations/[id]/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + deleteEmailConfiguration, + getEmailConfiguration, + updateEmailConfiguration, +} from '@/features/foundation/email/server/email-config.service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +const emailConfigurationSchema = z.object({ + providerType: z.literal('smtp').default('smtp'), + name: z.string().trim().min(1).max(120), + host: z.string().trim().min(1).max(255), + port: z.number().int().min(1).max(65535), + secure: z.boolean(), + username: z.string().trim().max(255).nullable().optional(), + password: z.string().trim().max(1000).nullable().optional(), + clearPassword: z.boolean().optional(), + fromEmail: z.string().trim().email(), + fromName: z.string().trim().max(255).nullable().optional(), + replyToEmail: z.string().trim().email().nullable().optional(), + isDefault: z.boolean().optional(), + isActive: z.boolean().optional(), +}); + +export async function GET( + _request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.notificationsAdmin, + }); + const { id } = await context.params; + const configuration = await getEmailConfiguration(organization.id, id); + + if (!configuration) { + return NextResponse.json({ message: 'Email configuration was not found.' }, { status: 404 }); + } + + return NextResponse.json(configuration); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json({ message: 'Unable to load email configuration' }, { status: 500 }); + } +} + +export async function PATCH( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.notificationsAdmin, + }); + const { id } = await context.params; + const payload = emailConfigurationSchema.parse(await request.json()); + + return NextResponse.json( + await updateEmailConfiguration({ + organizationId: organization.id, + userId: session.user.id, + id, + values: payload, + }) + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: 'Invalid email configuration payload', errors: error.flatten() }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to update email configuration' }, { status: 500 }); + } +} + +export async function DELETE( + _request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.notificationsAdmin, + }); + const { id } = await context.params; + + await deleteEmailConfiguration({ + organizationId: organization.id, + userId: session.user.id, + id, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to delete email configuration' }, { status: 500 }); + } +} diff --git a/src/app/api/notifications/email-configurations/[id]/send-test/route.ts b/src/app/api/notifications/email-configurations/[id]/send-test/route.ts new file mode 100644 index 0000000..696039f --- /dev/null +++ b/src/app/api/notifications/email-configurations/[id]/send-test/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { sendTestEmail } from '@/features/foundation/email/server/email-test.service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +const sendTestEmailSchema = z.object({ + recipientEmail: z.string().trim().email(), +}); + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.notificationsAdmin, + }); + const { id } = await context.params; + const payload = sendTestEmailSchema.parse(await request.json()); + + return NextResponse.json( + await sendTestEmail({ + organizationId: organization.id, + userId: session.user.id, + configurationId: id, + recipientEmail: payload.recipientEmail, + }) + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: 'Invalid test email payload', errors: error.flatten() }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to send test email' }, { status: 500 }); + } +} diff --git a/src/app/api/notifications/email-configurations/[id]/test/route.ts b/src/app/api/notifications/email-configurations/[id]/test/route.ts new file mode 100644 index 0000000..5575293 --- /dev/null +++ b/src/app/api/notifications/email-configurations/[id]/test/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { testEmailConfigurationConnection } from '@/features/foundation/email/server/email-test.service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function POST( + _request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.notificationsAdmin, + }); + const { id } = await context.params; + + return NextResponse.json( + await testEmailConfigurationConnection({ + organizationId: organization.id, + userId: session.user.id, + configurationId: id, + }) + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to test email configuration' }, { status: 500 }); + } +} diff --git a/src/app/api/notifications/email-configurations/route.ts b/src/app/api/notifications/email-configurations/route.ts new file mode 100644 index 0000000..e3ed122 --- /dev/null +++ b/src/app/api/notifications/email-configurations/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + createEmailConfiguration, + listEmailConfigurations, +} from '@/features/foundation/email/server/email-config.service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +const emailConfigurationSchema = z.object({ + providerType: z.literal('smtp').default('smtp'), + name: z.string().trim().min(1).max(120), + host: z.string().trim().min(1).max(255), + port: z.number().int().min(1).max(65535), + secure: z.boolean(), + username: z.string().trim().max(255).nullable().optional(), + password: z.string().trim().max(1000).nullable().optional(), + clearPassword: z.boolean().optional(), + fromEmail: z.string().trim().email(), + fromName: z.string().trim().max(255).nullable().optional(), + replyToEmail: z.string().trim().email().nullable().optional(), + isDefault: z.boolean().optional(), + isActive: z.boolean().optional(), +}); + +export async function GET() { + try { + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.notificationsAdmin, + }); + + return NextResponse.json(await listEmailConfigurations(organization.id)); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json({ message: 'Unable to load email configurations' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.notificationsAdmin, + }); + const payload = emailConfigurationSchema.parse(await request.json()); + + return NextResponse.json( + await createEmailConfiguration({ + organizationId: organization.id, + userId: session.user.id, + values: payload, + }), + { status: 201 } + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: 'Invalid email configuration payload', errors: error.flatten() }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to create email configuration' }, { status: 500 }); + } +} diff --git a/src/app/dashboard/administration/email/page.tsx b/src/app/dashboard/administration/email/page.tsx new file mode 100644 index 0000000..6827510 --- /dev/null +++ b/src/app/dashboard/administration/email/page.tsx @@ -0,0 +1,27 @@ +import { auth } from '@/auth'; +import PageContainer from '@/components/layout/page-container'; +import { EmailConfigurationPage } from '@/features/foundation/email/components/email-configuration-page'; + +export const metadata = { + title: 'Dashboard: Email Configuration', +}; + +export default async function AdministrationEmailPage() { + const session = await auth(); + const canAccess = session?.user?.systemRole === 'super_admin'; + + return ( + + Email configuration requires a super admin account. + + } + > + + + ); +} diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index dcc8578..f323827 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -178,16 +178,24 @@ import { NavGroup } from "@/types"; items: [], access: { systemRole: "super_admin" }, }, - { - title: "Setup Wizard", - url: "/dashboard/administration/setup", - icon: "settings", - isActive: false, - items: [], - access: { systemRole: "super_admin" }, - }, - { - title: "Teams", + { + title: "Setup Wizard", + url: "/dashboard/administration/setup", + icon: "settings", + isActive: false, + items: [], + access: { systemRole: "super_admin" }, + }, + { + title: "Email Delivery", + url: "/dashboard/administration/email", + icon: "settings", + isActive: false, + items: [], + access: { systemRole: "super_admin" }, + }, + { + title: "Teams", url: "/dashboard/workspaces/team", icon: "teams", isActive: false, diff --git a/src/db/schema.ts b/src/db/schema.ts index 24a940f..eb2a2d3 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -265,6 +265,39 @@ export const documentSequences = pgTable( }) ); +export const emailConfigurations = pgTable( + 'email_configurations', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + providerType: text('provider_type').default('smtp').notNull(), + name: text('name').notNull(), + host: text('host').notNull(), + port: integer('port').default(587).notNull(), + secure: boolean('secure').default(false).notNull(), + username: text('username'), + passwordEncrypted: text('password_encrypted'), + fromEmail: text('from_email').notNull(), + fromName: text('from_name'), + replyToEmail: text('reply_to_email'), + isDefault: boolean('is_default').default(false).notNull(), + isActive: boolean('is_active').default(true).notNull(), + lastTestStatus: text('last_test_status').default('not_tested').notNull(), + lastTestedAt: timestamp('last_tested_at', { withTimezone: true }), + lastTestError: text('last_test_error'), + createdBy: text('created_by').notNull(), + updatedBy: text('updated_by').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + organizationNameIdx: uniqueIndex('email_configurations_org_name_idx').on( + table.organizationId, + table.name + ) + }) +); + export const trAuditLogs = pgTable('tr_audit_logs', { id: text('id').primaryKey(), organizationId: text('organization_id').notNull(), diff --git a/src/features/foundation/email/api/mutations.ts b/src/features/foundation/email/api/mutations.ts new file mode 100644 index 0000000..ebc8578 --- /dev/null +++ b/src/features/foundation/email/api/mutations.ts @@ -0,0 +1,47 @@ +import { mutationOptions } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; +import { + createEmailConfiguration, + deleteEmailConfiguration, + sendEmailConfigurationTestEmail, + testEmailConfigurationConnection, + updateEmailConfiguration, +} from './service'; +import { emailConfigurationKeys } from './queries'; +import type { EmailConfigurationPayload, SendTestEmailPayload } from './types'; + +function invalidateEmailConfigurations() { + getQueryClient().invalidateQueries({ queryKey: emailConfigurationKeys.all }); +} + +export const createEmailConfigurationMutation = mutationOptions({ + mutationFn: (payload: EmailConfigurationPayload) => createEmailConfiguration(payload), + onSuccess: invalidateEmailConfigurations, +}); + +export const updateEmailConfigurationMutation = mutationOptions({ + mutationFn: ({ id, payload }: { id: string; payload: EmailConfigurationPayload }) => + updateEmailConfiguration(id, payload), + onSuccess: invalidateEmailConfigurations, +}); + +export const deleteEmailConfigurationMutation = mutationOptions({ + mutationFn: (id: string) => deleteEmailConfiguration(id), + onSuccess: invalidateEmailConfigurations, +}); + +export const testEmailConfigurationConnectionMutation = mutationOptions({ + mutationFn: (id: string) => testEmailConfigurationConnection(id), + onSuccess: invalidateEmailConfigurations, +}); + +export const sendEmailConfigurationTestEmailMutation = mutationOptions({ + mutationFn: ({ + id, + payload, + }: { + id: string; + payload: SendTestEmailPayload; + }) => sendEmailConfigurationTestEmail(id, payload), + onSuccess: invalidateEmailConfigurations, +}); diff --git a/src/features/foundation/email/api/queries.ts b/src/features/foundation/email/api/queries.ts new file mode 100644 index 0000000..d3730cf --- /dev/null +++ b/src/features/foundation/email/api/queries.ts @@ -0,0 +1,14 @@ +import { queryOptions } from '@tanstack/react-query'; +import { listEmailConfigurations } from './service'; + +export const emailConfigurationKeys = { + all: ['email-configurations'] as const, + lists: () => [...emailConfigurationKeys.all, 'list'] as const, + list: () => [...emailConfigurationKeys.lists()] as const, +}; + +export const emailConfigurationsQueryOptions = () => + queryOptions({ + queryKey: emailConfigurationKeys.list(), + queryFn: () => listEmailConfigurations(), + }); diff --git a/src/features/foundation/email/api/service.ts b/src/features/foundation/email/api/service.ts new file mode 100644 index 0000000..b4231ea --- /dev/null +++ b/src/features/foundation/email/api/service.ts @@ -0,0 +1,60 @@ +import { apiClient } from '@/lib/api-client'; +import type { + EmailConfigurationPayload, + EmailConfigurationSummary, + EmailConnectionTestResult, + SendTestEmailPayload, + SendTestEmailResult, +} from './types'; + +const EMAIL_CONFIG_ENDPOINT = '/notifications/email-configurations'; + +export async function listEmailConfigurations(): Promise { + return apiClient(EMAIL_CONFIG_ENDPOINT); +} + +export async function createEmailConfiguration( + payload: EmailConfigurationPayload +): Promise { + return apiClient(EMAIL_CONFIG_ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); +} + +export async function updateEmailConfiguration( + id: string, + payload: EmailConfigurationPayload +): Promise { + return apiClient(`${EMAIL_CONFIG_ENDPOINT}/${id}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); +} + +export async function deleteEmailConfiguration(id: string): Promise<{ success: true }> { + return apiClient<{ success: true }>(`${EMAIL_CONFIG_ENDPOINT}/${id}`, { + method: 'DELETE', + }); +} + +export async function testEmailConfigurationConnection( + id: string +): Promise { + return apiClient(`${EMAIL_CONFIG_ENDPOINT}/${id}/test`, { + method: 'POST', + }); +} + +export async function sendEmailConfigurationTestEmail( + id: string, + payload: SendTestEmailPayload +): Promise { + return apiClient(`${EMAIL_CONFIG_ENDPOINT}/${id}/send-test`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); +} diff --git a/src/features/foundation/email/api/types.ts b/src/features/foundation/email/api/types.ts new file mode 100644 index 0000000..d27a8c9 --- /dev/null +++ b/src/features/foundation/email/api/types.ts @@ -0,0 +1,58 @@ +export type EmailConfigurationProviderType = 'smtp'; +export type EmailConfigurationTestStatus = 'not_tested' | 'passed' | 'failed'; + +export interface EmailConfigurationSummary { + id: string; + organizationId: string; + providerType: EmailConfigurationProviderType; + name: string; + host: string; + port: number; + secure: boolean; + username: string | null; + fromEmail: string; + fromName: string | null; + replyToEmail: string | null; + isDefault: boolean; + isActive: boolean; + hasPassword: boolean; + lastTestStatus: EmailConfigurationTestStatus; + lastTestedAt: string | null; + lastTestError: string | null; + createdBy: string; + updatedBy: string; + createdAt: string; + updatedAt: string; +} + +export interface EmailConfigurationPayload { + providerType: EmailConfigurationProviderType; + name: string; + host: string; + port: number; + secure: boolean; + username?: string | null; + password?: string | null; + clearPassword?: boolean; + fromEmail: string; + fromName?: string | null; + replyToEmail?: string | null; + isDefault?: boolean; + isActive?: boolean; +} + +export interface EmailConnectionTestResult { + ok: boolean; + detail?: string; +} + +export interface SendTestEmailPayload { + recipientEmail: string; +} + +export interface SendTestEmailResult { + providerMessageId: string | null; + acceptedRecipients: string[]; + rejectedRecipients: string[]; + metadata?: Record; +} diff --git a/src/features/foundation/email/components/email-configuration-page.tsx b/src/features/foundation/email/components/email-configuration-page.tsx new file mode 100644 index 0000000..c137b2e --- /dev/null +++ b/src/features/foundation/email/components/email-configuration-page.tsx @@ -0,0 +1,677 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; +import { z } from 'zod'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Icons } from '@/components/icons'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { Separator } from '@/components/ui/separator'; +import { useAppForm, useFormFields } from '@/components/ui/tanstack-form'; +import { formatDateTime } from '@/lib/date-format'; +import { cn } from '@/lib/utils'; +import { + createEmailConfigurationMutation, + deleteEmailConfigurationMutation, + sendEmailConfigurationTestEmailMutation, + testEmailConfigurationConnectionMutation, + updateEmailConfigurationMutation, +} from '../api/mutations'; +import { emailConfigurationsQueryOptions } from '../api/queries'; +import type { EmailConfigurationPayload, EmailConfigurationSummary } from '../api/types'; + +const EMPTY_EMAIL_CONFIGURATIONS: EmailConfigurationSummary[] = []; + +const emailConfigurationFormSchema = z.object({ + providerType: z.literal('smtp'), + name: z.string().trim().min(1, 'Configuration name is required.'), + host: z.string().trim().min(1, 'SMTP host is required.'), + port: z + .string() + .trim() + .min(1, 'Port is required.') + .refine((value) => { + const port = Number(value); + return Number.isInteger(port) && port > 0 && port <= 65535; + }, 'Port must be between 1 and 65535.'), + secure: z.boolean(), + username: z.string().trim().max(255).optional(), + password: z.string().trim().max(1000).optional(), + clearPassword: z.boolean(), + fromEmail: z.string().trim().email('From email must be valid.'), + fromName: z.string().trim().max(255).optional(), + replyToEmail: z + .string() + .trim() + .optional() + .refine((value) => !value || z.string().email().safeParse(value).success, 'Reply-to email must be valid.'), + isDefault: z.boolean(), + isActive: z.boolean(), +}); + +type EmailConfigurationFormValues = z.infer; + +function buildDefaultValues( + configuration?: EmailConfigurationSummary +): EmailConfigurationFormValues { + return { + providerType: 'smtp', + name: configuration?.name ?? '', + host: configuration?.host ?? '', + port: String(configuration?.port ?? 587), + secure: configuration?.secure ?? false, + username: configuration?.username ?? '', + password: '', + clearPassword: false, + fromEmail: configuration?.fromEmail ?? '', + fromName: configuration?.fromName ?? '', + replyToEmail: configuration?.replyToEmail ?? '', + isDefault: configuration?.isDefault ?? false, + isActive: configuration?.isActive ?? true, + }; +} + +function statusBadgeVariant(status: EmailConfigurationSummary['lastTestStatus']) { + if (status === 'failed') return 'destructive'; + if (status === 'passed') return 'default'; + return 'secondary'; +} + +function SummaryMetric({ label, value, hint }: { label: string; value: string; hint: string }) { + return ( +
+
+ {label} +
+
{value}
+
{hint}
+
+ ); +} + +function SecurityNote({ title, description }: { title: string; description: string }) { + return ( +
+
+
+ +
+
+
{title}
+
{description}
+
+
+
+ ); +} + +function ConfigCard({ + configuration, + sendRecipientEmail, + onEdit, + onDelete, + onTestConnection, + onSendTest, + isDeleting, + isTesting, + isSending, +}: { + configuration: EmailConfigurationSummary; + sendRecipientEmail: string; + onEdit: (configuration: EmailConfigurationSummary) => void; + onDelete: (configuration: EmailConfigurationSummary) => void; + onTestConnection: (configuration: EmailConfigurationSummary) => void; + onSendTest: (configuration: EmailConfigurationSummary) => void; + isDeleting: boolean; + isTesting: boolean; + isSending: boolean; +}) { + return ( + + +
+
+
+ {configuration.name} + {configuration.isDefault ? Default : null} + + {configuration.isActive ? 'Active' : 'Inactive'} + +
+ + {configuration.host}:{configuration.port} ยท {configuration.secure ? 'TLS/SSL' : 'STARTTLS / plain'} + +
+ + {configuration.lastTestStatus === 'not_tested' + ? 'Not tested' + : configuration.lastTestStatus === 'passed' + ? 'SMTP ready' + : 'Connection failed'} + +
+
+ +
+
+
From
+
{configuration.fromEmail}
+
{configuration.fromName || 'No display name'}
+
+
+
Auth
+
{configuration.username || 'Anonymous / relay'}
+
+ Password {configuration.hasPassword ? 'configured' : 'not configured'} +
+
+
+
Reply-to
+
+ {configuration.replyToEmail || 'Same as from address'} +
+
+
+
Last verification
+
+ {configuration.lastTestedAt ? formatDateTime(configuration.lastTestedAt) : 'Never tested'} +
+
+
+ + {configuration.lastTestError ? ( + + + Last SMTP error + {configuration.lastTestError} + + ) : null} + +
+ + + + +
+
+
+ ); +} + +export function EmailConfigurationPage() { + const [sheetOpen, setSheetOpen] = useState(false); + const [editingConfiguration, setEditingConfiguration] = useState(null); + const [sendRecipientEmail, setSendRecipientEmail] = useState(''); + + const emailConfigurationsQuery = useQuery(emailConfigurationsQueryOptions()); + const createMutation = useMutation(createEmailConfigurationMutation); + const updateMutation = useMutation(updateEmailConfigurationMutation); + const deleteMutation = useMutation(deleteEmailConfigurationMutation); + const testConnectionMutation = useMutation(testEmailConfigurationConnectionMutation); + const sendTestEmailMutation = useMutation(sendEmailConfigurationTestEmailMutation); + + const configurations = emailConfigurationsQuery.data ?? EMPTY_EMAIL_CONFIGURATIONS; + const summary = useMemo(() => { + const activeCount = configurations.filter((item) => item.isActive).length; + const passingCount = configurations.filter((item) => item.lastTestStatus === 'passed').length; + const defaultConfig = configurations.find((item) => item.isDefault) ?? null; + + return { + total: configurations.length, + activeCount, + passingCount, + defaultName: defaultConfig?.name ?? 'Not selected', + }; + }, [configurations]); + + const defaultValues = useMemo( + () => buildDefaultValues(editingConfiguration ?? undefined), + [editingConfiguration] + ); + + const { FormTextField, FormSwitchField } = useFormFields(); + const form = useAppForm({ + defaultValues, + validators: { + onSubmit: emailConfigurationFormSchema, + }, + onSubmit: async ({ value }) => { + const payload: EmailConfigurationPayload = { + providerType: 'smtp', + name: value.name.trim(), + host: value.host.trim(), + port: Number(value.port), + secure: value.secure, + username: value.username?.trim() || null, + password: value.password?.trim() || undefined, + clearPassword: value.clearPassword, + fromEmail: value.fromEmail.trim().toLowerCase(), + fromName: value.fromName?.trim() || null, + replyToEmail: value.replyToEmail?.trim() || null, + isDefault: value.isDefault, + isActive: value.isActive, + }; + + if (editingConfiguration) { + await updateMutation.mutateAsync({ + id: editingConfiguration.id, + payload, + }); + toast.success('Email configuration updated.'); + } else { + await createMutation.mutateAsync(payload); + toast.success('Email configuration created.'); + } + + setSheetOpen(false); + setEditingConfiguration(null); + }, + }); + + useEffect(() => { + if (sheetOpen) { + form.reset(defaultValues); + } + }, [defaultValues, form, sheetOpen]); + + async function handleDelete(configuration: EmailConfigurationSummary) { + try { + await deleteMutation.mutateAsync(configuration.id); + toast.success(`Removed ${configuration.name}.`); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Unable to delete email configuration.'); + } + } + + async function handleTestConnection(configuration: EmailConfigurationSummary) { + try { + const result = await testConnectionMutation.mutateAsync(configuration.id); + if (!result.ok) { + toast.error(result.detail ?? 'SMTP connection failed.'); + return; + } + toast.success(`SMTP connection succeeded for ${configuration.name}.`); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Unable to test SMTP connection.'); + } + } + + async function handleSendTest(configuration: EmailConfigurationSummary) { + try { + const result = await sendTestEmailMutation.mutateAsync({ + id: configuration.id, + payload: { + recipientEmail: sendRecipientEmail.trim().toLowerCase(), + }, + }); + + if (result.rejectedRecipients.length > 0) { + toast.error(`SMTP rejected: ${result.rejectedRecipients.join(', ')}`); + return; + } + + toast.success(`Test email queued to ${sendRecipientEmail.trim().toLowerCase()}.`); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Unable to send test email.'); + } + } + + const saving = createMutation.isPending || updateMutation.isPending; + + return ( +
+
+
+
+
+ + Email delivery center +
+
+

+ Manage SMTP delivery in one calm, auditable place. +

+

+ Configure secure outbound email for approvals, password recovery, invitations, and setup warnings. + Secrets stay encrypted at rest and the UI only exposes safe operational state. +

+
+
+ + + + +
+
+ + + + Quick verification + + Use one test recipient while reviewing multiple SMTP profiles. + + + +
+ + setSendRecipientEmail(event.target.value)} + placeholder='ops@example.com' + /> +
+ +
+
+ + Connection test updates the configuration health state without exposing the password. +
+
+ + Stored passwords can be replaced or cleared, but are never returned from the API. +
+
+ + Setup wizard and notification runtime read the same organization-level SMTP source. +
+
+
+ + +
+
+
+
+
+ +
+
+ {emailConfigurationsQuery.isLoading ? ( + + + + Loading email configurations... + + + ) : null} + + {emailConfigurationsQuery.isError ? ( + + + Unable to load SMTP profiles + + {emailConfigurationsQuery.error instanceof Error + ? emailConfigurationsQuery.error.message + : 'Unknown email configuration error.'} + + + ) : null} + + {!emailConfigurationsQuery.isLoading && configurations.length === 0 ? ( + + +
+ +
+
+
No SMTP configuration yet
+
+ Add a profile to enable outbound email for system notifications, approvals, and future workflow messages. +
+
+ +
+
+ ) : null} + + {configurations.map((configuration) => ( + { + setEditingConfiguration(item); + setSheetOpen(true); + }} + onDelete={handleDelete} + onTestConnection={handleTestConnection} + onSendTest={handleSendTest} + isDeleting={deleteMutation.isPending && deleteMutation.variables === configuration.id} + isTesting={ + testConnectionMutation.isPending && + testConnectionMutation.variables === configuration.id + } + isSending={ + sendTestEmailMutation.isPending && + sendTestEmailMutation.variables?.id === configuration.id + } + /> + ))} +
+ + +
+ + { + setSheetOpen(open); + if (!open) { + setEditingConfiguration(null); + } + }} + > + + + {editingConfiguration ? 'Edit SMTP configuration' : 'Add SMTP configuration'} + + Keep the profile readable for operators. Passwords can be updated, but never viewed again. + + + +
+ + +
+ + + + + + + + +
+ +
+ + + + {editingConfiguration?.hasPassword ? ( + + ) : null} +
+
+
+
+ + + + + +
+
+
+ ); +} diff --git a/src/features/foundation/email/components/setup-email-card.tsx b/src/features/foundation/email/components/setup-email-card.tsx new file mode 100644 index 0000000..ba26708 --- /dev/null +++ b/src/features/foundation/email/components/setup-email-card.tsx @@ -0,0 +1,88 @@ +'use client'; + +import Link from 'next/link'; +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Icons } from '@/components/icons'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { emailConfigurationsQueryOptions } from '../api/queries'; + +export function SetupEmailCard() { + const query = useQuery(emailConfigurationsQueryOptions()); + + const summary = useMemo(() => { + const configurations = query.data ?? []; + const defaultConfiguration = configurations.find((item) => item.isDefault) ?? null; + const passingCount = configurations.filter((item) => item.lastTestStatus === 'passed').length; + + return { + total: configurations.length, + defaultName: defaultConfiguration?.name ?? 'Not configured', + passingCount, + }; + }, [query.data]); + + return ( + + +
+
+ Email Delivery + + Optional setup step for SMTP readiness. This does not block setup unless your chosen profile requires it. + +
+ Optional +
+
+ + {query.isLoading ? ( +
+ + Loading email configuration status... +
+ ) : null} + + {query.isError ? ( + + + Unable to load email status + + {query.error instanceof Error ? query.error.message : 'Unknown email configuration error.'} + + + ) : null} + + {!query.isLoading && !query.isError ? ( + <> +
+
+
Profiles
+
{summary.total}
+
+
+
Ready
+
{summary.passingCount}
+
+
+
Default
+
{summary.defaultName}
+
+
+ +
+ Configure SMTP now if you want setup verification to include live email readiness against the active organization. +
+ + ) : null} + + +
+
+ ); +} diff --git a/src/features/foundation/email/server/email-config.service.ts b/src/features/foundation/email/server/email-config.service.ts new file mode 100644 index 0000000..a07bff5 --- /dev/null +++ b/src/features/foundation/email/server/email-config.service.ts @@ -0,0 +1,420 @@ +import 'server-only'; + +import { and, desc, eq, ne } from 'drizzle-orm'; +import { emailConfigurations } from '@/db/schema'; +import { auditAction, auditCreate, auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { decryptSecret, encryptSecret } from '@/features/foundation/secrets/server/crypto'; +import { db } from '@/lib/db'; +import type { EmailProviderConfig } from './email-provider'; + +export type EmailConfigurationProviderType = 'smtp'; +export type EmailConfigurationTestStatus = 'not_tested' | 'passed' | 'failed'; + +export type EmailConfigurationRecord = { + id: string; + organizationId: string; + providerType: EmailConfigurationProviderType; + name: string; + host: string; + port: number; + secure: boolean; + username: string | null; + fromEmail: string; + fromName: string | null; + replyToEmail: string | null; + isDefault: boolean; + isActive: boolean; + hasPassword: boolean; + lastTestStatus: EmailConfigurationTestStatus; + lastTestedAt: string | null; + lastTestError: string | null; + createdBy: string; + updatedBy: string; + createdAt: string; + updatedAt: string; +}; + +export type SaveEmailConfigurationInput = { + providerType: EmailConfigurationProviderType; + name: string; + host: string; + port: number; + secure: boolean; + username?: string | null; + password?: string | null; + clearPassword?: boolean; + fromEmail: string; + fromName?: string | null; + replyToEmail?: string | null; + isDefault?: boolean; + isActive?: boolean; +}; + +type EmailConfigurationRow = typeof emailConfigurations.$inferSelect; + +function toIso(value: Date | null): string | null { + return value ? value.toISOString() : null; +} + +function mapRecord(row: EmailConfigurationRow): EmailConfigurationRecord { + return { + id: row.id, + organizationId: row.organizationId, + providerType: row.providerType as EmailConfigurationProviderType, + name: row.name, + host: row.host, + port: row.port, + secure: row.secure, + username: row.username ?? null, + fromEmail: row.fromEmail, + fromName: row.fromName ?? null, + replyToEmail: row.replyToEmail ?? null, + isDefault: row.isDefault, + isActive: row.isActive, + hasPassword: Boolean(row.passwordEncrypted), + lastTestStatus: row.lastTestStatus as EmailConfigurationTestStatus, + lastTestedAt: toIso(row.lastTestedAt), + lastTestError: row.lastTestError ?? null, + createdBy: row.createdBy, + updatedBy: row.updatedBy, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function normalizeOptionalText(value?: string | null): string | null { + const nextValue = value?.trim() ?? ''; + return nextValue.length > 0 ? nextValue : null; +} + +function truncateErrorMessage(value: string): string { + return value.slice(0, 500); +} + +async function unsetOtherDefaults(organizationId: string, currentId?: string): Promise { + await db + .update(emailConfigurations) + .set({ + isDefault: false, + updatedAt: new Date(), + }) + .where( + and( + eq(emailConfigurations.organizationId, organizationId), + currentId ? ne(emailConfigurations.id, currentId) : undefined + ) + ); +} + +export async function listEmailConfigurations(organizationId: string): Promise { + const rows = await db + .select() + .from(emailConfigurations) + .where(eq(emailConfigurations.organizationId, organizationId)) + .orderBy(desc(emailConfigurations.isDefault), desc(emailConfigurations.isActive), emailConfigurations.name); + + return rows.map(mapRecord); +} + +export async function getEmailConfiguration( + organizationId: string, + id: string +): Promise { + const [row] = await db + .select() + .from(emailConfigurations) + .where(and(eq(emailConfigurations.organizationId, organizationId), eq(emailConfigurations.id, id))) + .limit(1); + + return row ? mapRecord(row) : null; +} + +export async function getEmailConfigurationForUse( + organizationId: string, + providerType: EmailConfigurationProviderType = 'smtp' +): Promise<(EmailProviderConfig & { id: string; name: string }) | null> { + const [row] = await db + .select() + .from(emailConfigurations) + .where( + and( + eq(emailConfigurations.organizationId, organizationId), + eq(emailConfigurations.providerType, providerType), + eq(emailConfigurations.isActive, true) + ) + ) + .orderBy(desc(emailConfigurations.isDefault), desc(emailConfigurations.updatedAt)) + .limit(1); + + if (!row) { + return null; + } + + return { + id: row.id, + name: row.name, + providerType: row.providerType as EmailConfigurationProviderType, + host: row.host, + port: row.port, + secure: row.secure, + username: row.username ?? null, + password: row.passwordEncrypted ? decryptSecret(row.passwordEncrypted) : null, + fromEmail: row.fromEmail, + fromName: row.fromName ?? null, + replyToEmail: row.replyToEmail ?? null, + }; +} + +export async function getEmailConfigurationByIdForUse( + organizationId: string, + id: string +): Promise<(EmailProviderConfig & { id: string; name: string }) | null> { + const [row] = await db + .select() + .from(emailConfigurations) + .where(and(eq(emailConfigurations.organizationId, organizationId), eq(emailConfigurations.id, id))) + .limit(1); + + if (!row) { + return null; + } + + return { + id: row.id, + name: row.name, + providerType: row.providerType as EmailConfigurationProviderType, + host: row.host, + port: row.port, + secure: row.secure, + username: row.username ?? null, + password: row.passwordEncrypted ? decryptSecret(row.passwordEncrypted) : null, + fromEmail: row.fromEmail, + fromName: row.fromName ?? null, + replyToEmail: row.replyToEmail ?? null, + }; +} + +export async function createEmailConfiguration(input: { + organizationId: string; + userId: string; + values: SaveEmailConfigurationInput; +}): Promise { + const now = new Date(); + const username = normalizeOptionalText(input.values.username); + const shouldEncryptPassword = typeof input.values.password === 'string' && input.values.password.trim().length > 0; + + if (input.values.isDefault) { + await unsetOtherDefaults(input.organizationId); + } + + const [created] = await db + .insert(emailConfigurations) + .values({ + id: crypto.randomUUID(), + organizationId: input.organizationId, + providerType: input.values.providerType, + name: input.values.name.trim(), + host: input.values.host.trim(), + port: input.values.port, + secure: input.values.secure, + username, + passwordEncrypted: shouldEncryptPassword ? encryptSecret(input.values.password!.trim()) : null, + fromEmail: input.values.fromEmail.trim().toLowerCase(), + fromName: normalizeOptionalText(input.values.fromName), + replyToEmail: normalizeOptionalText(input.values.replyToEmail)?.toLowerCase() ?? null, + isDefault: Boolean(input.values.isDefault), + isActive: input.values.isActive ?? true, + lastTestStatus: 'not_tested', + createdBy: input.userId, + updatedBy: input.userId, + createdAt: now, + updatedAt: now, + }) + .returning(); + + await auditCreate({ + organizationId: input.organizationId, + userId: input.userId, + entityType: 'email_configuration', + entityId: created.id, + afterData: { + providerType: created.providerType, + name: created.name, + host: created.host, + port: created.port, + secure: created.secure, + username: created.username, + fromEmail: created.fromEmail, + isDefault: created.isDefault, + isActive: created.isActive, + hasPassword: Boolean(created.passwordEncrypted), + }, + }); + + return mapRecord(created); +} + +export async function updateEmailConfiguration(input: { + organizationId: string; + userId: string; + id: string; + values: SaveEmailConfigurationInput; +}): Promise { + const [existing] = await db + .select() + .from(emailConfigurations) + .where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id))) + .limit(1); + + if (!existing) { + throw new Error('Email configuration was not found.'); + } + + if (input.values.isDefault) { + await unsetOtherDefaults(input.organizationId, input.id); + } + + const passwordEncrypted = + input.values.clearPassword === true + ? null + : typeof input.values.password === 'string' && input.values.password.trim().length > 0 + ? encryptSecret(input.values.password.trim()) + : existing.passwordEncrypted; + + const [updated] = await db + .update(emailConfigurations) + .set({ + providerType: input.values.providerType, + name: input.values.name.trim(), + host: input.values.host.trim(), + port: input.values.port, + secure: input.values.secure, + username: normalizeOptionalText(input.values.username), + passwordEncrypted, + fromEmail: input.values.fromEmail.trim().toLowerCase(), + fromName: normalizeOptionalText(input.values.fromName), + replyToEmail: normalizeOptionalText(input.values.replyToEmail)?.toLowerCase() ?? null, + isDefault: Boolean(input.values.isDefault), + isActive: input.values.isActive ?? existing.isActive, + updatedBy: input.userId, + updatedAt: new Date(), + }) + .where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id))) + .returning(); + + await auditUpdate({ + organizationId: input.organizationId, + userId: input.userId, + entityType: 'email_configuration', + entityId: updated.id, + beforeData: { + ...mapRecord(existing), + hasPassword: Boolean(existing.passwordEncrypted), + }, + afterData: mapRecord(updated), + }); + + return mapRecord(updated); +} + +export async function deleteEmailConfiguration(input: { + organizationId: string; + userId: string; + id: string; +}): Promise { + const [existing] = await db + .select() + .from(emailConfigurations) + .where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id))) + .limit(1); + + if (!existing) { + throw new Error('Email configuration was not found.'); + } + + await db + .delete(emailConfigurations) + .where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id))); + + if (existing.isDefault) { + const [fallback] = await db + .select() + .from(emailConfigurations) + .where(eq(emailConfigurations.organizationId, input.organizationId)) + .orderBy(desc(emailConfigurations.isActive), desc(emailConfigurations.updatedAt)) + .limit(1); + + if (fallback) { + await db + .update(emailConfigurations) + .set({ + isDefault: true, + updatedBy: input.userId, + updatedAt: new Date(), + }) + .where(eq(emailConfigurations.id, fallback.id)); + } + } + + await auditDelete({ + organizationId: input.organizationId, + userId: input.userId, + entityType: 'email_configuration', + entityId: input.id, + beforeData: mapRecord(existing), + }); +} + +export async function markEmailConfigurationTestResult(input: { + organizationId: string; + userId: string; + id: string; + status: Exclude; + errorMessage?: string | null; +}): Promise { + const [updated] = await db + .update(emailConfigurations) + .set({ + lastTestStatus: input.status, + lastTestedAt: new Date(), + lastTestError: input.errorMessage ? truncateErrorMessage(input.errorMessage) : null, + updatedBy: input.userId, + updatedAt: new Date(), + }) + .where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id))) + .returning(); + + if (!updated) { + throw new Error('Email configuration was not found.'); + } + + await auditAction({ + organizationId: input.organizationId, + userId: input.userId, + entityType: 'email_configuration', + entityId: input.id, + action: input.status === 'passed' ? 'email_configuration_test_passed' : 'email_configuration_test_failed', + afterData: { + lastTestStatus: updated.lastTestStatus, + lastTestedAt: toIso(updated.lastTestedAt), + lastTestError: updated.lastTestError, + }, + }); + + return mapRecord(updated); +} + +export async function hasActiveEmailConfiguration(organizationId: string): Promise { + const [row] = await db + .select({ id: emailConfigurations.id }) + .from(emailConfigurations) + .where( + and( + eq(emailConfigurations.organizationId, organizationId), + eq(emailConfigurations.isActive, true) + ) + ) + .limit(1); + + return Boolean(row); +} diff --git a/src/features/foundation/email/server/email-provider.ts b/src/features/foundation/email/server/email-provider.ts new file mode 100644 index 0000000..3bbc4a8 --- /dev/null +++ b/src/features/foundation/email/server/email-provider.ts @@ -0,0 +1,52 @@ +import 'server-only'; + +export type EmailProviderConfig = { + providerType: 'smtp'; + host: string; + port: number; + secure: boolean; + username: string | null; + password: string | null; + fromEmail: string; + fromName: string | null; + replyToEmail: string | null; +}; + +export type EmailTestResult = { + ok: boolean; + detail?: string; +}; + +export type SendEmailRecipient = { + email: string; + name?: string | null; +}; + +export type SendEmailAttachment = { + fileName: string; + contentType: string; + buffer: Buffer; +}; + +export type SendEmailInput = { + subject: string; + text: string; + html?: string | null; + to: SendEmailRecipient[]; + cc?: SendEmailRecipient[]; + bcc?: SendEmailRecipient[]; + replyTo?: string | null; + attachments?: SendEmailAttachment[]; +}; + +export type SendEmailResult = { + providerMessageId: string | null; + acceptedRecipients: string[]; + rejectedRecipients: string[]; + metadata?: Record; +}; + +export interface EmailProvider { + testConnection(config: EmailProviderConfig): Promise; + sendEmail(config: EmailProviderConfig, input: SendEmailInput): Promise; +} diff --git a/src/features/foundation/email/server/email-test.service.ts b/src/features/foundation/email/server/email-test.service.ts new file mode 100644 index 0000000..67285c4 --- /dev/null +++ b/src/features/foundation/email/server/email-test.service.ts @@ -0,0 +1,85 @@ +import 'server-only'; + +import type { SendEmailResult } from './email-provider'; +import { SmtpEmailProvider } from './smtp-provider'; +import { + getEmailConfigurationByIdForUse, + markEmailConfigurationTestResult, +} from './email-config.service'; + +export async function testEmailConfigurationConnection(input: { + organizationId: string; + userId: string; + configurationId: string; +}) { + const config = await getEmailConfigurationByIdForUse(input.organizationId, input.configurationId); + if (!config) { + throw new Error('Email configuration was not found.'); + } + + const provider = new SmtpEmailProvider(); + const result = await provider.testConnection(config); + + await markEmailConfigurationTestResult({ + organizationId: input.organizationId, + userId: input.userId, + id: config.id, + status: result.ok ? 'passed' : 'failed', + errorMessage: result.detail ?? null, + }); + + return result; +} + +export async function sendTestEmail(input: { + organizationId: string; + userId: string; + configurationId: string; + recipientEmail: string; +}): Promise { + const config = await getEmailConfigurationByIdForUse(input.organizationId, input.configurationId); + if (!config) { + throw new Error('Email configuration was not found.'); + } + + const provider = new SmtpEmailProvider(); + + try { + const result = await provider.sendEmail(config, { + subject: `ALLA OS test email from ${config.name}`, + text: [ + 'This is a test email from ALLA OS.', + '', + `Configuration: ${config.name}`, + `SMTP host: ${config.host}:${config.port}`, + `Sent at: ${new Date().toISOString()}`, + ].join('\n'), + html: [ + '

This is a test email from ALLA OS.

', + `

Configuration: ${config.name}

`, + `

SMTP host: ${config.host}:${config.port}

`, + `

Sent at: ${new Date().toISOString()}

`, + ].join(''), + to: [{ email: input.recipientEmail.trim().toLowerCase() }], + }); + + await markEmailConfigurationTestResult({ + organizationId: input.organizationId, + userId: input.userId, + id: config.id, + status: 'passed', + }); + + return result; + } catch (error) { + await markEmailConfigurationTestResult({ + organizationId: input.organizationId, + userId: input.userId, + id: config.id, + status: 'failed', + errorMessage: error instanceof Error ? error.message : 'Unknown email send failure', + }); + + throw error; + } +} diff --git a/src/features/foundation/email/server/smtp-provider.ts b/src/features/foundation/email/server/smtp-provider.ts new file mode 100644 index 0000000..da63fc2 --- /dev/null +++ b/src/features/foundation/email/server/smtp-provider.ts @@ -0,0 +1,89 @@ +import 'server-only'; + +import type SMTPTransport from 'nodemailer/lib/smtp-transport'; +import type { Transporter } from 'nodemailer'; +import type { + EmailProvider, + EmailProviderConfig, + EmailTestResult, + SendEmailInput, + SendEmailRecipient, + SendEmailResult, +} from './email-provider'; + +function formatRecipient(recipient: SendEmailRecipient): string { + return recipient.name?.trim() + ? `"${recipient.name.trim()}" <${recipient.email}>` + : recipient.email; +} + +function getFromValue(config: EmailProviderConfig): string { + return config.fromName?.trim() + ? `"${config.fromName.trim()}" <${config.fromEmail}>` + : config.fromEmail; +} + +async function createTransporter(config: EmailProviderConfig): Promise { + const nodemailer = await import('nodemailer'); + + return nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: config.username + ? { + user: config.username, + pass: config.password ?? undefined, + } + : undefined, + } satisfies SMTPTransport.Options); +} + +function normalizeRecipients(values: Array | undefined): string[] { + return Array.isArray(values) ? values.map((value) => String(value)) : []; +} + +export class SmtpEmailProvider implements EmailProvider { + async testConnection(config: EmailProviderConfig): Promise { + const transporter = await createTransporter(config); + + try { + await transporter.verify(); + return { ok: true }; + } catch (error) { + return { + ok: false, + detail: error instanceof Error ? error.message : 'Unknown SMTP health check failure', + }; + } + } + + async sendEmail(config: EmailProviderConfig, input: SendEmailInput): Promise { + const transporter = await createTransporter(config); + const info = await transporter.sendMail({ + from: getFromValue(config), + to: input.to.map(formatRecipient), + cc: input.cc?.map(formatRecipient), + bcc: input.bcc?.map(formatRecipient), + replyTo: input.replyTo ?? config.replyToEmail ?? undefined, + subject: input.subject, + text: input.text, + html: input.html ?? undefined, + attachments: input.attachments?.map((attachment) => ({ + filename: attachment.fileName, + content: attachment.buffer, + contentType: attachment.contentType, + })), + }); + + return { + providerMessageId: info.messageId ?? null, + acceptedRecipients: normalizeRecipients(info.accepted), + rejectedRecipients: normalizeRecipients(info.rejected), + metadata: { + envelope: info.envelope, + response: info.response ?? null, + }, + }; + } +} diff --git a/src/features/foundation/notifications/server/email-provider.ts b/src/features/foundation/notifications/server/email-provider.ts index 965e1b9..a1b4795 100644 --- a/src/features/foundation/notifications/server/email-provider.ts +++ b/src/features/foundation/notifications/server/email-provider.ts @@ -1,7 +1,7 @@ import 'server-only'; -import type { Transporter } from 'nodemailer'; -import type SMTPTransport from 'nodemailer/lib/smtp-transport'; +import { getEmailConfigurationForUse } from '@/features/foundation/email/server/email-config.service'; +import { SmtpEmailProvider } from '@/features/foundation/email/server/smtp-provider'; import type { NotificationChannel, NotificationProvider, @@ -9,126 +9,59 @@ import type { NotificationProviderSendResult, } from '../types'; -type SmtpConfig = { - host: string; - port: number; - secure: boolean; - user?: string; - pass?: string; - from: string; -}; - -function parseBoolean(value: string | undefined, fallback: boolean) { - if (!value) { - return fallback; - } - - return value === 'true' || value === '1'; -} - -function getSmtpConfig(): SmtpConfig { - const host = process.env.SMTP_HOST?.trim() ?? ''; - const port = Number(process.env.SMTP_PORT ?? '587'); - const secure = parseBoolean(process.env.SMTP_SECURE, port === 465); - const user = process.env.SMTP_USER?.trim(); - const pass = process.env.SMTP_PASS?.trim(); - const from = process.env.SMTP_FROM?.trim() ?? ''; - - return { host, port, secure, user, pass, from }; -} - -async function createTransporter() { - const nodemailer = await import('nodemailer'); - const config = getSmtpConfig(); - - return nodemailer.createTransport({ - host: config.host, - port: config.port, - secure: config.secure, - auth: config.user ? { user: config.user, pass: config.pass } : undefined, - } satisfies SMTPTransport.Options); -} - -function formatRecipients( - recipients: NotificationProviderSendInput['to'], -): string[] { - return recipients.map((recipient) => - recipient.name?.trim() - ? `"${recipient.name.trim()}" <${recipient.email}>` - : recipient.email, - ); -} - export class EmailNotificationProvider implements NotificationProvider { readonly channel: NotificationChannel = 'email'; readonly key = 'smtp'; + constructor(private readonly organizationId: string) {} + + private async resolveConfig() { + const config = await getEmailConfigurationForUse(this.organizationId, 'smtp'); + if (!config) { + throw new Error('No active SMTP email configuration was found for this organization.'); + } + + return config; + } + async validate() { - const config = getSmtpConfig(); - - if (!config.host) { - throw new Error('SMTP host is not configured.'); - } - - if (!Number.isFinite(config.port) || config.port <= 0) { - throw new Error('SMTP port is invalid.'); - } - - if (!config.from) { - throw new Error('SMTP from address is not configured.'); - } + await this.resolveConfig(); } async healthCheck() { try { - await this.validate(); - const transporter = await createTransporter(); - await transporter.verify(); + const config = await this.resolveConfig(); + const provider = new SmtpEmailProvider(); + const result = await provider.testConnection(config); - return { ok: true as const }; + return result.ok + ? { ok: true } + : { ok: false, detail: result.detail ?? 'SMTP health check failed.' }; } catch (error) { return { - ok: false as const, + ok: false, detail: error instanceof Error ? error.message : 'Unknown SMTP health check failure', }; } } - async send( - input: NotificationProviderSendInput, - ): Promise { - await this.validate(); - const transporter = (await createTransporter()) as Transporter; - const config = getSmtpConfig(); + async send(input: NotificationProviderSendInput): Promise { + const config = await this.resolveConfig(); + const provider = new SmtpEmailProvider(); - const info = await transporter.sendMail({ - from: config.from, - to: formatRecipients(input.to), - cc: input.cc.length ? formatRecipients(input.cc) : undefined, - bcc: input.bcc.length ? formatRecipients(input.bcc) : undefined, - replyTo: input.replyTo ?? undefined, + return provider.sendEmail(config, { subject: input.subject, text: input.bodyText, - html: input.bodyHtml ?? undefined, + html: input.bodyHtml, + to: input.to, + cc: input.cc, + bcc: input.bcc, + replyTo: input.replyTo, attachments: input.attachments.map((attachment, index) => ({ - filename: attachment.fileName, - content: input.attachmentBuffers[index], + fileName: attachment.fileName, contentType: attachment.contentType, + buffer: input.attachmentBuffers[index] ?? Buffer.alloc(0), })), }); - - return { - providerMessageId: info.messageId ?? null, - acceptedRecipients: Array.isArray(info.accepted) - ? info.accepted.map((value: string | { address: string }) => String(value)) - : [], - rejectedRecipients: Array.isArray(info.rejected) - ? info.rejected.map((value: string | { address: string }) => String(value)) - : [], - metadata: { - envelope: info.envelope, - response: info.response ?? null, - }, - }; } } diff --git a/src/features/foundation/notifications/server/provider-factory.ts b/src/features/foundation/notifications/server/provider-factory.ts index 9657b1f..67e9248 100644 --- a/src/features/foundation/notifications/server/provider-factory.ts +++ b/src/features/foundation/notifications/server/provider-factory.ts @@ -5,11 +5,11 @@ import type { NotificationChannel, NotificationProvider } from '../types'; export function getNotificationProvider( channel: NotificationChannel, - providerKey?: string, + organizationId: string, + providerKey?: string ): NotificationProvider { if (channel === 'email') { - const provider = new EmailNotificationProvider(); - + const provider = new EmailNotificationProvider(organizationId); if (providerKey && provider.key !== providerKey) { throw new Error(`Unsupported email provider: ${providerKey}`); } diff --git a/src/features/foundation/notifications/server/service.ts b/src/features/foundation/notifications/server/service.ts index 29ef9e3..b5bd77f 100644 --- a/src/features/foundation/notifications/server/service.ts +++ b/src/features/foundation/notifications/server/service.ts @@ -430,7 +430,11 @@ async function runDispatch(input: { dispatch: NotificationDispatchRecord; attachments: ResolvedNotificationAttachment[]; }) { - const provider = getNotificationProvider(input.dispatch.channel, input.dispatch.providerKey); + const provider = getNotificationProvider( + input.dispatch.channel, + input.organizationId, + input.dispatch.providerKey + ); await db .update(appNotificationDispatches) diff --git a/src/features/foundation/secrets/server/crypto.ts b/src/features/foundation/secrets/server/crypto.ts new file mode 100644 index 0000000..876a9d2 --- /dev/null +++ b/src/features/foundation/secrets/server/crypto.ts @@ -0,0 +1,82 @@ +import 'server-only'; + +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; + +const ENCRYPTION_PREFIX = 'enc:v1'; +const ENCRYPTION_ENV_KEY = 'EMAIL_CONFIG_ENCRYPTION_KEY'; +const MIN_KEY_BYTES = 32; + +function decodeConfiguredKey(rawValue: string): Buffer { + const value = rawValue.trim(); + + if (/^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0) { + return Buffer.from(value, 'hex'); + } + + try { + const base64Buffer = Buffer.from(value, 'base64'); + if (base64Buffer.length > 0 && base64Buffer.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')) { + return base64Buffer; + } + } catch { + // Fall back to UTF-8 below. + } + + return Buffer.from(value, 'utf8'); +} + +function getEncryptionKeyMaterial(): Buffer { + const configured = process.env[ENCRYPTION_ENV_KEY]?.trim() ?? ''; + if (!configured) { + throw new Error(`${ENCRYPTION_ENV_KEY} is not configured.`); + } + + const keyMaterial = decodeConfiguredKey(configured); + if (keyMaterial.byteLength < MIN_KEY_BYTES) { + throw new Error(`${ENCRYPTION_ENV_KEY} must provide at least ${MIN_KEY_BYTES} bytes of key material.`); + } + + return createHash('sha256').update(keyMaterial).digest(); +} + +export function assertSecretsEncryptionReady(): void { + getEncryptionKeyMaterial(); +} + +export function encryptSecret(plainText: string): string { + const normalized = plainText.trim(); + if (!normalized) { + throw new Error('Secret value is required for encryption.'); + } + + const key = getEncryptionKeyMaterial(); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([cipher.update(normalized, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + + return [ + ENCRYPTION_PREFIX, + iv.toString('base64'), + tag.toString('base64'), + encrypted.toString('base64'), + ].join(':'); +} + +export function decryptSecret(cipherText: string): string { + const [prefix, ivPart, tagPart, encryptedPart] = cipherText.split(':'); + if (prefix !== ENCRYPTION_PREFIX || !ivPart || !tagPart || !encryptedPart) { + throw new Error('Encrypted secret format is invalid.'); + } + + const key = getEncryptionKeyMaterial(); + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivPart, 'base64')); + decipher.setAuthTag(Buffer.from(tagPart, 'base64')); + + const plain = Buffer.concat([ + decipher.update(Buffer.from(encryptedPart, 'base64')), + decipher.final(), + ]); + + return plain.toString('utf8'); +} diff --git a/src/features/setup/components/setup-wizard.tsx b/src/features/setup/components/setup-wizard.tsx index 5027bd3..b7a7aac 100644 --- a/src/features/setup/components/setup-wizard.tsx +++ b/src/features/setup/components/setup-wizard.tsx @@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; +import { SetupEmailCard } from '@/features/foundation/email/components/setup-email-card'; import { cn } from '@/lib/utils'; import { useConfiguredBootstrapCsvCommit, @@ -616,16 +617,17 @@ export function SetupWizard({ mode = 'admin', tokenRequired = false }: SetupWiza onSelect={setCurrentStepKey} /> -
- - + + + - + Final Verification Setup can complete after system-required import steps pass. diff --git a/src/features/setup/server/readiness.service.ts b/src/features/setup/server/readiness.service.ts index 4895030..7202d49 100644 --- a/src/features/setup/server/readiness.service.ts +++ b/src/features/setup/server/readiness.service.ts @@ -15,7 +15,8 @@ import { organizations, users } from '@/db/schema'; -import { EmailNotificationProvider } from '@/features/foundation/notifications/server/email-provider'; +import { getEmailConfigurationForUse } from '@/features/foundation/email/server/email-config.service'; +import { SmtpEmailProvider } from '@/features/foundation/email/server/smtp-provider'; import { getStorageProvider } from '@/features/foundation/storage/service'; import { getSetupPluginRegistry } from '@/features/setup/plugins/plugin-registry'; import { db } from '@/lib/db'; @@ -366,28 +367,44 @@ export async function checkPdfMappingCoverage( }); } -export async function checkEmailConfig(): Promise { - const meta: CheckMeta = { - id: 'EMAIL_CONFIG', - area: 'Email', - name: 'Email configuration', - requiredFor: ['optional notifications'], - purpose: 'Confirm optional email readiness.', - suggestedFix: 'Fix SMTP provider settings.' - }; - return runSafeCheck(meta, async () => { - const hasSmtpConfig = Boolean(process.env.SMTP_HOST?.trim() || process.env.SMTP_FROM?.trim()); - if (!hasSmtpConfig) { - return buildSetupCheck( - meta, - 'WARNING', - 'SMTP email is not configured; in-app notifications can still work.' - ); - } - const provider = new EmailNotificationProvider(); - await provider.validate(); - return buildSetupCheck(meta, 'PASS', 'SMTP email configuration is valid.'); - }); +export async function checkEmailConfig(organizationId?: string | null): Promise { + const meta: CheckMeta = { + id: 'EMAIL_CONFIG', + area: 'Email', + name: 'Email configuration', + requiredFor: ['optional notifications'], + purpose: 'Confirm optional email readiness.', + suggestedFix: 'Fix SMTP provider settings.' + }; + return runSafeCheck(meta, async () => { + if (!organizationId) { + return buildSetupCheck( + meta, + 'WARNING', + 'No organization context available for email verification yet.' + ); + } + const config = await getEmailConfigurationForUse(organizationId, 'smtp'); + if (!config) { + return buildSetupCheck( + meta, + 'WARNING', + 'Email delivery is not configured for this organization; in-app notifications can still work.' + ); + } + const provider = new SmtpEmailProvider(); + const result = await provider.testConnection(config); + if (!result.ok) { + return buildSetupCheck( + meta, + 'FAIL', + result.detail + ? `Email configuration exists but SMTP connection failed: ${result.detail}` + : 'Email configuration exists but SMTP connection failed.' + ); + } + return buildSetupCheck(meta, 'PASS', 'Organization email configuration is valid.'); + }); } export async function getSetupReadinessReport(input: { @@ -419,7 +436,7 @@ export async function getSetupReadinessReport(input: { checkUploadDirectory(), checkPdfTemplateAsset(input.organizationId), checkPdfMappingCoverage(input.organizationId), - checkEmailConfig() + checkEmailConfig(input.organizationId) ]); return buildSetupReport([...checks, ...pluginChecks], 'Setup readiness checks completed.'); diff --git a/src/features/setup/server/verification.service.ts b/src/features/setup/server/verification.service.ts index 7557c95..f5e3f27 100644 --- a/src/features/setup/server/verification.service.ts +++ b/src/features/setup/server/verification.service.ts @@ -422,7 +422,7 @@ export async function getSetupVerificationReport( ) ); - checks.push(await checkEmailConfig()); + checks.push(await checkEmailConfig(organizationId)); checks.push( buildSetupCheck(