From 9d4a8d2a7759ca855163f9707bc780a574e3675b Mon Sep 17 00:00:00 2001 From: Ediminator <211899731+Ediminator@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:07:08 +0100 Subject: [PATCH 1/5] feat(proposals): implement email templates, outbox delivery, and send/revoke actions (TASK-0060) Signed-off-by: Ediminator <211899731+Ediminator@users.noreply.github.com> --- drizzle/0021_motionless_cerebro.sql | 6 + drizzle/meta/0021_snapshot.json | 5764 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/de.json | 40 + messages/en.json | 40 + src/server/db/schema/email-outbox.ts | 15 +- src/server/email/outbox-processor.ts | 205 +- src/server/email/outbox.ts | 1 + src/server/email/processor.ts | 1 + src/server/email/templates.ts | 6 + .../templates/proposal-accepted-client.ts | 215 + .../templates/proposal-accepted-owner.ts | 161 + src/server/email/templates/proposal-sent.ts | 183 + src/server/email/templates/proposal.ts | 142 + src/server/proposals/actions.ts | 193 + src/server/proposals/repo.ts | 4 + src/server/proposals/schema.ts | 57 + src/server/proposals/service.ts | 503 ++ .../integration/proposal-send-action.test.ts | 1238 ++++ tests/unit/proposal-email-templates.test.ts | 330 + tests/unit/proposal-export-service.test.ts | 262 + .../unit/proposal-schemas-validation.test.ts | 165 + tests/unit/proposal-token.test.ts | 59 + 23 files changed, 9591 insertions(+), 6 deletions(-) create mode 100644 drizzle/0021_motionless_cerebro.sql create mode 100644 drizzle/meta/0021_snapshot.json create mode 100644 src/server/email/processor.ts create mode 100644 src/server/email/templates/proposal-accepted-client.ts create mode 100644 src/server/email/templates/proposal-accepted-owner.ts create mode 100644 src/server/email/templates/proposal-sent.ts create mode 100644 src/server/email/templates/proposal.ts create mode 100644 src/server/proposals/actions.ts create mode 100644 src/server/proposals/service.ts create mode 100644 tests/integration/proposal-send-action.test.ts create mode 100644 tests/unit/proposal-email-templates.test.ts create mode 100644 tests/unit/proposal-export-service.test.ts create mode 100644 tests/unit/proposal-schemas-validation.test.ts create mode 100644 tests/unit/proposal-token.test.ts diff --git a/drizzle/0021_motionless_cerebro.sql b/drizzle/0021_motionless_cerebro.sql new file mode 100644 index 0000000..2da7f9e --- /dev/null +++ b/drizzle/0021_motionless_cerebro.sql @@ -0,0 +1,6 @@ +ALTER TABLE "email_outbox" DROP CONSTRAINT "email_outbox_kind_check";--> statement-breakpoint +ALTER TABLE "email_outbox" ADD COLUMN "proposal_id" uuid;--> statement-breakpoint +ALTER TABLE "email_outbox" ADD COLUMN "payload" jsonb;--> statement-breakpoint +ALTER TABLE "email_outbox" ADD CONSTRAINT "email_outbox_proposal_id_proposals_id_fk" FOREIGN KEY ("proposal_id") REFERENCES "public"."proposals"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "email_outbox_proposal_id_idx" ON "email_outbox" USING btree ("proposal_id");--> statement-breakpoint +ALTER TABLE "email_outbox" ADD CONSTRAINT "email_outbox_kind_check" CHECK (kind IN ('lead_auto_reply', 'appointment_confirmation', 'appointment_cancellation', 'proposal_sent', 'proposal_accepted_client', 'proposal_accepted_owner')); \ No newline at end of file diff --git a/drizzle/meta/0021_snapshot.json b/drizzle/meta/0021_snapshot.json new file mode 100644 index 0000000..f2dab6b --- /dev/null +++ b/drizzle/meta/0021_snapshot.json @@ -0,0 +1,5764 @@ +{ + "id": "b9df6c9d-de8a-4065-ad55-f4c422b639bc", + "prevId": "90f31a9e-3ee8-4a6b-89b4-a7ab670f287b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.studio_settings": { + "name": "studio_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "studio_name": { + "name": "studio_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "default_locale": { + "name": "default_locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'EUR'" + }, + "mfa_required": { + "name": "mfa_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mfa_postponed_until": { + "name": "mfa_postponed_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "forms_pow_hmac_key": { + "name": "forms_pow_hmac_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_number": { + "name": "tax_number", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "vat_id": { + "name": "vat_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "is_kleinunternehmer": { + "name": "is_kleinunternehmer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kleinunternehmer_notice": { + "name": "kleinunternehmer_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_tax_rate_basis_points": { + "name": "default_tax_rate_basis_points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1900 + }, + "prices_include_tax": { + "name": "prices_include_tax", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "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": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_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": { + "api_keys_user_id_user_id_fk": { + "name": "api_keys_user_id_user_id_fk", + "tableFrom": "api_keys", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_credential": { + "name": "passkey_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "transports": { + "name": "transports", + "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()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "passkey_credential_user_id_user_id_fk": { + "name": "passkey_credential_user_id_user_id_fk", + "tableFrom": "passkey_credential", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_credential_id_unique": { + "name": "passkey_credential_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limits": { + "name": "rate_limits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limits_key_unique": { + "name": "rate_limits_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recovery_code": { + "name": "recovery_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "salt": { + "name": "salt", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_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()" + } + }, + "indexes": {}, + "foreignKeys": { + "recovery_code_user_id_user_id_fk": { + "name": "recovery_code_user_id_user_id_fk", + "tableFrom": "recovery_code", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "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()" + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_reauthenticated_at": { + "name": "last_reauthenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.totp_credential": { + "name": "totp_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "secret_encrypted": { + "name": "secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_used_step": { + "name": "last_used_step", + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "totp_credential_user_id_user_id_fk": { + "name": "totp_credential_user_id_user_id_fk", + "tableFrom": "totp_credential", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "totp_credential_user_id_unique": { + "name": "totp_credential_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'owner'" + }, + "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": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_type": { + "name": "actor_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_events_occurred_at_idx": { + "name": "audit_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_type_idx": { + "name": "audit_events_actor_type_idx", + "columns": [ + { + "expression": "actor_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_action_idx": { + "name": "audit_events_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "audit_events_actor_type_check": { + "name": "audit_events_actor_type_check", + "value": "actor_type IN ('owner', 'client', 'system', 'agent', 'token')" + }, + "audit_events_outcome_check": { + "name": "audit_events_outcome_check", + "value": "outcome IN ('success', 'failure', 'denied')" + } + }, + "isRLSEnabled": false + }, + "public.client_addresses": { + "name": "client_addresses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "line1": { + "name": "line1", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "line2": { + "name": "line2", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "char(2)", + "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": { + "client_addresses_client_id_idx": { + "name": "client_addresses_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_addresses_client_id_clients_id_fk": { + "name": "client_addresses_client_id_clients_id_fk", + "tableFrom": "client_addresses", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_addresses_client_id_type_unique": { + "name": "client_addresses_client_id_type_unique", + "nullsNotDistinct": false, + "columns": ["client_id", "type"] + } + }, + "policies": {}, + "checkConstraints": { + "client_addresses_type_check": { + "name": "client_addresses_type_check", + "value": "type IN ('postal', 'billing')" + } + }, + "isRLSEnabled": false + }, + "public.client_contacts": { + "name": "client_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(254)", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "varchar(254)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "client_contacts_client_id_idx": { + "name": "client_contacts_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "client_contacts_email_normalized_idx": { + "name": "client_contacts_email_normalized_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "client_contacts_client_id_primary_idx": { + "name": "client_contacts_client_id_primary_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_primary = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_contacts_client_id_clients_id_fk": { + "name": "client_contacts_client_id_clients_id_fk", + "tableFrom": "client_contacts", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "client_contacts_name_check": { + "name": "client_contacts_name_check", + "value": "(given_name IS NOT NULL AND length(trim(given_name)) > 0) OR (family_name IS NOT NULL AND length(trim(family_name)) > 0)" + } + }, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "preferred_locale": { + "name": "preferred_locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "google_contacts_sync_enabled": { + "name": "google_contacts_sync_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_activity_at": { + "name": "last_activity_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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "clients_kind_check": { + "name": "clients_kind_check", + "value": "kind IN ('person', 'company')" + }, + "clients_preferred_locale_check": { + "name": "clients_preferred_locale_check", + "value": "preferred_locale IN ('en', 'de')" + } + }, + "isRLSEnabled": false + }, + "public.client_tags": { + "name": "client_tags", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_tags_tag_id_idx": { + "name": "client_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_tags_client_id_clients_id_fk": { + "name": "client_tags_client_id_clients_id_fk", + "tableFrom": "client_tags", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_tags_tag_id_tags_id_fk": { + "name": "client_tags_tag_id_tags_id_fk", + "tableFrom": "client_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "client_tags_client_id_tag_id_pk": { + "name": "client_tags_client_id_tag_id_pk", + "columns": ["client_id", "tag_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "name_normalized": { + "name": "name_normalized", + "type": "varchar(50)", + "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": { + "tags_name_normalized_unique": { + "name": "tags_name_normalized_unique", + "nullsNotDistinct": false, + "columns": ["name_normalized"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_notes": { + "name": "client_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "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": { + "client_notes_client_id_idx": { + "name": "client_notes_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_notes_client_id_clients_id_fk": { + "name": "client_notes_client_id_clients_id_fk", + "tableFrom": "client_notes", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "client_notes_body_len_check": { + "name": "client_notes_body_len_check", + "value": "char_length(body) >= 1 AND char_length(body) <= 5000" + } + }, + "isRLSEnabled": false + }, + "public.client_timeline_events": { + "name": "client_timeline_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "subject_id": { + "name": "subject_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_timeline_events_client_occurred_idx": { + "name": "client_timeline_events_client_occurred_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_timeline_events_client_id_clients_id_fk": { + "name": "client_timeline_events_client_id_clients_id_fk", + "tableFrom": "client_timeline_events", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "client_timeline_events_actor_type_check": { + "name": "client_timeline_events_actor_type_check", + "value": "actor_type IN ('owner', 'client', 'system', 'agent', 'token')" + } + }, + "isRLSEnabled": false + }, + "public.consent_records": { + "name": "consent_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "effective_on": { + "name": "effective_on", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "evidence_note": { + "name": "evidence_note", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "recorded_by_actor_type": { + "name": "recorded_by_actor_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "recorded_by_actor_id": { + "name": "recorded_by_actor_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "consent_records_contact_purpose_idx": { + "name": "consent_records_contact_purpose_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_on", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "consent_records_contact_id_client_contacts_id_fk": { + "name": "consent_records_contact_id_client_contacts_id_fk", + "tableFrom": "consent_records", + "tableTo": "client_contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "consent_records_purpose_check": { + "name": "consent_records_purpose_check", + "value": "purpose IN ('marketing', 'image_usage')" + }, + "consent_records_status_check": { + "name": "consent_records_status_check", + "value": "status IN ('granted', 'withdrawn')" + }, + "consent_records_method_check": { + "name": "consent_records_method_check", + "value": "method IN ('in_person', 'paper_form', 'email', 'phone', 'other')" + }, + "consent_records_actor_type_check": { + "name": "consent_records_actor_type_check", + "value": "recorded_by_actor_type IN ('owner', 'client', 'system', 'agent', 'token')" + } + }, + "isRLSEnabled": false + }, + "public.client_legal_holds": { + "name": "client_legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "until": { + "name": "until", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "set_by_actor_type": { + "name": "set_by_actor_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "set_by_actor_id": { + "name": "set_by_actor_id", + "type": "varchar(100)", + "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": { + "client_legal_holds_client_id_idx": { + "name": "client_legal_holds_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_legal_holds_client_id_clients_id_fk": { + "name": "client_legal_holds_client_id_clients_id_fk", + "tableFrom": "client_legal_holds", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_legal_holds_client_id_unique": { + "name": "client_legal_holds_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": { + "client_legal_holds_reason_check": { + "name": "client_legal_holds_reason_check", + "value": "reason IN ('dispute', 'statutory', 'other')" + }, + "client_legal_holds_actor_type_check": { + "name": "client_legal_holds_actor_type_check", + "value": "set_by_actor_type IN ('owner', 'client', 'system', 'agent', 'token')" + } + }, + "isRLSEnabled": false + }, + "public.google_oauth_tokens": { + "name": "google_oauth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'google'" + }, + "encrypted_refresh_token": { + "name": "encrypted_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calendar_sync_enabled": { + "name": "calendar_sync_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "selected_calendar_id": { + "name": "selected_calendar_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "default": "'primary'" + }, + "last_calendar_sync_at": { + "name": "last_calendar_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_calendar_sync_error": { + "name": "last_calendar_sync_error", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "google_oauth_tokens_provider_unique": { + "name": "google_oauth_tokens_provider_unique", + "nullsNotDistinct": false, + "columns": ["provider"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.google_contacts_links": { + "name": "google_contacts_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "google_resource_name": { + "name": "google_resource_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_etag": { + "name": "google_etag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "google_contacts_links_contact_id_client_contacts_id_fk": { + "name": "google_contacts_links_contact_id_client_contacts_id_fk", + "tableFrom": "google_contacts_links", + "tableTo": "client_contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "google_contacts_links_contact_id_unique": { + "name": "google_contacts_links_contact_id_unique", + "nullsNotDistinct": false, + "columns": ["contact_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.google_contacts_outbox": { + "name": "google_contacts_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_error": { + "name": "last_error", + "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": { + "google_contacts_outbox_status_next_attempt_idx": { + "name": "google_contacts_outbox_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "google_contacts_outbox_contact_id_client_contacts_id_fk": { + "name": "google_contacts_outbox_contact_id_client_contacts_id_fk", + "tableFrom": "google_contacts_outbox", + "tableTo": "client_contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "google_contacts_outbox_action_check": { + "name": "google_contacts_outbox_action_check", + "value": "action IN ('UPSERT', 'DELETE')" + }, + "google_contacts_outbox_status_check": { + "name": "google_contacts_outbox_status_check", + "value": "status IN ('PENDING', 'PROCESSING', 'FAILED')" + } + }, + "isRLSEnabled": false + }, + "public.inquiry_form_notice_versions": { + "name": "inquiry_form_notice_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "form_id": { + "name": "form_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acknowledgement_text_en": { + "name": "acknowledgement_text_en", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "acknowledgement_text_de": { + "name": "acknowledgement_text_de", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "privacy_policy_url": { + "name": "privacy_policy_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "inquiry_form_notice_versions_form_id_idx": { + "name": "inquiry_form_notice_versions_form_id_idx", + "columns": [ + { + "expression": "form_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inquiry_form_notice_versions_form_id_inquiry_forms_id_fk": { + "name": "inquiry_form_notice_versions_form_id_inquiry_forms_id_fk", + "tableFrom": "inquiry_form_notice_versions", + "tableTo": "inquiry_forms", + "columnsFrom": ["form_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "inquiry_form_notice_versions_form_id_version_unique": { + "name": "inquiry_form_notice_versions_form_id_version_unique", + "nullsNotDistinct": false, + "columns": ["form_id", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inquiry_forms": { + "name": "inquiry_forms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "default_locale": { + "name": "default_locale", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "event_types": { + "name": "event_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "current_notice_version_id": { + "name": "current_notice_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auto_reply_enabled": { + "name": "auto_reply_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "accept_unverified": { + "name": "accept_unverified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "success_redirect_url": { + "name": "success_redirect_url", + "type": "varchar(2048)", + "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": { + "inquiry_forms_current_notice_version_id_inquiry_form_notice_versions_id_fk": { + "name": "inquiry_forms_current_notice_version_id_inquiry_form_notice_versions_id_fk", + "tableFrom": "inquiry_forms", + "tableTo": "inquiry_form_notice_versions", + "columnsFrom": ["current_notice_version_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "inquiry_forms_slug_unique": { + "name": "inquiry_forms_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": { + "inquiry_forms_status_check": { + "name": "inquiry_forms_status_check", + "value": "status IN ('draft', 'published', 'archived')" + }, + "inquiry_forms_default_locale_check": { + "name": "inquiry_forms_default_locale_check", + "value": "default_locale IN ('en', 'de')" + } + }, + "isRLSEnabled": false + }, + "public.inquiry_pow_replays": { + "name": "inquiry_pow_replays", + "schema": "", + "columns": { + "signature_hash": { + "name": "signature_hash", + "type": "char(64)", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "form_id": { + "name": "form_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notice_version_id": { + "name": "notice_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notice_acknowledged_at": { + "name": "notice_acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "verification": { + "name": "verification", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "delivery_mode": { + "name": "delivery_mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "event_type_key": { + "name": "event_type_key", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "event_date": { + "name": "event_date", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "event_location": { + "name": "event_location", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_reply_status": { + "name": "auto_reply_status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'not_applicable'" + }, + "converted_at": { + "name": "converted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_activity_at": { + "name": "last_activity_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()" + } + }, + "indexes": { + "leads_status_received_at_idx": { + "name": "leads_status_received_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_email_normalized_idx": { + "name": "leads_email_normalized_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_client_id_idx": { + "name": "leads_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_last_activity_at_idx": { + "name": "leads_last_activity_at_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_client_id_clients_id_fk": { + "name": "leads_client_id_clients_id_fk", + "tableFrom": "leads", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leads_form_id_inquiry_forms_id_fk": { + "name": "leads_form_id_inquiry_forms_id_fk", + "tableFrom": "leads", + "tableTo": "inquiry_forms", + "columnsFrom": ["form_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "leads_notice_version_id_inquiry_form_notice_versions_id_fk": { + "name": "leads_notice_version_id_inquiry_form_notice_versions_id_fk", + "tableFrom": "leads", + "tableTo": "inquiry_form_notice_versions", + "columnsFrom": ["notice_version_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "leads_status_check": { + "name": "leads_status_check", + "value": "status IN ('new', 'contacted', 'converted', 'archived', 'spam')" + }, + "leads_verification_check": { + "name": "leads_verification_check", + "value": "verification IN ('verified', 'unverified')" + }, + "leads_delivery_mode_check": { + "name": "leads_delivery_mode_check", + "value": "delivery_mode IN ('hosted', 'embed', 'script_free')" + }, + "leads_locale_check": { + "name": "leads_locale_check", + "value": "locale IN ('en', 'de')" + }, + "leads_auto_reply_status_check": { + "name": "leads_auto_reply_status_check", + "value": "auto_reply_status IN ('not_applicable', 'queued', 'sent', 'failed', 'suppressed', 'disabled', 'pending')" + }, + "leads_converted_client_id_check": { + "name": "leads_converted_client_id_check", + "value": "(status = 'converted') = (client_id IS NOT NULL)" + }, + "leads_converted_or_name_email_check": { + "name": "leads_converted_or_name_email_check", + "value": "status = 'converted' OR (given_name IS NOT NULL AND email IS NOT NULL)" + }, + "leads_message_length_check": { + "name": "leads_message_length_check", + "value": "message IS NULL OR length(message) <= 2000" + } + }, + "isRLSEnabled": false + }, + "public.email_outbox": { + "name": "email_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "proposal_id": { + "name": "proposal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recipient": { + "name": "recipient", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_error": { + "name": "last_error", + "type": "varchar(500)", + "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": { + "email_outbox_status_next_attempt_idx": { + "name": "email_outbox_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_outbox_appointment_id_idx": { + "name": "email_outbox_appointment_id_idx", + "columns": [ + { + "expression": "appointment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_outbox_proposal_id_idx": { + "name": "email_outbox_proposal_id_idx", + "columns": [ + { + "expression": "proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_outbox_lead_id_leads_id_fk": { + "name": "email_outbox_lead_id_leads_id_fk", + "tableFrom": "email_outbox", + "tableTo": "leads", + "columnsFrom": ["lead_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_outbox_appointment_id_appointments_id_fk": { + "name": "email_outbox_appointment_id_appointments_id_fk", + "tableFrom": "email_outbox", + "tableTo": "appointments", + "columnsFrom": ["appointment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_outbox_proposal_id_proposals_id_fk": { + "name": "email_outbox_proposal_id_proposals_id_fk", + "tableFrom": "email_outbox", + "tableTo": "proposals", + "columnsFrom": ["proposal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_outbox_kind_check": { + "name": "email_outbox_kind_check", + "value": "kind IN ('lead_auto_reply', 'appointment_confirmation', 'appointment_cancellation', 'proposal_sent', 'proposal_accepted_client', 'proposal_accepted_owner')" + }, + "email_outbox_status_check": { + "name": "email_outbox_status_check", + "value": "status IN ('PENDING', 'PROCESSING', 'FAILED')" + } + }, + "isRLSEnabled": false + }, + "public.pipeline_stages": { + "name": "pipeline_stages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'neutral'" + }, + "is_system": { + "name": "is_system", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pipeline_stages_key_unique": { + "name": "pipeline_stages_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + }, + "pipeline_stages_position_unique": { + "name": "pipeline_stages_position_unique", + "nullsNotDistinct": false, + "columns": ["position"] + } + }, + "policies": {}, + "checkConstraints": { + "pipeline_stages_category_check": { + "name": "pipeline_stages_category_check", + "value": "category IN ('inquiry', 'booked', 'in_progress', 'review', 'completed', 'archived')" + } + }, + "isRLSEnabled": false + }, + "public.project_participants": { + "name": "project_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "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": { + "project_participants_project_id_idx": { + "name": "project_participants_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_participants_contact_id_idx": { + "name": "project_participants_contact_id_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_participants_project_id_projects_id_fk": { + "name": "project_participants_project_id_projects_id_fk", + "tableFrom": "project_participants", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_participants_contact_id_client_contacts_id_fk": { + "name": "project_participants_contact_id_client_contacts_id_fk", + "tableFrom": "project_participants", + "tableTo": "client_contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_participants_contact_or_name_check": { + "name": "project_participants_contact_or_name_check", + "value": "(contact_id IS NOT NULL) OR (name IS NOT NULL AND length(trim(name)) > 0)" + } + }, + "isRLSEnabled": false + }, + "public.project_tasks": { + "name": "project_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": { + "project_tasks_project_id_idx": { + "name": "project_tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_tasks_stage_id_idx": { + "name": "project_tasks_stage_id_idx", + "columns": [ + { + "expression": "stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_tasks_project_id_projects_id_fk": { + "name": "project_tasks_project_id_projects_id_fk", + "tableFrom": "project_tasks", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_tasks_stage_id_pipeline_stages_id_fk": { + "name": "project_tasks_stage_id_pipeline_stages_id_fk", + "tableFrom": "project_tasks", + "tableTo": "pipeline_stages", + "columnsFrom": ["stage_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "project_type_key": { + "name": "project_type_key", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "lead_id": { + "name": "lead_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "primary_shoot_starts_at": { + "name": "primary_shoot_starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "primary_shoot_timezone": { + "name": "primary_shoot_timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "char(3)", + "primaryKey": false, + "notNull": true, + "default": "'EUR'" + }, + "price_minor": { + "name": "price_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_activity_at": { + "name": "last_activity_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()" + } + }, + "indexes": { + "projects_client_id_idx": { + "name": "projects_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_stage_id_idx": { + "name": "projects_stage_id_idx", + "columns": [ + { + "expression": "stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_primary_shoot_starts_at_idx": { + "name": "projects_primary_shoot_starts_at_idx", + "columns": [ + { + "expression": "primary_shoot_starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_last_activity_at_idx": { + "name": "projects_last_activity_at_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_client_id_clients_id_fk": { + "name": "projects_client_id_clients_id_fk", + "tableFrom": "projects", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_stage_id_pipeline_stages_id_fk": { + "name": "projects_stage_id_pipeline_stages_id_fk", + "tableFrom": "projects", + "tableTo": "pipeline_stages", + "columnsFrom": ["stage_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "projects_lead_id_leads_id_fk": { + "name": "projects_lead_id_leads_id_fk", + "tableFrom": "projects", + "tableTo": "leads", + "columnsFrom": ["lead_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shoot_locations": { + "name": "shoot_locations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "shoot_id": { + "name": "shoot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "address_line1": { + "name": "address_line1", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "address_line2": { + "name": "address_line2", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "char(2)", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": { + "shoot_locations_shoot_id_idx": { + "name": "shoot_locations_shoot_id_idx", + "columns": [ + { + "expression": "shoot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shoot_locations_shoot_id_shoots_id_fk": { + "name": "shoot_locations_shoot_id_shoots_id_fk", + "tableFrom": "shoot_locations", + "tableTo": "shoots", + "columnsFrom": ["shoot_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shoot_timeline_items": { + "name": "shoot_timeline_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "shoot_id": { + "name": "shoot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": { + "shoot_timeline_items_shoot_id_idx": { + "name": "shoot_timeline_items_shoot_id_idx", + "columns": [ + { + "expression": "shoot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shoot_timeline_items_starts_at_idx": { + "name": "shoot_timeline_items_starts_at_idx", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shoot_timeline_items_shoot_id_shoots_id_fk": { + "name": "shoot_timeline_items_shoot_id_shoots_id_fk", + "tableFrom": "shoot_timeline_items", + "tableTo": "shoots", + "columnsFrom": ["shoot_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shoot_timeline_items_location_id_shoot_locations_id_fk": { + "name": "shoot_timeline_items_location_id_shoot_locations_id_fk", + "tableFrom": "shoot_timeline_items", + "tableTo": "shoot_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shoots": { + "name": "shoots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "'Shoot'" + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'Europe/Berlin'" + }, + "notes": { + "name": "notes", + "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": { + "shoots_project_id_idx": { + "name": "shoots_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shoots_starts_at_idx": { + "name": "shoots_starts_at_idx", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shoots_project_id_projects_id_fk": { + "name": "shoots_project_id_projects_id_fk", + "tableFrom": "shoots", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stage_task_templates": { + "name": "stage_task_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_type_key": { + "name": "project_type_key", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": { + "stage_task_templates_stage_id_idx": { + "name": "stage_task_templates_stage_id_idx", + "columns": [ + { + "expression": "stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stage_task_templates_stage_id_pipeline_stages_id_fk": { + "name": "stage_task_templates_stage_id_pipeline_stages_id_fk", + "tableFrom": "stage_task_templates", + "tableTo": "pipeline_stages", + "columnsFrom": ["stage_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "booking_type_id": { + "name": "booking_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'Europe/Berlin'" + }, + "attendee_name": { + "name": "attendee_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "attendee_email": { + "name": "attendee_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "attendee_phone": { + "name": "attendee_phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "attendee_notes": { + "name": "attendee_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_type": { + "name": "location_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'google_meet'" + }, + "location_details": { + "name": "location_details", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "meet_url": { + "name": "meet_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "cancellation_reason": { + "name": "cancellation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_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": { + "appointments_starts_at_idx": { + "name": "appointments_starts_at_idx", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "appointments_client_id_idx": { + "name": "appointments_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "appointments_project_id_idx": { + "name": "appointments_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "appointments_status_idx": { + "name": "appointments_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "appointments_booking_type_id_booking_types_id_fk": { + "name": "appointments_booking_type_id_booking_types_id_fk", + "tableFrom": "appointments", + "tableTo": "booking_types", + "columnsFrom": ["booking_type_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_client_id_clients_id_fk": { + "name": "appointments_client_id_clients_id_fk", + "tableFrom": "appointments", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_project_id_projects_id_fk": { + "name": "appointments_project_id_projects_id_fk", + "tableFrom": "appointments", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_lead_id_leads_id_fk": { + "name": "appointments_lead_id_leads_id_fk", + "tableFrom": "appointments", + "tableTo": "leads", + "columnsFrom": ["lead_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "appointments_location_type_check": { + "name": "appointments_location_type_check", + "value": "location_type IN ('in_person', 'phone', 'google_meet')" + }, + "appointments_status_check": { + "name": "appointments_status_check", + "value": "status IN ('scheduled', 'completed', 'cancelled', 'no_show')" + } + }, + "isRLSEnabled": false + }, + "public.availability_overrides": { + "name": "availability_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "date": { + "name": "date", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "end_time": { + "name": "end_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "is_unavailable": { + "name": "is_unavailable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reason": { + "name": "reason", + "type": "varchar(150)", + "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": { + "availability_overrides_date_start_time_unique": { + "name": "availability_overrides_date_start_time_unique", + "nullsNotDistinct": false, + "columns": ["date", "start_time"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.availability_schedules": { + "name": "availability_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "availability_schedules_day_of_week_check": { + "name": "availability_schedules_day_of_week_check", + "value": "day_of_week >= 0 AND day_of_week <= 6" + } + }, + "isRLSEnabled": false + }, + "public.blocked_time": { + "name": "blocked_time", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "'Blocked Time'" + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "all_day": { + "name": "all_day", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "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": { + "blocked_time_starts_at_idx": { + "name": "blocked_time_starts_at_idx", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.booking_types": { + "name": "booking_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "buffer_before_minutes": { + "name": "buffer_before_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "buffer_after_minutes": { + "name": "buffer_after_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "min_notice_hours": { + "name": "min_notice_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "max_future_days": { + "name": "max_future_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "location_type": { + "name": "location_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'google_meet'" + }, + "location_details": { + "name": "location_details", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'indigo'" + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "booking_types_slug_unique": { + "name": "booking_types_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": { + "booking_types_location_type_check": { + "name": "booking_types_location_type_check", + "value": "location_type IN ('in_person', 'phone', 'google_meet')" + } + }, + "isRLSEnabled": false + }, + "public.caldav_accounts": { + "name": "caldav_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "server_url": { + "name": "server_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calendar_home_url": { + "name": "calendar_home_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "selected_calendar_url": { + "name": "selected_calendar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "selected_calendar_name": { + "name": "selected_calendar_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "ctag": { + "name": "ctag", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "sync_token": { + "name": "sync_token", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_feeds": { + "name": "calendar_feeds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'Main Studio Feed'" + }, + "summary_level": { + "name": "summary_level", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_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": { + "calendar_feeds_token_hash_unique": { + "name": "calendar_feeds_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "calendar_feeds_summary_level_check": { + "name": "calendar_feeds_summary_level_check", + "value": "summary_level IN ('minimal', 'standard', 'full')" + } + }, + "isRLSEnabled": false + }, + "public.calendar_sync_links": { + "name": "calendar_sync_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_event_id": { + "name": "external_event_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "etag": { + "name": "etag", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "synced_at": { + "name": "synced_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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "calendar_sync_links_provider_entity_unique": { + "name": "calendar_sync_links_provider_entity_unique", + "nullsNotDistinct": false, + "columns": ["provider", "entity_type", "entity_id"] + } + }, + "policies": {}, + "checkConstraints": { + "calendar_sync_links_provider_check": { + "name": "calendar_sync_links_provider_check", + "value": "provider IN ('google', 'caldav')" + }, + "calendar_sync_links_entity_type_check": { + "name": "calendar_sync_links_entity_type_check", + "value": "entity_type IN ('shoot', 'appointment', 'blocked_time')" + } + }, + "isRLSEnabled": false + }, + "public.calendar_sync_outbox": { + "name": "calendar_sync_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_error": { + "name": "last_error", + "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": { + "calendar_sync_outbox_status_next_attempt_idx": { + "name": "calendar_sync_outbox_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "calendar_sync_outbox_entity_type_check": { + "name": "calendar_sync_outbox_entity_type_check", + "value": "entity_type IN ('shoot', 'appointment', 'blocked_time')" + }, + "calendar_sync_outbox_action_check": { + "name": "calendar_sync_outbox_action_check", + "value": "action IN ('create', 'update', 'delete')" + }, + "calendar_sync_outbox_status_check": { + "name": "calendar_sync_outbox_status_check", + "value": "status IN ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED')" + } + }, + "isRLSEnabled": false + }, + "public.external_calendar_busy_intervals": { + "name": "external_calendar_busy_intervals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(500)", + "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": { + "external_calendar_busy_intervals_starts_at_idx": { + "name": "external_calendar_busy_intervals_starts_at_idx", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "external_calendar_busy_intervals_provider_check": { + "name": "external_calendar_busy_intervals_provider_check", + "value": "provider IN ('google', 'caldav')" + } + }, + "isRLSEnabled": false + }, + "public.catalogue_items": { + "name": "catalogue_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'service'" + }, + "unit_price_minor": { + "name": "unit_price_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "char(3)", + "primaryKey": false, + "notNull": true, + "default": "'EUR'" + }, + "tax_rate_basis_points": { + "name": "tax_rate_basis_points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1900 + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "catalogue_items_is_active_idx": { + "name": "catalogue_items_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "catalogue_items_category_idx": { + "name": "catalogue_items_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "catalogue_items_position_idx": { + "name": "catalogue_items_position_idx", + "columns": [ + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "catalogue_items_slug_unique": { + "name": "catalogue_items_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": { + "catalogue_items_category_check": { + "name": "catalogue_items_category_check", + "value": "category IN ('service', 'product', 'travel', 'licensing')" + } + }, + "isRLSEnabled": false + }, + "public.package_included_items": { + "name": "package_included_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "position": { + "name": "position", + "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()" + } + }, + "indexes": { + "package_included_items_package_id_idx": { + "name": "package_included_items_package_id_idx", + "columns": [ + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "package_included_items_position_idx": { + "name": "package_included_items_position_idx", + "columns": [ + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "package_included_items_package_id_packages_id_fk": { + "name": "package_included_items_package_id_packages_id_fk", + "tableFrom": "package_included_items", + "tableTo": "packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.packages": { + "name": "packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_type_key": { + "name": "project_type_key", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "base_price_minor": { + "name": "base_price_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "char(3)", + "primaryKey": false, + "notNull": true, + "default": "'EUR'" + }, + "tax_rate_basis_points": { + "name": "tax_rate_basis_points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1900 + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "packages_is_active_idx": { + "name": "packages_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "packages_position_idx": { + "name": "packages_position_idx", + "columns": [ + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "packages_project_type_key_idx": { + "name": "packages_project_type_key_idx", + "columns": [ + { + "expression": "project_type_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "packages_slug_unique": { + "name": "packages_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proposal_line_items": { + "name": "proposal_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "proposal_id": { + "name": "proposal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "catalogue_item_id": { + "name": "catalogue_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "item_type": { + "name": "item_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'addon'" + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_price_minor": { + "name": "unit_price_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "min_quantity": { + "name": "min_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_quantity": { + "name": "max_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "tax_rate_basis_points": { + "name": "tax_rate_basis_points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1900 + }, + "is_optional": { + "name": "is_optional", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_selected": { + "name": "is_selected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "position": { + "name": "position", + "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()" + } + }, + "indexes": { + "proposal_line_items_proposal_position_idx": { + "name": "proposal_line_items_proposal_position_idx", + "columns": [ + { + "expression": "proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proposal_line_items_proposal_id_proposals_id_fk": { + "name": "proposal_line_items_proposal_id_proposals_id_fk", + "tableFrom": "proposal_line_items", + "tableTo": "proposals", + "columnsFrom": ["proposal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proposal_line_items_package_id_packages_id_fk": { + "name": "proposal_line_items_package_id_packages_id_fk", + "tableFrom": "proposal_line_items", + "tableTo": "packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "proposal_line_items_catalogue_item_id_catalogue_items_id_fk": { + "name": "proposal_line_items_catalogue_item_id_catalogue_items_id_fk", + "tableFrom": "proposal_line_items", + "tableTo": "catalogue_items", + "columnsFrom": ["catalogue_item_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proposal_line_items_item_type_check": { + "name": "proposal_line_items_item_type_check", + "value": "item_type IN ('base_package', 'addon', 'custom')" + } + }, + "isRLSEnabled": false + }, + "public.proposals": { + "name": "proposals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "currency": { + "name": "currency", + "type": "char(3)", + "primaryKey": false, + "notNull": true, + "default": "'EUR'" + }, + "subtotal_minor": { + "name": "subtotal_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tax_minor": { + "name": "tax_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_minor": { + "name": "total_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "prices_include_tax": { + "name": "prices_include_tax", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_kleinunternehmer": { + "name": "is_kleinunternehmer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tax_notice": { + "name": "tax_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "token_revoked_at": { + "name": "token_revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "declined_at": { + "name": "declined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decline_reason": { + "name": "decline_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_by_name": { + "name": "accepted_by_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "accepted_by_email": { + "name": "accepted_by_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "acceptance_ip_anonymized": { + "name": "acceptance_ip_anonymized", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "acceptance_user_agent": { + "name": "acceptance_user_agent", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "accepted_options_snapshot": { + "name": "accepted_options_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_notes": { + "name": "internal_notes", + "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": { + "proposals_project_id_idx": { + "name": "proposals_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proposals_client_id_idx": { + "name": "proposals_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proposals_token_hash_idx": { + "name": "proposals_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proposals_status_idx": { + "name": "proposals_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proposals_project_id_projects_id_fk": { + "name": "proposals_project_id_projects_id_fk", + "tableFrom": "proposals", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proposals_client_id_clients_id_fk": { + "name": "proposals_client_id_clients_id_fk", + "tableFrom": "proposals", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "proposals_token_hash_unique": { + "name": "proposals_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "proposals_status_check": { + "name": "proposals_status_check", + "value": "status IN ('draft', 'sent', 'accepted', 'declined', 'expired', 'superseded')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c904719..4e68f44 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1793144441396, "tag": "0020_moaning_sunspot", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1793190483366, + "tag": "0021_motionless_cerebro", + "breakpoints": true } ] } diff --git a/messages/de.json b/messages/de.json index e59e3d8..639f8e4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1904,6 +1904,46 @@ "in_person": "Vor Ort", "phone": "Telefonat" } + }, + "proposal": { + "sent": { + "subject": "Angebot: {title} von {studioName}", + "greeting": "Hallo {clientName},", + "intro": "Sie haben ein neues Angebot von {studioName} erhalten.", + "proposalTitle": "Angebot: {title}", + "totalAmount": "Gesamtbetrag: {amount}", + "validUntil": "Gültig bis: {date}", + "buttonText": "Angebot ansehen", + "linkText": "Öffnen Sie folgenden Link, um Ihr Angebot anzusehen und anzunehmen: {url}", + "contactInfo": "Bei Fragen oder Änderungswünschen wenden Sie sich bitte an {studioName}.", + "footer": "Dies ist eine automatisierte Benachrichtigung." + }, + "acceptedClient": { + "subject": "Bestätigung: Angebot angenommen — {title}", + "greeting": "Hallo {clientName},", + "intro": "Vielen Dank für die Annahme unseres Angebots. Wir freuen uns sehr auf die Zusammenarbeit!", + "proposalTitle": "Angebot: {title}", + "packageTitle": "Angenommenes Paket: {packageName}", + "includedItems": "Enthaltene Leistungen:", + "addonsTitle": "Ausgewählte Zusatzleistungen:", + "noAddons": "Keine", + "totalAmount": "Gesamtbetrag: {amount}", + "nextSteps": "Nächste Schritte: Wir bereiten Ihren Buchungsvertrag vor. Sie erhalten in Kürze eine Einladung zur Prüfung und Unterzeichnung.", + "contactInfo": "Bei Fragen wenden Sie sich bitte an {studioName}.", + "footer": "Dies ist eine automatisierte Benachrichtigung." + }, + "acceptedOwner": { + "subject": "Angebot angenommen: {title}", + "greeting": "Hallo {studioName},", + "intro": "Gute Neuigkeiten! Das Angebot „{title}“ wurde von {clientName} angenommen.", + "proposalTitle": "Angebot: {title}", + "packageTitle": "Paket: {packageName}", + "totalAmount": "Gesamtbetrag: {amount}", + "acceptedBy": "Angenommen von: {acceptedByName} ({acceptedByEmail})", + "buttonText": "Projekt ansehen", + "linkText": "Projekt in Ihrem Studio-Arbeitsbereich ansehen: {url}", + "footer": "Dies ist eine automatisierte Benachrichtigung." + } } }, "calendar": { diff --git a/messages/en.json b/messages/en.json index fb58902..f9b2902 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1904,6 +1904,46 @@ "in_person": "In Person", "phone": "Phone Call" } + }, + "proposal": { + "sent": { + "subject": "Proposal: {title} from {studioName}", + "greeting": "Hello {clientName},", + "intro": "You have received a new proposal from {studioName}.", + "proposalTitle": "Proposal: {title}", + "totalAmount": "Total Quote: {amount}", + "validUntil": "Valid until: {date}", + "buttonText": "View Proposal", + "linkText": "Open this link to view and accept your proposal: {url}", + "contactInfo": "If you have any questions or would like adjustments, please reach out to {studioName}.", + "footer": "This is an automated transactional notification." + }, + "acceptedClient": { + "subject": "Confirmation: Proposal accepted — {title}", + "greeting": "Hello {clientName},", + "intro": "Thank you for accepting our proposal. We are delighted to work with you!", + "proposalTitle": "Proposal: {title}", + "packageTitle": "Accepted Package: {packageName}", + "includedItems": "Included Deliverables:", + "addonsTitle": "Selected Add-ons:", + "noAddons": "None", + "totalAmount": "Total Amount: {amount}", + "nextSteps": "Next Steps: We are preparing your booking contract. You will receive an invitation to review and sign shortly.", + "contactInfo": "If you have any questions in the meantime, please contact {studioName}.", + "footer": "This is an automated transactional notification." + }, + "acceptedOwner": { + "subject": "Proposal Accepted: {title}", + "greeting": "Hello {studioName},", + "intro": "Great news! The proposal \"{title}\" has been accepted by {clientName}.", + "proposalTitle": "Proposal: {title}", + "packageTitle": "Package: {packageName}", + "totalAmount": "Total Amount: {amount}", + "acceptedBy": "Accepted by: {acceptedByName} ({acceptedByEmail})", + "buttonText": "View Project", + "linkText": "View this project in your studio workspace: {url}", + "footer": "This is an automated transactional notification." + } } }, "calendar": { diff --git a/src/server/db/schema/email-outbox.ts b/src/server/db/schema/email-outbox.ts index 25be466..601b077 100644 --- a/src/server/db/schema/email-outbox.ts +++ b/src/server/db/schema/email-outbox.ts @@ -4,6 +4,7 @@ import { varchar, integer, timestamp, + jsonb, index, check, type AnyPgColumn, @@ -11,13 +12,16 @@ import { import { sql } from 'drizzle-orm'; import { generateUuidV7 } from '@/lib/id'; import { leads } from './leads'; - import { appointments } from './calendar'; +import { proposals } from './proposals'; export const EMAIL_OUTBOX_KINDS = [ 'lead_auto_reply', 'appointment_confirmation', 'appointment_cancellation', + 'proposal_sent', + 'proposal_accepted_client', + 'proposal_accepted_owner', ] as const; export type EmailOutboxKind = (typeof EMAIL_OUTBOX_KINDS)[number]; @@ -25,7 +29,7 @@ export const EMAIL_OUTBOX_STATUSES = ['PENDING', 'PROCESSING', 'FAILED'] as cons export type EmailOutboxStatus = (typeof EMAIL_OUTBOX_STATUSES)[number]; /** - * Transactional Email Outbox table (TASK-0030 / TASK-0041 / ADR-0015 §7). + * Transactional Email Outbox table (TASK-0030 / TASK-0041 / TASK-0060 / ADR-0015 §7 / ADR-0018 §5). * Ephemeral queue for transactional email dispatch with zero copied PII. */ export const emailOutbox = pgTable( @@ -39,7 +43,11 @@ export const emailOutbox = pgTable( appointmentId: uuid('appointment_id').references((): AnyPgColumn => appointments.id, { onDelete: 'cascade', }), + proposalId: uuid('proposal_id').references((): AnyPgColumn => proposals.id, { + onDelete: 'cascade', + }), recipient: varchar('recipient', { length: 255 }), + payload: jsonb('payload').$type | null>(), status: varchar('status', { length: 20 }) .notNull() .default('PENDING') @@ -55,11 +63,12 @@ export const emailOutbox = pgTable( (table) => [ check( 'email_outbox_kind_check', - sql`kind IN ('lead_auto_reply', 'appointment_confirmation', 'appointment_cancellation')`, + sql`kind IN ('lead_auto_reply', 'appointment_confirmation', 'appointment_cancellation', 'proposal_sent', 'proposal_accepted_client', 'proposal_accepted_owner')`, ), check('email_outbox_status_check', sql`status IN ('PENDING', 'PROCESSING', 'FAILED')`), index('email_outbox_status_next_attempt_idx').on(table.status, table.nextAttemptAt), index('email_outbox_appointment_id_idx').on(table.appointmentId), + index('email_outbox_proposal_id_idx').on(table.proposalId), ], ); diff --git a/src/server/email/outbox-processor.ts b/src/server/email/outbox-processor.ts index 52efa3d..41fcb4a 100644 --- a/src/server/email/outbox-processor.ts +++ b/src/server/email/outbox-processor.ts @@ -1,6 +1,6 @@ import 'server-only'; import crypto from 'node:crypto'; -import { eq } from 'drizzle-orm'; +import { eq, desc, asc } from 'drizzle-orm'; import type { Transporter } from 'nodemailer'; import { db as defaultDb, type DbClient } from '@/server/db/client'; import { audit } from '@/server/audit'; @@ -23,13 +23,21 @@ import { } from './outbox'; import { renderAutoReplyEmail } from './templates'; import { appointments } from '@/server/db/schema/calendar'; -import { clients } from '@/server/db/schema/clients'; +import { clients, clientContacts } from '@/server/db/schema/clients'; import { leads } from '@/server/db/schema/leads'; +import { proposals } from '@/server/db/schema/proposals'; import { renderAppointmentConfirmationEmail, renderAppointmentCancellationEmail, getAppointmentEmailHeaders, } from './templates/appointment'; +import { + renderProposalSentEmail, + renderProposalAcceptedClientEmail, + renderProposalAcceptedOwnerEmail, + getProposalEmailHeaders, +} from './templates'; +import { getAcceptedProposalForProject } from '@/server/proposals/service'; export interface EmailOutboxBatchOptions { batchSize?: number; @@ -232,7 +240,198 @@ export async function processEmailOutboxBatch( continue; } - // Branch B: Lead auto-reply + // Branch B: Proposal transactional emails (TASK-0060 / AC-1, AC-2, AC-4) + if ( + item.kind === 'proposal_sent' || + item.kind === 'proposal_accepted_client' || + item.kind === 'proposal_accepted_owner' + ) { + if (!item.proposalId) { + await deleteEmailOutboxItem(item.id, dbClient); + suppressedCount += 1; + continue; + } + + const [proposal] = await dbClient + .select() + .from(proposals) + .where(eq(proposals.id, item.proposalId)) + .limit(1); + + if (!proposal) { + await deleteEmailOutboxItem(item.id, dbClient); + suppressedCount += 1; + continue; + } + + const [clientRow] = await dbClient + .select() + .from(clients) + .where(eq(clients.id, proposal.clientId)) + .limit(1); + + let studioName = 'Photo Studio'; + let defaultLocale = 'en'; + try { + const settings = await getStudioSettings(dbClient); + studioName = settings.studio_name; + defaultLocale = settings.default_locale; + } catch { + studioName = 'Photo Studio'; + } + + const clientLocale = clientRow?.preferredLocale ?? defaultLocale; + const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'; + + let recipientEmail: string | null | undefined = item.recipient; + if (!recipientEmail) { + if (item.kind === 'proposal_sent') { + const [contact] = await dbClient + .select({ email: clientContacts.email }) + .from(clientContacts) + .where(eq(clientContacts.clientId, proposal.clientId)) + .orderBy(desc(clientContacts.isPrimary), asc(clientContacts.createdAt)) + .limit(1); + recipientEmail = contact?.email; + } else if (item.kind === 'proposal_accepted_client') { + if (proposal.acceptedByEmail) { + recipientEmail = proposal.acceptedByEmail; + } else { + const [contact] = await dbClient + .select({ email: clientContacts.email }) + .from(clientContacts) + .where(eq(clientContacts.clientId, proposal.clientId)) + .orderBy(desc(clientContacts.isPrimary), asc(clientContacts.createdAt)) + .limit(1); + recipientEmail = contact?.email; + } + } else { + recipientEmail = process.env.SMTP_FROM ?? 'owner@example.org'; + } + } + + if (!recipientEmail) { + await deleteEmailOutboxItem(item.id, dbClient); + suppressedCount += 1; + continue; + } + + let rendered; + if (item.kind === 'proposal_sent') { + const payload = item.payload as { token?: string; tokenUrl?: string } | null | undefined; + const payloadToken = payload?.token; + const payloadTokenUrl = payload?.tokenUrl; + rendered = renderProposalSentEmail({ + proposal: { + id: proposal.id, + title: proposal.title, + totalMinor: proposal.totalMinor, + currency: proposal.currency, + validUntil: proposal.validUntil, + token: payloadToken, + tokenUrl: payloadTokenUrl, + }, + clientName: clientRow?.displayName ?? 'Client', + studioName, + locale: clientLocale, + baseUrl, + }); + } else if (item.kind === 'proposal_accepted_client') { + const summary = await getAcceptedProposalForProject(proposal.projectId, dbClient); + rendered = renderProposalAcceptedClientEmail({ + proposal: { + id: proposal.id, + title: proposal.title, + packageName: summary?.packageName, + includedItems: summary?.includedItems, + selectedAddons: summary?.selectedAddons, + totalMinor: proposal.totalMinor, + currency: proposal.currency, + }, + clientName: proposal.acceptedByName ?? clientRow?.displayName ?? 'Client', + studioName, + locale: clientLocale, + }); + } else { + // proposal_accepted_owner + const summary = await getAcceptedProposalForProject(proposal.projectId, dbClient); + rendered = renderProposalAcceptedOwnerEmail({ + proposal: { + id: proposal.id, + projectId: proposal.projectId, + title: proposal.title, + packageName: summary?.packageName, + totalMinor: proposal.totalMinor, + currency: proposal.currency, + acceptedByName: proposal.acceptedByName, + acceptedByEmail: proposal.acceptedByEmail, + }, + clientName: clientRow?.displayName ?? 'Client', + studioName, + locale: defaultLocale, + baseUrl, + }); + } + + const headers = getProposalEmailHeaders(); + + try { + await sendEmail({ + to: { address: recipientEmail }, + subject: rendered.subject, + text: rendered.text, + html: rendered.html, + headers, + transporter: options.transporter, + }); + + await deleteEmailOutboxItem(item.id, dbClient); + + logger.info( + { + proposalId: proposal.id, + outboxId: item.id, + kind: item.kind, + }, + 'Proposal transactional email sent successfully', + ); + + sentCount += 1; + } catch (err) { + failedCount += 1; + const newAttempts = item.attempts + 1; + const isTerminal = isTerminalRetry(newAttempts); + const nextAttemptAt = calculateNextAttemptDate(newAttempts, options.backoffOptions); + + const rawMsg = err instanceof Error ? err.message : String(err); + const errorClass = err instanceof Error ? err.constructor.name : 'Error'; + const sanitizedError = sanitizeOutboxError(rawMsg, errorClass); + + logger.warn( + { + outboxId: item.id, + proposalId: proposal.id, + attempts: newAttempts, + isTerminal, + errorClass, + }, + 'Proposal transactional email sending failed', + ); + + await updateEmailOutboxRetry( + item.id, + newAttempts, + nextAttemptAt, + sanitizedError, + isTerminal, + dbClient, + ); + } + + continue; + } + + // Branch C: Lead auto-reply if (!item.leadId) { await deleteEmailOutboxItem(item.id, dbClient); suppressedCount += 1; diff --git a/src/server/email/outbox.ts b/src/server/email/outbox.ts index 54a3814..ebe4273 100644 --- a/src/server/email/outbox.ts +++ b/src/server/email/outbox.ts @@ -114,3 +114,4 @@ export async function getFailedEmailOutboxCount(client: DbClient = defaultDb): P } export { enqueueAppointmentEmail } from './templates/appointment'; +export { enqueueProposalEmail, enqueueProposalAcceptedEmails } from './templates/proposal'; diff --git a/src/server/email/processor.ts b/src/server/email/processor.ts new file mode 100644 index 0000000..a326d9c --- /dev/null +++ b/src/server/email/processor.ts @@ -0,0 +1 @@ +export * from './outbox-processor'; diff --git a/src/server/email/templates.ts b/src/server/email/templates.ts index 1857a8c..dd01416 100644 --- a/src/server/email/templates.ts +++ b/src/server/email/templates.ts @@ -9,6 +9,7 @@ export interface RenderAutoReplyParams { export interface RenderedEmail { subject: string; text: string; + html?: string; } /** @@ -61,3 +62,8 @@ export function renderAutoReplyEmail({ return { subject, text }; } + +export * from './templates/proposal'; +export * from './templates/proposal-sent'; +export * from './templates/proposal-accepted-client'; +export * from './templates/proposal-accepted-owner'; diff --git a/src/server/email/templates/proposal-accepted-client.ts b/src/server/email/templates/proposal-accepted-client.ts new file mode 100644 index 0000000..87d0a3e --- /dev/null +++ b/src/server/email/templates/proposal-accepted-client.ts @@ -0,0 +1,215 @@ +import 'server-only'; +import { formatCurrency } from '@/lib/formatters'; +import { sanitizeStudioName, type RenderedEmail } from '@/server/email/templates'; +import enMessages from '@/../messages/en.json'; +import deMessages from '@/../messages/de.json'; + +export interface AcceptedAddonItem { + name: string; + quantity: number; + unitPriceMinor: number; +} + +export interface ProposalAcceptedClientEmailPayload { + id: string; + title: string; + packageName?: string | null; + includedItems?: string[]; + selectedAddons?: AcceptedAddonItem[]; + totalMinor: number; + currency: string; +} + +export interface RenderProposalAcceptedClientParams { + proposal: ProposalAcceptedClientEmailPayload; + clientName?: string | null; + studioName?: string | null; + locale?: string | null; +} + +function sanitizeSingleLine(input?: string | null, fallback = ''): string { + if (!input || typeof input !== 'string') { + return fallback; + } + return input.replaceAll(/[\r\n\x00]+/g, ' ').trim() || fallback; +} + +function escapeHtml(input: string): string { + return input + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +/** + * Renders the localized proposal acceptance confirmation for the client (TASK-0060 / AC-4, AC-6). + */ +export function renderProposalAcceptedClientEmail({ + proposal, + clientName, + studioName, + locale = 'en', +}: RenderProposalAcceptedClientParams): RenderedEmail { + const isGerman = locale?.toLowerCase().startsWith('de'); + const activeLocale = isGerman ? 'de' : 'en'; + const dict = isGerman + ? deMessages.emails.proposal.acceptedClient + : enMessages.emails.proposal.acceptedClient; + + const sanitizedStudio = sanitizeStudioName(studioName); + const sanitizedTitle = sanitizeSingleLine(proposal.title, 'Proposal'); + const sanitizedClient = sanitizeSingleLine(clientName, isGerman ? 'Kunde' : 'Client'); + const packageName = sanitizeSingleLine(proposal.packageName, isGerman ? 'Paket' : 'Package'); + + const formattedAmount = formatCurrency(proposal.totalMinor, proposal.currency, activeLocale); + + const subject = dict.subject + .replace('{title}', sanitizedTitle) + .replaceAll(/[\r\n\x00]+/g, ' ') + .trim(); + + // --- Plain-text template --- + const textLines: string[] = [ + dict.greeting.replace('{clientName}', sanitizedClient), + '', + dict.intro, + '', + dict.proposalTitle.replace('{title}', sanitizedTitle), + dict.packageTitle.replace('{packageName}', packageName), + ]; + + if (proposal.includedItems && proposal.includedItems.length > 0) { + textLines.push(''); + textLines.push(dict.includedItems); + for (const item of proposal.includedItems) { + textLines.push(` - ${sanitizeSingleLine(item)}`); + } + } + + textLines.push(''); + textLines.push(dict.addonsTitle); + if (proposal.selectedAddons && proposal.selectedAddons.length > 0) { + for (const addon of proposal.selectedAddons) { + const addonPrice = formatCurrency( + addon.unitPriceMinor * addon.quantity, + proposal.currency, + activeLocale, + ); + const qtyStr = addon.quantity > 1 ? ` (${String(addon.quantity)}x)` : ''; + textLines.push(` - ${sanitizeSingleLine(addon.name)}${qtyStr}: ${addonPrice}`); + } + } else { + textLines.push(` ${dict.noAddons}`); + } + + textLines.push(''); + textLines.push(dict.totalAmount.replace('{amount}', formattedAmount)); + textLines.push(''); + textLines.push(dict.nextSteps); + textLines.push(''); + textLines.push(dict.contactInfo.replace('{studioName}', sanitizedStudio)); + textLines.push(''); + textLines.push(dict.footer); + + const text = textLines.join('\n'); + + // --- Responsive HTML template --- + const htmlGreeting = escapeHtml(dict.greeting.replace('{clientName}', sanitizedClient)); + const htmlIntro = escapeHtml(dict.intro); + const htmlPackageTitle = escapeHtml(packageName); + const htmlAmount = escapeHtml(formattedAmount); + const htmlNextSteps = escapeHtml(dict.nextSteps); + const htmlContactInfo = escapeHtml(dict.contactInfo.replace('{studioName}', sanitizedStudio)); + const htmlFooter = escapeHtml(dict.footer); + + let includedHtml = ''; + if (proposal.includedItems && proposal.includedItems.length > 0) { + const listItems = proposal.includedItems + .map((item) => `
  • ${escapeHtml(sanitizeSingleLine(item))}
  • `) + .join(''); + includedHtml = ` +
    +
    ${escapeHtml(dict.includedItems)}
    + +
    `; + } + + let addonsHtml = ''; + if (proposal.selectedAddons && proposal.selectedAddons.length > 0) { + const addonRows = proposal.selectedAddons + .map((addon) => { + const price = formatCurrency( + addon.unitPriceMinor * addon.quantity, + proposal.currency, + activeLocale, + ); + const qty = addon.quantity > 1 ? ` (${String(addon.quantity)}x)` : ''; + return ` + + ${escapeHtml(sanitizeSingleLine(addon.name))}${escapeHtml(qty)} + ${escapeHtml(price)} + `; + }) + .join(''); + addonsHtml = ` +
    +
    ${escapeHtml(dict.addonsTitle)}
    + + ${addonRows} +
    +
    `; + } + + const html = ` + + + + + ${escapeHtml(subject)} + + + + + + + + + +
    +

    ${escapeHtml(sanitizedStudio)}

    +

    ${htmlGreeting}

    +

    ${htmlIntro}

    +
    +
    ${escapeHtml(isGerman ? 'Ausgewähltes Paket' : 'Accepted Package')}
    +
    ${htmlPackageTitle}
    + ${includedHtml} + ${addonsHtml} +
    + + + + + +
    ${escapeHtml(isGerman ? 'Gesamtbetrag' : 'Total Amount')}${htmlAmount}
    +
    +
    +
    +

    ${htmlNextSteps}

    +
    +

    ${htmlContactInfo}

    +
    + ${htmlFooter} +
    + +`; + + return { + subject, + text, + html, + }; +} diff --git a/src/server/email/templates/proposal-accepted-owner.ts b/src/server/email/templates/proposal-accepted-owner.ts new file mode 100644 index 0000000..c540e97 --- /dev/null +++ b/src/server/email/templates/proposal-accepted-owner.ts @@ -0,0 +1,161 @@ +import 'server-only'; +import { formatCurrency } from '@/lib/formatters'; +import { sanitizeStudioName, type RenderedEmail } from '@/server/email/templates'; +import enMessages from '@/../messages/en.json'; +import deMessages from '@/../messages/de.json'; + +export interface ProposalAcceptedOwnerEmailPayload { + id: string; + projectId: string; + title: string; + packageName?: string | null; + totalMinor: number; + currency: string; + acceptedByName?: string | null; + acceptedByEmail?: string | null; +} + +export interface RenderProposalAcceptedOwnerParams { + proposal: ProposalAcceptedOwnerEmailPayload; + clientName?: string | null; + studioName?: string | null; + locale?: string | null; + baseUrl?: string | null; +} + +function sanitizeSingleLine(input?: string | null, fallback = ''): string { + if (!input || typeof input !== 'string') { + return fallback; + } + return input.replaceAll(/[\r\n\x00]+/g, ' ').trim() || fallback; +} + +function escapeHtml(input: string): string { + return input + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +/** + * Renders the studio owner notification for proposal acceptance (TASK-0060 / AC-4, AC-6). + */ +export function renderProposalAcceptedOwnerEmail({ + proposal, + clientName, + studioName, + locale = 'en', + baseUrl = '', +}: RenderProposalAcceptedOwnerParams): RenderedEmail { + const isGerman = locale?.toLowerCase().startsWith('de'); + const activeLocale = isGerman ? 'de' : 'en'; + const dict = isGerman + ? deMessages.emails.proposal.acceptedOwner + : enMessages.emails.proposal.acceptedOwner; + + const sanitizedStudio = sanitizeStudioName(studioName); + const sanitizedTitle = sanitizeSingleLine(proposal.title, 'Proposal'); + const sanitizedClient = sanitizeSingleLine(clientName, isGerman ? 'Kunde' : 'Client'); + const packageName = sanitizeSingleLine(proposal.packageName, isGerman ? 'Paket' : 'Package'); + const acceptedByName = sanitizeSingleLine(proposal.acceptedByName, sanitizedClient); + const acceptedByEmail = sanitizeSingleLine(proposal.acceptedByEmail, ''); + + const formattedAmount = formatCurrency(proposal.totalMinor, proposal.currency, activeLocale); + + const safeBaseUrl = (baseUrl ?? '').replaceAll(/\/+$/g, ''); + const projectUrl = safeBaseUrl + ? `${safeBaseUrl}/projects/${proposal.projectId}` + : `/projects/${proposal.projectId}`; + + const subject = dict.subject + .replace('{title}', sanitizedTitle) + .replaceAll(/[\r\n\x00]+/g, ' ') + .trim(); + + // --- Plain-text template --- + const textLines: string[] = [ + dict.greeting.replace('{studioName}', sanitizedStudio), + '', + dict.intro.replace('{title}', sanitizedTitle).replace('{clientName}', sanitizedClient), + '', + dict.proposalTitle.replace('{title}', sanitizedTitle), + dict.packageTitle.replace('{packageName}', packageName), + dict.totalAmount.replace('{amount}', formattedAmount), + ]; + + if (acceptedByName || acceptedByEmail) { + textLines.push( + dict.acceptedBy + .replace('{acceptedByName}', acceptedByName) + .replace('{acceptedByEmail}', acceptedByEmail || 'N/A'), + ); + } + + textLines.push(''); + textLines.push(dict.linkText.replace('{url}', projectUrl)); + textLines.push(''); + textLines.push(dict.footer); + + const text = textLines.join('\n'); + + // --- Responsive HTML template --- + const htmlGreeting = escapeHtml(dict.greeting.replace('{studioName}', sanitizedStudio)); + const htmlIntro = escapeHtml( + dict.intro.replace('{title}', sanitizedTitle).replace('{clientName}', sanitizedClient), + ); + const htmlPackageTitle = escapeHtml(packageName); + const htmlAmount = escapeHtml(formattedAmount); + const htmlAcceptedBy = escapeHtml( + dict.acceptedBy + .replace('{acceptedByName}', acceptedByName) + .replace('{acceptedByEmail}', acceptedByEmail || 'N/A'), + ); + const htmlButtonText = escapeHtml(dict.buttonText); + const htmlFooter = escapeHtml(dict.footer); + + const html = ` + + + + + ${escapeHtml(subject)} + + + + + + + + + +
    +

    ${escapeHtml(sanitizedStudio)}

    +

    ${htmlGreeting}

    +

    ${htmlIntro}

    +
    +
    ${escapeHtml(isGerman ? 'Paket' : 'Package')}
    +
    ${htmlPackageTitle}
    +
    ${escapeHtml(isGerman ? 'Gesamtbetrag' : 'Total Amount')}
    +
    ${htmlAmount}
    +
    ${htmlAcceptedBy}
    +
    + +

    + ${escapeHtml(dict.linkText.replace('{url}', ''))} ${escapeHtml(projectUrl)} +

    +
    + ${htmlFooter} +
    + +`; + + return { + subject, + text, + html, + }; +} diff --git a/src/server/email/templates/proposal-sent.ts b/src/server/email/templates/proposal-sent.ts new file mode 100644 index 0000000..4be2690 --- /dev/null +++ b/src/server/email/templates/proposal-sent.ts @@ -0,0 +1,183 @@ +import 'server-only'; +import { formatCurrency, formatDate } from '@/lib/formatters'; +import { sanitizeStudioName, type RenderedEmail } from '@/server/email/templates'; +import enMessages from '@/../messages/en.json'; +import deMessages from '@/../messages/de.json'; + +export interface ProposalSentEmailPayload { + id: string; + title: string; + totalMinor: number; + currency: string; + validUntil?: Date | string | null; + token?: string | null; + tokenUrl?: string | null; +} + +export interface RenderProposalSentEmailParams { + proposal: ProposalSentEmailPayload; + clientName?: string | null; + studioName?: string | null; + locale?: string | null; + baseUrl?: string | null; +} + +/** + * Sanitizes single-line fields against header and CRLF injection. + */ +function sanitizeSingleLine(input?: string | null, fallback = ''): string { + if (!input || typeof input !== 'string') { + return fallback; + } + return input.replaceAll(/[\r\n\x00]+/g, ' ').trim() || fallback; +} + +/** + * Escapes HTML special characters for safe inclusion in HTML templates. + */ +function escapeHtml(input: string): string { + return input + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +/** + * Renders the localized proposal-sent email in both plain-text and responsive HTML (TASK-0060 / AC-1, AC-2, AC-6). + */ +export function renderProposalSentEmail({ + proposal, + clientName, + studioName, + locale = 'en', + baseUrl = '', +}: RenderProposalSentEmailParams): RenderedEmail { + const isGerman = locale?.toLowerCase().startsWith('de'); + const activeLocale = isGerman ? 'de' : 'en'; + const dict = isGerman ? deMessages.emails.proposal.sent : enMessages.emails.proposal.sent; + + const sanitizedStudio = sanitizeStudioName(studioName); + const sanitizedTitle = sanitizeSingleLine(proposal.title, 'Proposal'); + const sanitizedClient = sanitizeSingleLine(clientName, isGerman ? 'Kunde' : 'Client'); + + const formattedAmount = formatCurrency(proposal.totalMinor, proposal.currency, activeLocale); + + let formattedValidUntil = ''; + if (proposal.validUntil) { + const validUntilDate = + proposal.validUntil instanceof Date ? proposal.validUntil : new Date(proposal.validUntil); + if (!Number.isNaN(validUntilDate.getTime())) { + formattedValidUntil = formatDate(validUntilDate, activeLocale, { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + } + } + + // Construct proposal acceptance link + const safeBaseUrl = (baseUrl ?? '').replaceAll(/\/+$/g, ''); + const rawUrl = + proposal.tokenUrl ?? + (proposal.token + ? safeBaseUrl + ? `${safeBaseUrl}/p/${proposal.token}` + : `/p/${proposal.token}` + : ''); + + const subject = dict.subject + .replace('{title}', sanitizedTitle) + .replace('{studioName}', sanitizedStudio) + .replaceAll(/[\r\n\x00]+/g, ' ') + .trim(); + + // --- Plain-text template --- + const textLines: string[] = [ + dict.greeting.replace('{clientName}', sanitizedClient), + '', + dict.intro.replace('{studioName}', sanitizedStudio), + '', + dict.proposalTitle.replace('{title}', sanitizedTitle), + dict.totalAmount.replace('{amount}', formattedAmount), + ]; + + if (formattedValidUntil) { + textLines.push(dict.validUntil.replace('{date}', formattedValidUntil)); + } + + if (rawUrl) { + textLines.push(''); + textLines.push(dict.linkText.replace('{url}', rawUrl)); + } + + textLines.push(''); + textLines.push(dict.contactInfo.replace('{studioName}', sanitizedStudio)); + textLines.push(''); + textLines.push(dict.footer); + + const text = textLines.join('\n'); + + // --- Responsive HTML template --- + const htmlGreeting = escapeHtml(dict.greeting.replace('{clientName}', sanitizedClient)); + const htmlIntro = escapeHtml(dict.intro.replace('{studioName}', sanitizedStudio)); + const htmlProposalTitle = escapeHtml(sanitizedTitle); + const htmlAmount = escapeHtml(formattedAmount); + const htmlContactInfo = escapeHtml(dict.contactInfo.replace('{studioName}', sanitizedStudio)); + const htmlFooter = escapeHtml(dict.footer); + const htmlButtonText = escapeHtml(dict.buttonText); + + const html = ` + + + + + ${escapeHtml(subject)} + + + + + + + + + +
    +

    ${escapeHtml(sanitizedStudio)}

    +

    ${htmlGreeting}

    +

    ${htmlIntro}

    +
    +
    ${escapeHtml(isGerman ? 'Angebot' : 'Proposal')}
    +
    ${htmlProposalTitle}
    +
    ${escapeHtml(isGerman ? 'Gesamtbetrag' : 'Total Quote')}
    +
    ${htmlAmount}
    + ${ + formattedValidUntil + ? `
    ${escapeHtml(dict.validUntil.replace('{date}', formattedValidUntil))}
    ` + : '' + } +
    + ${ + rawUrl + ? ` +

    + ${escapeHtml(dict.linkText.replace('{url}', ''))} ${escapeHtml(rawUrl)} +

    ` + : '' + } +

    ${htmlContactInfo}

    +
    + ${htmlFooter} +
    + +`; + + return { + subject, + text, + html, + }; +} diff --git a/src/server/email/templates/proposal.ts b/src/server/email/templates/proposal.ts new file mode 100644 index 0000000..5d8b73b --- /dev/null +++ b/src/server/email/templates/proposal.ts @@ -0,0 +1,142 @@ +import 'server-only'; +import { db as defaultDb, type DbClient } from '@/server/db/client'; +import { generateUuidV7 } from '@/lib/id'; +import { logger } from '@/server/log'; +import { emailOutbox, type EmailOutboxItem } from '@/server/db/schema/email-outbox'; +import { user } from '@/server/db/schema/auth'; +import { proposals } from '@/server/db/schema/proposals'; +import { clientContacts } from '@/server/db/schema/clients'; +import { eq, desc, asc } from 'drizzle-orm'; + +export interface EnqueueProposalEmailParams { + proposalId: string; + kind: 'proposal_sent' | 'proposal_accepted_client' | 'proposal_accepted_owner'; + recipient?: string | null; + payload?: Record | null; + tx?: DbClient; +} + +/** + * Standard RFC 3834 auto-submitted headers for automated proposal emails (TASK-0060). + */ +export const PROPOSAL_EMAIL_HEADERS = { + 'Auto-Submitted': 'auto-generated', + Precedence: 'bulk', + 'X-Auto-Response-Suppress': 'All', +} as const; + +/** + * Returns RFC 3834 auto-submitted headers for proposal transactional notifications. + */ +export function getProposalEmailHeaders(): Record { + return { + 'Auto-Submitted': 'auto-generated', + Precedence: 'bulk', + 'X-Auto-Response-Suppress': 'All', + }; +} + +/** + * Enqueues a proposal transactional email in `email_outbox` (TASK-0060 / AC-1, AC-4). + * Adheres strictly to security baseline by logging only proposalId and outboxId without PII. + */ +export async function enqueueProposalEmail({ + proposalId, + kind, + recipient, + payload, + tx = defaultDb, +}: EnqueueProposalEmailParams): Promise { + const now = new Date(); + const [created] = await tx + .insert(emailOutbox) + .values({ + id: generateUuidV7(), + kind, + proposalId, + recipient: recipient ?? null, + payload: payload ?? null, + status: 'PENDING', + attempts: 0, + nextAttemptAt: now, + createdAt: now, + updatedAt: now, + }) + .returning(); + + if (!created) { + throw new Error('Failed to enqueue proposal email outbox record'); + } + + // Security constraint: Log proposal ID and outbox ID only. Never log recipient email or client notes. + logger.info( + { + proposalId, + outboxId: created.id, + kind, + }, + 'Proposal transactional email enqueued', + ); + + return created; +} + +/** + * Enqueues both client and owner notification emails upon proposal acceptance (TASK-0060 / AC-4). + */ +export async function enqueueProposalAcceptedEmails( + proposalId: string, + tx: DbClient = defaultDb, +): Promise<{ clientItem: EmailOutboxItem; ownerItem: EmailOutboxItem }> { + // 1. Resolve proposal and client recipient + const [proposalRow] = await tx + .select({ + id: proposals.id, + clientId: proposals.clientId, + acceptedByEmail: proposals.acceptedByEmail, + }) + .from(proposals) + .where(eq(proposals.id, proposalId)) + .limit(1); + + if (!proposalRow) { + throw new Error(`Proposal not found for enqueueProposalAcceptedEmails: ${proposalId}`); + } + + let clientRecipient = proposalRow.acceptedByEmail; + if (!clientRecipient) { + const [contactRow] = await tx + .select({ email: clientContacts.email }) + .from(clientContacts) + .where(eq(clientContacts.clientId, proposalRow.clientId)) + .orderBy(desc(clientContacts.isPrimary), asc(clientContacts.createdAt)) + .limit(1); + clientRecipient = contactRow?.email ?? null; + } + + // 2. Resolve studio owner recipient + const [ownerRow] = await tx + .select({ email: user.email }) + .from(user) + .where(eq(user.role, 'owner')) + .limit(1); + + const ownerRecipient = ownerRow?.email ?? process.env.SMTP_FROM ?? 'owner@example.org'; + + // 3. Insert both outbox items in the transaction + const clientItem = await enqueueProposalEmail({ + proposalId, + kind: 'proposal_accepted_client', + recipient: clientRecipient, + tx, + }); + + const ownerItem = await enqueueProposalEmail({ + proposalId, + kind: 'proposal_accepted_owner', + recipient: ownerRecipient, + tx, + }); + + return { clientItem, ownerItem }; +} diff --git a/src/server/proposals/actions.ts b/src/server/proposals/actions.ts new file mode 100644 index 0000000..32b84c1 --- /dev/null +++ b/src/server/proposals/actions.ts @@ -0,0 +1,193 @@ +'use server'; + +import 'server-only'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { db as defaultDb, type DbClient } from '@/server/db/client'; +import { requireOwner, ForbiddenError, type AuthContext } from '@/server/auth/guards'; +import { logger } from '@/server/log'; +import { saveDraftProposalSchema, sendProposalSchema, revokeProposalTokenSchema } from './schema'; +import * as service from './service'; +import { ProposalNotFoundError, ProposalForbiddenError, type ProposalWithLineItems } from './repo'; +import { ProposalImmutableError } from './snapshot'; + +export type ProposalActionResult = + { success: true; data: T } | { success: false; code: string; error: string; fields?: string[] }; + +function safeRevalidate(path: string): void { + try { + revalidatePath(path); + } catch { + // Ignore outside Next.js request context (e.g. tests) + } +} + +function formatValidationIssues(error: z.ZodError): { error: string; fields: string[] } { + return { + error: error.issues[0]?.message ?? 'Validation failed', + fields: error.issues.map((i) => i.path.join('.')), + }; +} + +/** + * Creates or updates a draft proposal and its line items transactionally (TASK-0060 / AC-8). + * Strictly requires authenticated studio owner. + */ +export async function saveDraftProposalAction( + rawInput: unknown, + client: DbClient = defaultDb, +): Promise> { + let auth: AuthContext; + try { + auth = await requireOwner({ client }); + } catch (err) { + if (err instanceof ForbiddenError) { + return { success: false, code: 'FORBIDDEN', error: 'FORBIDDEN' }; + } + return { success: false, code: 'UNAUTHENTICATED', error: 'UNAUTHENTICATED' }; + } + + const parsed = saveDraftProposalSchema.safeParse(rawInput); + if (!parsed.success) { + const formatted = formatValidationIssues(parsed.error); + return { + success: false, + code: 'VALIDATION_FAILED', + error: formatted.error, + fields: formatted.fields, + }; + } + + try { + const result = await service.saveDraftProposal(parsed.data, { auth, client }, client); + safeRevalidate(`/projects/${result.projectId}`); + return { success: true, data: result }; + } catch (err) { + if (err instanceof ProposalNotFoundError) { + return { success: false, code: 'NOT_FOUND', error: err.message }; + } + if (err instanceof ProposalForbiddenError) { + return { success: false, code: 'FORBIDDEN', error: err.message }; + } + if (err instanceof ProposalImmutableError) { + return { success: false, code: 'IMMUTABLE', error: err.message }; + } + logger.error( + { + error: err instanceof Error ? err.message : String(err), + }, + 'Unexpected error in saveDraftProposalAction', + ); + return { + success: false, + code: 'INTERNAL_ERROR', + error: err instanceof Error ? err.message : 'Internal error saving draft proposal', + }; + } +} + +/** + * Sends a proposal to the client: generates 32-byte secret token, updates status to 'sent', + * queues proposal_sent email outbox item in the same transaction, and emits timeline and audit events (TASK-0060 / AC-1). + * Strictly requires authenticated studio owner. + */ +export async function sendProposalAction( + rawInput: unknown, + client: DbClient = defaultDb, +): Promise> { + let auth: AuthContext; + try { + auth = await requireOwner({ client }); + } catch (err) { + if (err instanceof ForbiddenError) { + return { success: false, code: 'FORBIDDEN', error: 'FORBIDDEN' }; + } + return { success: false, code: 'UNAUTHENTICATED', error: 'UNAUTHENTICATED' }; + } + + const parsed = sendProposalSchema.safeParse(rawInput); + if (!parsed.success) { + const formatted = formatValidationIssues(parsed.error); + return { + success: false, + code: 'VALIDATION_FAILED', + error: formatted.error, + fields: formatted.fields, + }; + } + + try { + const result = await service.sendProposal(parsed.data, { auth, client }, client); + safeRevalidate('/projects'); + return { success: true, data: result }; + } catch (err) { + if (err instanceof ProposalNotFoundError) { + return { success: false, code: 'NOT_FOUND', error: err.message }; + } + if (err instanceof ProposalImmutableError) { + return { success: false, code: 'IMMUTABLE', error: err.message }; + } + logger.error( + { + error: err instanceof Error ? err.message : String(err), + }, + 'Unexpected error in sendProposalAction', + ); + return { + success: false, + code: 'INTERNAL_ERROR', + error: err instanceof Error ? err.message : 'Internal error sending proposal', + }; + } +} + +/** + * Revokes public access token for a proposal, setting token_revoked_at = now() and generating a fresh hash (TASK-0060 / AC-3). + * Strictly requires authenticated studio owner. + */ +export async function revokeProposalTokenAction( + rawInput: unknown, + client: DbClient = defaultDb, +): Promise> { + let auth: AuthContext; + try { + auth = await requireOwner({ client }); + } catch (err) { + if (err instanceof ForbiddenError) { + return { success: false, code: 'FORBIDDEN', error: 'FORBIDDEN' }; + } + return { success: false, code: 'UNAUTHENTICATED', error: 'UNAUTHENTICATED' }; + } + + const parsed = revokeProposalTokenSchema.safeParse(rawInput); + if (!parsed.success) { + const formatted = formatValidationIssues(parsed.error); + return { + success: false, + code: 'VALIDATION_FAILED', + error: formatted.error, + fields: formatted.fields, + }; + } + + try { + const result = await service.revokeProposalToken(parsed.data, { auth, client }, client); + safeRevalidate('/projects'); + return { success: true, data: result }; + } catch (err) { + if (err instanceof ProposalNotFoundError) { + return { success: false, code: 'NOT_FOUND', error: err.message }; + } + logger.error( + { + error: err instanceof Error ? err.message : String(err), + }, + 'Unexpected error in revokeProposalTokenAction', + ); + return { + success: false, + code: 'INTERNAL_ERROR', + error: err instanceof Error ? err.message : 'Internal error revoking proposal token', + }; + } +} diff --git a/src/server/proposals/repo.ts b/src/server/proposals/repo.ts index 7e42e2c..97bfbea 100644 --- a/src/server/proposals/repo.ts +++ b/src/server/proposals/repo.ts @@ -13,6 +13,7 @@ import { resolveRepoAuth, type RepoAuthInput } from '@/server/auth/repo-auth'; import { audit } from '@/server/audit'; import { recordTimelineEvent } from '@/server/timeline/service'; import { calculateProposalTotals } from './calculator'; +import { enqueueProposalAcceptedEmails } from '@/server/email/templates/proposal'; import { assertProposalTransitionAllowed, assertProposalEditable, @@ -668,6 +669,9 @@ export async function transitionProposalStatus( tx, ); + // Enqueue transactional acceptance emails for client and studio owner (ADR-0018 §5 / TASK-0060 AC-4) + await enqueueProposalAcceptedEmails(acceptedProposal.id, tx); + return { ...acceptedProposal, lineItems: currentItems, diff --git a/src/server/proposals/schema.ts b/src/server/proposals/schema.ts index a2ebc8b..421f8d3 100644 --- a/src/server/proposals/schema.ts +++ b/src/server/proposals/schema.ts @@ -92,3 +92,60 @@ export const transitionProposalStatusSchema = z export type TransitionProposalStatusInput = z.input; export type TransitionProposalStatusOutput = z.output; + +/** + * Schema for saving (creating or updating) a draft proposal (TASK-0060). + */ +export const saveDraftProposalSchema = z + .object({ + id: z.string().uuid().optional(), + projectId: z.string().uuid('Project ID must be a valid UUID'), + clientId: z.string().uuid('Client ID must be a valid UUID').optional(), + title: z + .string() + .trim() + .min(1, 'Proposal title is required') + .max(200, 'Proposal title too long'), + currency: z.string().length(3, 'Currency must be 3 characters').default('EUR'), + pricesIncludeTax: z.boolean().default(true), + isKleinunternehmer: z.boolean().default(false), + taxNotice: z.string().max(1000).nullable().optional(), + validUntil: z.coerce.date().nullable().optional(), + internalNotes: z.string().max(5000).nullable().optional(), + lineItems: z.array(proposalLineItemInputSchema).default([]), + }) + .strict(); + +export type SaveDraftProposalInput = z.input; +export type SaveDraftProposalOutput = z.output; + +/** + * Schema for sending a proposal to a client (TASK-0060). + */ +export const sendProposalSchema = z + .object({ + proposalId: z.string().uuid('Proposal ID must be a valid UUID'), + validUntil: z.coerce + .date({ message: 'Valid expiration date is required' }) + .refine((date) => date.getTime() > Date.now(), { + message: 'Expiration date must be in the future', + }) + .optional(), + recipientEmail: z.string().trim().email('Invalid recipient email address').max(255).optional(), + }) + .strict(); + +export type SendProposalInput = z.input; +export type SendProposalOutput = z.output; + +/** + * Schema for revoking a proposal's public token (TASK-0060). + */ +export const revokeProposalTokenSchema = z + .object({ + proposalId: z.string().uuid('Proposal ID must be a valid UUID'), + }) + .strict(); + +export type RevokeProposalTokenInput = z.input; +export type RevokeProposalTokenOutput = z.output; diff --git a/src/server/proposals/service.ts b/src/server/proposals/service.ts new file mode 100644 index 0000000..014c23b --- /dev/null +++ b/src/server/proposals/service.ts @@ -0,0 +1,503 @@ +import 'server-only'; +import { randomBytes, createHash } from 'node:crypto'; +import { eq, and, desc, asc } from 'drizzle-orm'; +import { db as defaultDb, type DbClient } from '@/server/db/client'; +import { generateUuidV7 } from '@/lib/id'; +import { + proposals, + proposalLineItems, + type Proposal, + type ProposalLineItem, +} from '@/server/db/schema/proposals'; +import { packageIncludedItems } from '@/server/db/schema/packages'; +import { projects } from '@/server/db/schema/projects'; +import { clientContacts } from '@/server/db/schema/clients'; +import { resolveRepoAuth, type RepoAuthInput } from '@/server/auth/repo-auth'; +import { audit } from '@/server/audit'; +import { recordTimelineEvent } from '@/server/timeline/service'; +import { calculateProposalTotals } from './calculator'; +import { enqueueProposalEmail } from '@/server/email/templates/proposal'; +import { ProposalImmutableError, assertProposalEditable } from './snapshot'; +import { + saveDraftProposalSchema, + sendProposalSchema, + revokeProposalTokenSchema, + type SaveDraftProposalInput, + type SendProposalInput, + type RevokeProposalTokenInput, +} from './schema'; +import { + createProposal, + getProposalById, + ProposalNotFoundError, + ProposalForbiddenError, + type ProposalWithLineItems, +} from './repo'; + +export interface AcceptedProposalSummary { + proposalId: string; + projectId: string; + packageName: string; + includedItems: string[]; + selectedAddons: { name: string; quantity: number; unitPriceMinor: number }[]; + subtotalMinor: number; + taxMinor: number; + totalMinor: number; + currency: string; + acceptedAt: Date; + acceptedByName: string; + acceptedByEmail: string; +} + +export interface SendProposalResult { + proposalId: string; + rawToken: string; + tokenUrl: string; +} + +export interface RevokeProposalTokenActionResult { + proposalId: string; + revokedAt: Date; +} + +/** + * Returns structured summary of accepted proposal on a project (ADR-0018 §4.1 / AC-9). + * Forwards-compatible with E6 contract merge fields and E12 Lexware invoicing. + */ +export async function getAcceptedProposalForProject( + projectId: string, + tx?: DbClient, +): Promise { + const client = tx ?? defaultDb; + + const [proposal] = await client + .select() + .from(proposals) + .where(and(eq(proposals.projectId, projectId), eq(proposals.status, 'accepted'))) + .orderBy(desc(proposals.acceptedAt), desc(proposals.createdAt)) + .limit(1); + + if (!proposal) { + return null; + } + + const items = await client + .select() + .from(proposalLineItems) + .where(eq(proposalLineItems.proposalId, proposal.id)) + .orderBy(asc(proposalLineItems.position), asc(proposalLineItems.createdAt)); + + const basePackageItem = items.find((i) => i.itemType === 'base_package'); + let includedItems: string[] = []; + + if (basePackageItem?.packageId) { + const includedRows = await client + .select({ title: packageIncludedItems.title }) + .from(packageIncludedItems) + .where(eq(packageIncludedItems.packageId, basePackageItem.packageId)) + .orderBy(asc(packageIncludedItems.position), asc(packageIncludedItems.createdAt)); + + includedItems = includedRows.map((r) => r.title); + } + + const packageName = basePackageItem?.name ?? proposal.title; + + const selectedAddons = items + .filter((i) => (i.itemType === 'addon' || i.itemType === 'custom') && i.isSelected) + .map((i) => ({ + name: i.name, + quantity: i.quantity, + unitPriceMinor: i.unitPriceMinor, + })); + + return { + proposalId: proposal.id, + projectId: proposal.projectId, + packageName, + includedItems, + selectedAddons, + subtotalMinor: proposal.subtotalMinor, + taxMinor: proposal.taxMinor, + totalMinor: proposal.totalMinor, + currency: proposal.currency, + acceptedAt: proposal.acceptedAt ?? proposal.updatedAt, + acceptedByName: proposal.acceptedByName ?? '', + acceptedByEmail: proposal.acceptedByEmail ?? '', + }; +} + +/** + * Creates a new draft proposal or updates an existing draft proposal and line items transactionally (TASK-0060 / AC-8). + */ +export async function saveDraftProposal( + rawInput: SaveDraftProposalInput, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { auth, client } = await resolveRepoAuth(authOrOptions, explicitClient); + const validated = saveDraftProposalSchema.parse(rawInput); + + if (!validated.id) { + // Creating a new proposal + let clientId = validated.clientId; + if (!clientId) { + const [project] = await client + .select({ clientId: projects.clientId }) + .from(projects) + .where(eq(projects.id, validated.projectId)) + .limit(1); + if (!project?.clientId) { + throw new Error('Project not found or missing clientId'); + } + clientId = project.clientId; + } + + const result = await createProposal( + { + projectId: validated.projectId, + clientId, + title: validated.title, + currency: validated.currency, + pricesIncludeTax: validated.pricesIncludeTax, + isKleinunternehmer: validated.isKleinunternehmer, + taxNotice: validated.taxNotice, + validUntil: validated.validUntil, + internalNotes: validated.internalNotes, + lineItems: validated.lineItems, + }, + authOrOptions, + client, + ); + + return { + id: result.id, + projectId: result.projectId, + clientId: result.clientId, + title: result.title, + status: result.status, + version: result.version, + currency: result.currency, + subtotalMinor: result.subtotalMinor, + taxMinor: result.taxMinor, + totalMinor: result.totalMinor, + pricesIncludeTax: result.pricesIncludeTax, + isKleinunternehmer: result.isKleinunternehmer, + taxNotice: result.taxNotice, + validUntil: result.validUntil, + tokenHash: result.tokenHash, + tokenRevokedAt: result.tokenRevokedAt, + sentAt: result.sentAt, + acceptedAt: result.acceptedAt, + declinedAt: result.declinedAt, + declineReason: result.declineReason, + acceptedByName: result.acceptedByName, + acceptedByEmail: result.acceptedByEmail, + acceptanceIpAnonymized: result.acceptanceIpAnonymized, + acceptanceUserAgent: result.acceptanceUserAgent, + acceptedOptionsSnapshot: result.acceptedOptionsSnapshot, + internalNotes: result.internalNotes, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + lineItems: result.lineItems, + }; + } + + // Updating existing draft proposal + const proposalId = validated.id; + const existing = await getProposalById(proposalId, authOrOptions, client); + if (!existing) { + throw new ProposalNotFoundError(); + } + + assertProposalEditable(existing); + + // Validate project exists + const [project] = await client + .select() + .from(projects) + .where(eq(projects.id, validated.projectId)) + .limit(1); + + if (!project) { + throw new ProposalForbiddenError('Project does not exist or access is forbidden'); + } + + const clientId = validated.clientId ?? existing.clientId; + if (project.clientId !== clientId) { + throw new ProposalForbiddenError('FORBIDDEN: Project does not belong to the specified client'); + } + + const now = new Date(); + const calcResult = calculateProposalTotals( + validated.lineItems.map((item) => ({ + unitPriceMinor: item.unitPriceMinor, + quantity: item.quantity, + taxRateBasisPoints: item.taxRateBasisPoints, + isSelected: item.isSelected, + })), + { + pricesIncludeTax: validated.pricesIncludeTax, + isKleinunternehmer: validated.isKleinunternehmer, + }, + ); + + return await client.transaction(async (tx) => { + // 1. Delete existing line items + await tx.delete(proposalLineItems).where(eq(proposalLineItems.proposalId, proposalId)); + + // 2. Insert new line items + let insertedItems: ProposalLineItem[] = []; + if (validated.lineItems.length > 0) { + insertedItems = await tx + .insert(proposalLineItems) + .values( + validated.lineItems.map((item, index) => ({ + id: item.id ?? generateUuidV7(), + proposalId, + packageId: item.packageId ?? null, + catalogueItemId: item.catalogueItemId ?? null, + itemType: item.itemType, + name: item.name, + description: item.description ?? null, + unitPriceMinor: item.unitPriceMinor, + quantity: item.quantity, + minQuantity: item.minQuantity, + maxQuantity: item.maxQuantity, + taxRateBasisPoints: item.taxRateBasisPoints, + isOptional: item.isOptional, + isSelected: item.isSelected, + position: item.position ?? index, + createdAt: now, + updatedAt: now, + })), + ) + .returning(); + } + + // 3. Update proposal row + const [updated] = (await tx + .update(proposals) + .set({ + projectId: validated.projectId, + clientId, + title: validated.title, + currency: validated.currency, + pricesIncludeTax: validated.pricesIncludeTax, + isKleinunternehmer: validated.isKleinunternehmer, + taxNotice: validated.taxNotice ?? null, + validUntil: validated.validUntil ?? null, + internalNotes: validated.internalNotes ?? null, + subtotalMinor: calcResult.subtotalMinor, + taxMinor: calcResult.taxMinor, + totalMinor: calcResult.totalMinor, + updatedAt: now, + }) + .where(eq(proposals.id, proposalId)) + .returning()) as [Proposal]; + + // 4. Emit proposal.updated audit log + await audit( + { + actorType: auth.authType === 'apiKey' ? 'token' : 'owner', + actorId: auth.user.id, + action: 'proposal.updated', + targetType: 'proposal', + targetId: updated.id, + outcome: 'success', + metadata: { + proposal_id: updated.id, + project_id: updated.projectId, + client_id: updated.clientId, + title: updated.title, + total_minor: updated.totalMinor, + currency: updated.currency, + version: updated.version, + status: updated.status, + changed_fields: 'line_items,subtotal_minor,tax_minor,total_minor', + }, + }, + tx, + ); + + return { + ...updated, + lineItems: insertedItems, + }; + }); +} + +/** + * Sends a proposal to the client: generates 32-byte secret token, updates status to 'sent', + * queues 'proposal_sent' in email_outbox in the same transaction, and emits timeline and audit events (TASK-0060 / AC-1). + */ +export async function sendProposal( + rawInput: SendProposalInput, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { auth, client } = await resolveRepoAuth(authOrOptions, explicitClient); + const validated = sendProposalSchema.parse(rawInput); + + const existing = await getProposalById(validated.proposalId, authOrOptions, client); + if (!existing) { + throw new ProposalNotFoundError(); + } + + if (existing.status !== 'draft' && existing.status !== 'sent') { + throw new ProposalImmutableError( + `Cannot send proposal with status '${existing.status}'. Proposals can only be sent from 'draft' or 'sent' state.`, + ); + } + + // Resolve recipient email: explicit parameter or client contact email + let recipientEmail = validated.recipientEmail?.trim(); + if (!recipientEmail) { + const [contactRow] = await client + .select({ email: clientContacts.email }) + .from(clientContacts) + .where(eq(clientContacts.clientId, existing.clientId)) + .orderBy(desc(clientContacts.isPrimary), asc(clientContacts.createdAt)) + .limit(1); + + recipientEmail = contactRow?.email?.trim(); + } + + if (!recipientEmail) { + throw new Error('Recipient email is required to send proposal'); + } + + // Generate 32-byte URL-safe base64 secret token + const rawToken = randomBytes(32).toString('base64url'); + const tokenHash = createHash('sha256').update(rawToken).digest('hex'); + const now = new Date(); + const validUntil = validated.validUntil ?? existing.validUntil; + + const result = await client.transaction(async (tx) => { + // 1. Update proposal to 'sent' + const [updated] = (await tx + .update(proposals) + .set({ + status: 'sent', + tokenHash, + tokenRevokedAt: null, + sentAt: now, + validUntil, + updatedAt: now, + }) + .where(eq(proposals.id, existing.id)) + .returning()) as [Proposal]; + + // 2. Enqueue proposal_sent outbox row with token link in single transaction (AC-1) + await enqueueProposalEmail({ + proposalId: updated.id, + kind: 'proposal_sent', + recipient: recipientEmail, + payload: { + token: rawToken, + tokenUrl: `/p/${rawToken}`, + }, + tx, + }); + + // 3. Emit client relationship timeline event (proposal.sent) + await recordTimelineEvent(tx, { + clientId: updated.clientId, + type: 'proposal.sent', + actorType: auth.authType === 'apiKey' ? 'token' : 'owner', + actorId: auth.user.id, + subjectType: 'proposal', + subjectId: updated.id, + data: { + proposal_id: updated.id, + total_minor: updated.totalMinor, + currency: updated.currency, + }, + }); + + // 4. Emit audit log event (proposal.sent) + await audit( + { + actorType: auth.authType === 'apiKey' ? 'token' : 'owner', + actorId: auth.user.id, + action: 'proposal.sent', + targetType: 'proposal', + targetId: updated.id, + outcome: 'success', + metadata: { + proposal_id: updated.id, + project_id: updated.projectId, + client_id: updated.clientId, + total_minor: updated.totalMinor, + currency: updated.currency, + valid_until: updated.validUntil ? updated.validUntil.toISOString() : null, + }, + }, + tx, + ); + + return { + proposalId: updated.id, + rawToken, + tokenUrl: `/p/${rawToken}`, + }; + }); + + return result; +} + +/** + * Revokes proposal secret token: sets token_revoked_at = now(), generates a fresh hash, + * invalidating previous link and emitting proposal.revoked audit event (TASK-0060 / AC-3). + */ +export async function revokeProposalToken( + rawInput: RevokeProposalTokenInput, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { auth, client } = await resolveRepoAuth(authOrOptions, explicitClient); + const validated = revokeProposalTokenSchema.parse(rawInput); + + const existing = await getProposalById(validated.proposalId, authOrOptions, client); + if (!existing) { + throw new ProposalNotFoundError(); + } + + // Generate new secret token and hash + const rawToken = randomBytes(32).toString('base64url'); + const tokenHash = createHash('sha256').update(rawToken).digest('hex'); + const now = new Date(); + + await client.transaction(async (tx) => { + await tx + .update(proposals) + .set({ + tokenHash, + tokenRevokedAt: now, + updatedAt: now, + }) + .where(eq(proposals.id, existing.id)); + + await audit( + { + actorType: auth.authType === 'apiKey' ? 'token' : 'owner', + actorId: auth.user.id, + action: 'proposal.revoked', + targetType: 'proposal', + targetId: existing.id, + outcome: 'success', + metadata: { + proposal_id: existing.id, + project_id: existing.projectId, + client_id: existing.clientId, + reason: 'manual_revocation', + }, + }, + tx, + ); + }); + + return { + proposalId: existing.id, + revokedAt: now, + }; +} diff --git a/tests/integration/proposal-send-action.test.ts b/tests/integration/proposal-send-action.test.ts new file mode 100644 index 0000000..cea295b --- /dev/null +++ b/tests/integration/proposal-send-action.test.ts @@ -0,0 +1,1238 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import nodemailer from 'nodemailer'; +import { eq, and } from 'drizzle-orm'; + +vi.mock('server-only', () => ({})); + +const mockCookiesStore = new Map(); +const mockHeadersStore = new Map(); + +vi.mock('next/headers', () => ({ + cookies: () => + Promise.resolve({ + get: (name: string) => { + const val = mockCookiesStore.get(name); + return val ? { name, value: val } : undefined; + }, + set: (name: string, value: string) => { + mockCookiesStore.set(name, value); + }, + delete: (name: string) => { + mockCookiesStore.delete(name); + }, + }), + headers: () => + Promise.resolve({ + get: (name: string) => mockHeadersStore.get(name.toLowerCase()) ?? null, + }), +})); + +import { createIsolatedTestDatabase, type TestDatabaseInstance } from '../helpers/db-test-helper'; +import type { DbClient } from '@/server/db/client'; +import { user } from '@/server/db/schema/auth'; +import { clients, clientContacts } from '@/server/db/schema/clients'; +import { projects, pipelineStages } from '@/server/db/schema/projects'; +import { packages, packageIncludedItems } from '@/server/db/schema/packages'; +import { proposals } from '@/server/db/schema/proposals'; +import { emailOutbox } from '@/server/db/schema/email-outbox'; +import { auditEvents } from '@/server/db/schema/audit'; +import { clientTimelineEvents } from '@/server/db/schema/timeline'; +import { studioSettings } from '@/server/db/schema/studio-settings'; +import { createSession, SESSION_COOKIE_NAME } from '@/server/auth/session'; +import { generateUuidV7 } from '@/lib/id'; +import { seedDefaultPipelineStages } from '@/server/db/seeds/pipeline-stages'; +import { + saveDraftProposalAction, + sendProposalAction, + revokeProposalTokenAction, +} from '@/server/proposals/actions'; +import { + transitionProposalStatus, + getProposalByRawToken, + ProposalNotFoundError, + ProposalForbiddenError, +} from '@/server/proposals/repo'; +import { processEmailOutboxBatch } from '@/server/email/outbox-processor'; +import * as proposalService from '@/server/proposals/service'; +import { getAcceptedProposalForProject } from '@/server/proposals/service'; +import { enqueueProposalAcceptedEmails } from '@/server/email/templates/proposal'; + +describe('TASK-0060: Proposals Email Outbox, Templates, Send & Revoke Actions Integration Tests', () => { + let testDb: TestDatabaseInstance; + let dbClient: DbClient; + let ownerUser: { id: string; name: string; email: string; role: string }; + let sessionToken: string; + let testClientId: string; + let testProjectId: string; + let testPackageId: string; + let defaultStageId: string; + + beforeEach(async () => { + testDb = await createIsolatedTestDatabase(); + dbClient = testDb.db as unknown as DbClient; + + mockCookiesStore.clear(); + mockHeadersStore.clear(); + + await seedDefaultPipelineStages(dbClient); + const [stage] = await dbClient.select().from(pipelineStages).limit(1); + if (!stage) { + throw new Error('Default pipeline stage not found'); + } + defaultStageId = stage.id; + + // Create Studio Settings + await dbClient.insert(studioSettings).values({ + id: generateUuidV7(), + studio_name: 'Solstice Light Studio', + default_locale: 'en', + timezone: 'Europe/Berlin', + currency: 'EUR', + mfa_required: false, + }); + + // Create Studio Owner + const [insertedOwner] = await dbClient + .insert(user) + .values({ + id: generateUuidV7(), + name: 'Studio Owner', + email: 'owner@solsticelight.com', + role: 'owner', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + if (!insertedOwner) { + throw new Error('Failed to create owner user'); + } + ownerUser = insertedOwner; + + const sessionRecord = await createSession(ownerUser.id, dbClient); + sessionToken = sessionRecord.token; + mockCookiesStore.set(SESSION_COOKIE_NAME, sessionToken); + + // Create Client + testClientId = generateUuidV7(); + await dbClient.insert(clients).values({ + id: testClientId, + kind: 'person', + displayName: 'Sophia Loren', + preferredLocale: 'en', + createdAt: new Date(), + updatedAt: new Date(), + }); + + await dbClient.insert(clientContacts).values({ + id: generateUuidV7(), + clientId: testClientId, + givenName: 'Sophia', + familyName: 'Loren', + email: 'sophia@example.com', + isPrimary: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Create Project + testProjectId = generateUuidV7(); + await dbClient.insert(projects).values({ + id: testProjectId, + clientId: testClientId, + stageId: defaultStageId, + title: 'Sophia Loren Portrait Commission', + projectTypeKey: 'portrait', + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Create Package & Deliverables + testPackageId = generateUuidV7(); + await dbClient.insert(packages).values({ + id: testPackageId, + name: 'Editorial Portrait Package', + slug: 'editorial-portrait', + basePriceMinor: 180000, + currency: 'EUR', + taxRateBasisPoints: 1900, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await dbClient.insert(packageIncludedItems).values([ + { + id: generateUuidV7(), + packageId: testPackageId, + title: '2 Hours Studio Lighting', + position: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: generateUuidV7(), + packageId: testPackageId, + title: '10 Master Retouched Files', + position: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + }); + + afterEach(async () => { + mockCookiesStore.clear(); + mockHeadersStore.clear(); + await testDb.destroy(); + }); + + // AC-8 + describe('AC-8 (R-A): saveDraftProposalAction', () => { + it('AC-8: creates draft proposal and line items transactionally with status draft and recalculated minor totals', async () => { + const res = await saveDraftProposalAction( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Portrait Proposal Draft', + currency: 'EUR', + pricesIncludeTax: true, + isKleinunternehmer: false, + lineItems: [ + { + name: 'Editorial Portrait Package', + packageId: testPackageId, + itemType: 'base_package', + unitPriceMinor: 180000, + quantity: 1, + taxRateBasisPoints: 1900, + isSelected: true, + }, + { + name: 'Hair & Makeup Stylist', + itemType: 'addon', + unitPriceMinor: 35000, + quantity: 1, + taxRateBasisPoints: 1900, + isSelected: true, + }, + ], + }, + dbClient, + ); + + expect(res.success).toBe(true); + if (!res.success) return; + + expect(res.data.status).toBe('draft'); + expect(res.data.totalMinor).toBe(215000); + expect(res.data.lineItems).toHaveLength(2); + + // Verify audit log emitted + const [auditLog] = await dbClient + .select() + .from(auditEvents) + .where( + and(eq(auditEvents.action, 'proposal.created'), eq(auditEvents.targetId, res.data.id)), + ) + .limit(1); + + expect(auditLog).toBeDefined(); + expect(auditLog?.actorType).toBe('owner'); + }); + + it('AC-8: updates existing draft proposal and line items transactionally, recalculating totals', async () => { + // 1. Create initial draft + const initial = await saveDraftProposalAction( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Initial Draft', + lineItems: [ + { + name: 'Initial Package', + unitPriceMinor: 100000, + quantity: 1, + taxRateBasisPoints: 1900, + }, + ], + }, + dbClient, + ); + expect(initial.success).toBe(true); + if (!initial.success) return; + + // 2. Update draft with new title and line items + const updated = await saveDraftProposalAction( + { + id: initial.data.id, + projectId: testProjectId, + clientId: testClientId, + title: 'Updated Draft Proposal', + lineItems: [ + { + name: 'Updated Deluxe Package', + unitPriceMinor: 250000, + quantity: 1, + taxRateBasisPoints: 1900, + }, + { + name: 'Rush Delivery Addon', + unitPriceMinor: 50000, + quantity: 1, + taxRateBasisPoints: 1900, + }, + ], + }, + dbClient, + ); + + expect(updated.success).toBe(true); + if (!updated.success) return; + + expect(updated.data.title).toBe('Updated Draft Proposal'); + expect(updated.data.totalMinor).toBe(300000); + expect(updated.data.lineItems).toHaveLength(2); + + // Verify audit log emitted for update + const [auditLog] = await dbClient + .select() + .from(auditEvents) + .where( + and( + eq(auditEvents.action, 'proposal.updated'), + eq(auditEvents.targetId, initial.data.id), + ), + ) + .limit(1); + + expect(auditLog).toBeDefined(); + }); + }); + + // AC-1 & AC-2 + describe('AC-1 & AC-2 (R-A): sendProposalAction & Email Outbox Delivery', () => { + it('AC-1: generates 32-byte secret token, saves SHA-256 hash, updates status to sent, and enqueues proposal_sent in same transaction', async () => { + // 1. Create draft proposal + const draftRes = await saveDraftProposalAction( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Proposal Ready To Send', + lineItems: [ + { + name: 'Portrait Package', + unitPriceMinor: 200000, + quantity: 1, + taxRateBasisPoints: 1900, + }, + ], + }, + dbClient, + ); + expect(draftRes.success).toBe(true); + if (!draftRes.success) return; + + const proposalId = draftRes.data.id; + const validUntil = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000); + + // 2. Send proposal action + const sendRes = await sendProposalAction( + { + proposalId, + validUntil: validUntil.toISOString(), + }, + dbClient, + ); + + expect(sendRes.success).toBe(true); + if (!sendRes.success) return; + + expect(sendRes.data.proposalId).toBe(proposalId); + expect(sendRes.data.rawToken).toHaveLength(43); + expect(sendRes.data.tokenUrl).toBe(`/p/${sendRes.data.rawToken}`); + + // 3. Verify proposal row updated to 'sent' + const [proposal] = await dbClient + .select() + .from(proposals) + .where(eq(proposals.id, proposalId)) + .limit(1); + + expect(proposal?.status).toBe('sent'); + expect(proposal?.sentAt).toBeInstanceOf(Date); + expect(proposal?.validUntil).toBeInstanceOf(Date); + expect(proposal?.tokenRevokedAt).toBeNull(); + + // 4. Verify proposal_sent row in email_outbox + const [outboxRow] = await dbClient + .select() + .from(emailOutbox) + .where(and(eq(emailOutbox.proposalId, proposalId), eq(emailOutbox.kind, 'proposal_sent'))) + .limit(1); + + expect(outboxRow).toBeDefined(); + expect(outboxRow?.status).toBe('PENDING'); + expect(outboxRow?.recipient).toBe('sophia@example.com'); + const payload = outboxRow?.payload as { token?: string } | null; + expect(payload?.token).toBe(sendRes.data.rawToken); + + // 5. Verify timeline event and audit log + const [timelineEvent] = await dbClient + .select() + .from(clientTimelineEvents) + .where( + and( + eq(clientTimelineEvents.type, 'proposal.sent'), + eq(clientTimelineEvents.clientId, testClientId), + ), + ) + .limit(1); + expect(timelineEvent).toBeDefined(); + + const [auditRow] = await dbClient + .select() + .from(auditEvents) + .where(and(eq(auditEvents.action, 'proposal.sent'), eq(auditEvents.targetId, proposalId))) + .limit(1); + expect(auditRow).toBeDefined(); + }); + + it('AC-2: outbox worker processes proposal_sent, delivers email via SMTP with /p/[token] link and studio branding', async () => { + // 1. Create and send proposal + const draftRes = await saveDraftProposalAction( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Fine Art Session', + lineItems: [ + { + name: 'Fine Art Base Package', + unitPriceMinor: 250000, + quantity: 1, + taxRateBasisPoints: 1900, + }, + ], + }, + dbClient, + ); + if (!draftRes.success) throw new Error('Draft failed'); + + const sendRes = await sendProposalAction( + { + proposalId: draftRes.data.id, + validUntil: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + }, + dbClient, + ); + if (!sendRes.success) throw new Error('Send failed'); + + // 2. Set up mock nodemailer transporter to intercept dispatch + const sentMails: { + to: unknown; + subject: string; + text: string; + html?: string; + headers?: Record; + }[] = []; + + const testTransporter = nodemailer.createTransport({ + streamTransport: true, + buffer: true, + }); + + vi.spyOn(testTransporter, 'sendMail').mockImplementation((mailOptions) => { + sentMails.push(mailOptions as never); + return Promise.resolve({ messageId: 'msg-proposal-sent-123' }) as never; + }); + + // 3. Process outbox batch + const batchResult = await processEmailOutboxBatch({ + dbClient, + transporter: testTransporter, + }); + + expect(batchResult.sentCount).toBeGreaterThanOrEqual(1); + expect(sentMails).toHaveLength(1); + + const dispatched = sentMails[0]; + expect(dispatched).toBeDefined(); + if (!dispatched) throw new Error('Dispatched email is undefined'); + expect(dispatched.to).toEqual({ address: 'sophia@example.com' }); + expect(dispatched.subject).toContain('Fine Art Session'); + expect(dispatched.subject).toContain('Solstice Light Studio'); + expect(dispatched.text).toContain(`/p/${sendRes.data.rawToken}`); + expect(dispatched.text).toContain('€2,500.00'); + expect(dispatched.html).toContain(`/p/${sendRes.data.rawToken}`); + expect(dispatched.html).toContain('View Proposal'); + expect(dispatched.headers?.['Auto-Submitted']).toBe('auto-generated'); + expect(dispatched.headers?.Precedence).toBe('bulk'); + + // 4. Verify outbox row deleted after successful transmission + const remainingOutbox = await dbClient + .select() + .from(emailOutbox) + .where(eq(emailOutbox.proposalId, draftRes.data.id)); + expect(remainingOutbox).toHaveLength(0); + }); + }); + + // AC-3 + describe('AC-3 (R-A): revokeProposalTokenAction', () => { + it('AC-3: sets token_revoked_at, generates fresh hash, renders old token invalid, and emits proposal.revoked audit event', async () => { + // 1. Create and send proposal to obtain active token + const draftRes = await saveDraftProposalAction( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Proposal to Revoke', + lineItems: [ + { name: 'Item', unitPriceMinor: 50000, quantity: 1, taxRateBasisPoints: 1900 }, + ], + }, + dbClient, + ); + if (!draftRes.success) throw new Error('Draft failed'); + + const sendRes = await sendProposalAction({ proposalId: draftRes.data.id }, dbClient); + if (!sendRes.success) throw new Error('Send failed'); + + const initialToken = sendRes.data.rawToken; + + // Old token can currently resolve proposal + const initialResolution = await getProposalByRawToken(initialToken, dbClient); + expect(initialResolution).toBeDefined(); + + // 2. Revoke proposal token + const revokeRes = await revokeProposalTokenAction({ proposalId: draftRes.data.id }, dbClient); + expect(revokeRes.success).toBe(true); + if (!revokeRes.success) return; + + expect(revokeRes.data.proposalId).toBe(draftRes.data.id); + expect(revokeRes.data.revokedAt).toBeInstanceOf(Date); + + // 3. Old token now fails resolution + const revokedResolution = await getProposalByRawToken(initialToken, dbClient); + expect(revokedResolution).toBeNull(); + + // 4. Check DB row has tokenRevokedAt set + const [proposal] = await dbClient + .select() + .from(proposals) + .where(eq(proposals.id, draftRes.data.id)) + .limit(1); + expect(proposal?.tokenRevokedAt).toBeInstanceOf(Date); + + // 5. Verify audit log emitted + const [auditRow] = await dbClient + .select() + .from(auditEvents) + .where( + and( + eq(auditEvents.action, 'proposal.revoked'), + eq(auditEvents.targetId, draftRes.data.id), + ), + ) + .limit(1); + + expect(auditRow).toBeDefined(); + expect(auditRow?.action).toBe('proposal.revoked'); + }); + }); + + // AC-4 + describe('AC-4 (R-A): Proposal Acceptance Outbox Notification Ceremony', () => { + it('AC-4: queues proposal_accepted_client and proposal_accepted_owner in email_outbox with zero extraneous PII', async () => { + // 1. Create and send proposal + const draftRes = await saveDraftProposalAction( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Acceptance Ceremony Proposal', + lineItems: [ + { + packageId: testPackageId, + itemType: 'base_package', + name: 'Editorial Portrait Package', + unitPriceMinor: 180000, + quantity: 1, + taxRateBasisPoints: 1900, + }, + ], + }, + dbClient, + ); + if (!draftRes.success) throw new Error('Draft failed'); + + await sendProposalAction({ proposalId: draftRes.data.id }, dbClient); + + // Clear the proposal_sent outbox row so we only test acceptance notifications + await dbClient.delete(emailOutbox).where(eq(emailOutbox.proposalId, draftRes.data.id)); + + // 2. Transition proposal to 'accepted' + const accepted = await transitionProposalStatus( + draftRes.data.id, + { + newStatus: 'accepted', + acceptedByName: 'Sophia Loren', + acceptedByEmail: 'sophia.accepted@example.com', + acceptanceIpAnonymized: '10.0.0.0', + acceptanceUserAgent: 'Mozilla/5.0 Acceptance', + }, + undefined, + dbClient, + ); + + expect(accepted.status).toBe('accepted'); + + // 3. Verify two outbox rows queued: proposal_accepted_client & proposal_accepted_owner + const outboxItems = await dbClient + .select() + .from(emailOutbox) + .where(eq(emailOutbox.proposalId, draftRes.data.id)); + + expect(outboxItems).toHaveLength(2); + + const clientItem = outboxItems.find((i) => i.kind === 'proposal_accepted_client'); + const ownerItem = outboxItems.find((i) => i.kind === 'proposal_accepted_owner'); + + expect(clientItem).toBeDefined(); + expect(clientItem?.recipient).toBe('sophia.accepted@example.com'); + expect(clientItem?.status).toBe('PENDING'); + + expect(ownerItem).toBeDefined(); + expect(ownerItem?.recipient).toBe('owner@solsticelight.com'); + expect(ownerItem?.status).toBe('PENDING'); + + // 4. Process outbox items and verify email content and RFC 3834 headers + const sentMails: { + to: unknown; + subject: string; + text: string; + html?: string; + headers?: Record; + }[] = []; + + const testTransporter = nodemailer.createTransport({ streamTransport: true, buffer: true }); + vi.spyOn(testTransporter, 'sendMail').mockImplementation((mailOptions) => { + sentMails.push(mailOptions as never); + return Promise.resolve({ messageId: 'msg-acc-123' }) as never; + }); + + const batchResult = await processEmailOutboxBatch({ + dbClient, + transporter: testTransporter, + }); + + expect(batchResult.sentCount).toBe(2); + expect(sentMails).toHaveLength(2); + + const clientMail = sentMails.find( + (m) => (m.to as { address: string }).address === 'sophia.accepted@example.com', + ); + const ownerMail = sentMails.find( + (m) => (m.to as { address: string }).address === 'owner@solsticelight.com', + ); + + expect(clientMail).toBeDefined(); + expect(clientMail?.subject).toContain('Confirmation: Proposal accepted'); + expect(clientMail?.text).toContain('Editorial Portrait Package'); + expect(clientMail?.text).toContain('Next Steps'); + expect(clientMail?.headers?.['Auto-Submitted']).toBe('auto-generated'); + + expect(ownerMail).toBeDefined(); + expect(ownerMail?.subject).toContain('Proposal Accepted'); + expect(ownerMail?.text).toContain(`/projects/${testProjectId}`); + expect(ownerMail?.headers?.['Auto-Submitted']).toBe('auto-generated'); + }); + }); + + // AC-5 + describe('AC-5: Authentication Enforcement', () => { + it('AC-5: blocks unauthenticated caller attempting to call saveDraftProposalAction', async () => { + mockCookiesStore.clear(); // unauthenticated! + const res = await saveDraftProposalAction( + { + projectId: testProjectId, + title: 'Unauthenticated Draft', + }, + dbClient, + ); + expect(res.success).toBe(false); + if (!res.success) { + expect(res.code).toBe('UNAUTHENTICATED'); + } + }); + + it('AC-5: blocks unauthenticated caller attempting to call sendProposalAction', async () => { + mockCookiesStore.clear(); + const res = await sendProposalAction( + { + proposalId: '01920000-0000-7000-8000-000000000099', + }, + dbClient, + ); + expect(res.success).toBe(false); + if (!res.success) { + expect(res.code).toBe('UNAUTHENTICATED'); + } + }); + + it('AC-5: blocks unauthenticated caller attempting to call revokeProposalTokenAction', async () => { + mockCookiesStore.clear(); + const res = await revokeProposalTokenAction( + { + proposalId: '01920000-0000-7000-8000-000000000099', + }, + dbClient, + ); + expect(res.success).toBe(false); + if (!res.success) { + expect(res.code).toBe('UNAUTHENTICATED'); + } + }); + }); + + // AC-7 + describe('AC-7: Validation Failures', () => { + it('AC-7: returns VALIDATION_FAILED when sendProposalAction receives an expiration date in the past', async () => { + const pastDate = new Date(Date.now() - 3600000).toISOString(); + const res = await sendProposalAction( + { + proposalId: '01920000-0000-7000-8000-000000000001', + validUntil: pastDate, + }, + dbClient, + ); + + expect(res.success).toBe(false); + if (!res.success) { + expect(res.code).toBe('VALIDATION_FAILED'); + expect(res.error).toBe('Expiration date must be in the future'); + } + }); + + it('AC-7: returns VALIDATION_FAILED when saveDraftProposalAction receives negative line item price', async () => { + const res = await saveDraftProposalAction( + { + projectId: testProjectId, + title: 'Negative Price Draft', + lineItems: [ + { + name: 'Subsidized Item', + unitPriceMinor: -1000, + quantity: 1, + }, + ], + }, + dbClient, + ); + + expect(res.success).toBe(false); + if (!res.success) { + expect(res.code).toBe('VALIDATION_FAILED'); + expect(res.error).toBe('Unit price cannot be negative'); + } + }); + }); + + // AC-9 + describe('AC-9: Downstream getAcceptedProposalForProject Interface', () => { + it('AC-9: returns accepted proposal summary with package name, deliverables, and selected add-ons', async () => { + // 1. Create and send proposal + const draftRes = await saveDraftProposalAction( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Commercial Studio Shoot', + lineItems: [ + { + packageId: testPackageId, + itemType: 'base_package', + name: 'Editorial Portrait Package', + unitPriceMinor: 180000, + quantity: 1, + taxRateBasisPoints: 1900, + isSelected: true, + }, + { + name: 'Additional Studio Hour', + itemType: 'addon', + unitPriceMinor: 30000, + quantity: 2, + taxRateBasisPoints: 1900, + isSelected: true, + }, + ], + }, + dbClient, + ); + if (!draftRes.success) throw new Error('Draft failed'); + + // 2. Accept proposal + await transitionProposalStatus( + draftRes.data.id, + { + newStatus: 'accepted', + acceptedByName: 'Sophia Loren', + acceptedByEmail: 'sophia@example.com', + }, + undefined, + dbClient, + ); + + // 3. Call downstream interface + const summary = await getAcceptedProposalForProject(testProjectId, dbClient); + expect(summary).toBeDefined(); + expect(summary?.packageName).toBe('Editorial Portrait Package'); + expect(summary?.includedItems).toEqual([ + '2 Hours Studio Lighting', + '10 Master Retouched Files', + ]); + expect(summary?.selectedAddons).toEqual([ + { + name: 'Additional Studio Hour', + quantity: 2, + unitPriceMinor: 30000, + }, + ]); + expect(summary?.totalMinor).toBe(240000); + expect(summary?.acceptedByName).toBe('Sophia Loren'); + }); + }); + + describe('AC-5 & Boundary Defenses: Error handling, role-based access, and service edge cases', () => { + it('returns FORBIDDEN for non-owner role across all three server actions', async () => { + // 1. Create a non-owner user + const [memberUser] = await dbClient + .insert(user) + .values({ + id: generateUuidV7(), + name: 'Staff Member', + email: 'staff@solsticelight.com', + role: 'member', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + if (!memberUser) throw new Error('Failed to create member user'); + + const memberSession = await createSession(memberUser.id, dbClient); + mockCookiesStore.set(SESSION_COOKIE_NAME, memberSession.token); + + // 2. Call saveDraftProposalAction + const saveRes = await saveDraftProposalAction( + { + projectId: testProjectId, + title: 'Unauthorized Draft', + }, + dbClient, + ); + expect(saveRes.success).toBe(false); + if (!saveRes.success) { + expect(saveRes.code).toBe('FORBIDDEN'); + } + + // 3. Call sendProposalAction + const sendRes = await sendProposalAction( + { + proposalId: generateUuidV7(), + }, + dbClient, + ); + expect(sendRes.success).toBe(false); + if (!sendRes.success) { + expect(sendRes.code).toBe('FORBIDDEN'); + } + + // 4. Call revokeProposalTokenAction + const revokeRes = await revokeProposalTokenAction( + { + proposalId: generateUuidV7(), + }, + dbClient, + ); + expect(revokeRes.success).toBe(false); + if (!revokeRes.success) { + expect(revokeRes.code).toBe('FORBIDDEN'); + } + + // Restore owner session + mockCookiesStore.set(SESSION_COOKIE_NAME, sessionToken); + }); + + it('returns NOT_FOUND, FORBIDDEN, and IMMUTABLE for draft update boundary conditions', async () => { + // 1. NOT_FOUND: Updating non-existent proposal ID + const notFoundRes = await saveDraftProposalAction( + { + id: generateUuidV7(), + projectId: testProjectId, + title: 'Non-existent Proposal', + }, + dbClient, + ); + expect(notFoundRes.success).toBe(false); + if (!notFoundRes.success) { + expect(notFoundRes.code).toBe('NOT_FOUND'); + } + + // 2. Create valid draft proposal + const validDraft = await saveDraftProposalAction( + { + projectId: testProjectId, + title: 'Mutable Draft', + lineItems: [{ name: 'Service', unitPriceMinor: 10000 }], + }, + dbClient, + ); + if (!validDraft.success) throw new Error('Valid draft failed'); + + // 3. FORBIDDEN: Mismatched client ID for project + const otherClientId = generateUuidV7(); + await dbClient.insert(clients).values({ + id: otherClientId, + kind: 'person', + displayName: 'Unrelated Client', + preferredLocale: 'en', + createdAt: new Date(), + updatedAt: new Date(), + }); + + const mismatchRes = await saveDraftProposalAction( + { + id: validDraft.data.id, + projectId: testProjectId, + clientId: otherClientId, + title: 'Mismatch Draft', + }, + dbClient, + ); + expect(mismatchRes.success).toBe(false); + if (!mismatchRes.success) { + expect(mismatchRes.code).toBe('FORBIDDEN'); + } + + // 4. IMMUTABLE: Transition proposal to accepted, then attempt edit + await transitionProposalStatus( + validDraft.data.id, + { + newStatus: 'accepted', + acceptedByName: 'Sophia Loren', + acceptedByEmail: 'sophia@example.com', + }, + undefined, + dbClient, + ); + + const immutableRes = await saveDraftProposalAction( + { + id: validDraft.data.id, + projectId: testProjectId, + title: 'Attempted Modification After Acceptance', + }, + dbClient, + ); + expect(immutableRes.success).toBe(false); + if (!immutableRes.success) { + expect(immutableRes.code).toBe('IMMUTABLE'); + } + }); + + it('returns NOT_FOUND and IMMUTABLE for sendProposalAction boundary conditions', async () => { + // 1. NOT_FOUND: Unknown proposal + const notFoundRes = await sendProposalAction( + { + proposalId: generateUuidV7(), + }, + dbClient, + ); + expect(notFoundRes.success).toBe(false); + if (!notFoundRes.success) { + expect(notFoundRes.code).toBe('NOT_FOUND'); + } + + // 2. IMMUTABLE: Attempting to send an accepted proposal + const draftRes = await saveDraftProposalAction( + { + projectId: testProjectId, + title: 'Accepted Cannot Be Sent', + lineItems: [{ name: 'Service', unitPriceMinor: 10000 }], + }, + dbClient, + ); + if (!draftRes.success) throw new Error('Draft failed'); + + await transitionProposalStatus( + draftRes.data.id, + { + newStatus: 'accepted', + acceptedByName: 'Sophia Loren', + acceptedByEmail: 'sophia@example.com', + }, + undefined, + dbClient, + ); + + const sendAcceptedRes = await sendProposalAction( + { + proposalId: draftRes.data.id, + }, + dbClient, + ); + expect(sendAcceptedRes.success).toBe(false); + if (!sendAcceptedRes.success) { + expect(sendAcceptedRes.code).toBe('IMMUTABLE'); + } + }); + + it('returns NOT_FOUND and VALIDATION_FAILED for revokeProposalTokenAction', async () => { + // 1. NOT_FOUND: Unknown proposal + const notFoundRes = await revokeProposalTokenAction( + { + proposalId: generateUuidV7(), + }, + dbClient, + ); + expect(notFoundRes.success).toBe(false); + if (!notFoundRes.success) { + expect(notFoundRes.code).toBe('NOT_FOUND'); + } + + // 2. VALIDATION_FAILED: Non-UUID proposal ID + const invalidRes = await revokeProposalTokenAction( + { + proposalId: 'not-a-valid-uuid', + }, + dbClient, + ); + expect(invalidRes.success).toBe(false); + if (!invalidRes.success) { + expect(invalidRes.code).toBe('VALIDATION_FAILED'); + } + }); + + it('returns INTERNAL_ERROR when services throw unexpected exceptions', async () => { + const saveSpy = vi + .spyOn(proposalService, 'saveDraftProposal') + .mockRejectedValueOnce(new Error('Simulated DB Crash')); + const saveRes = await saveDraftProposalAction( + { + projectId: testProjectId, + title: 'Crashed Proposal', + }, + dbClient, + ); + expect(saveRes.success).toBe(false); + if (!saveRes.success) { + expect(saveRes.code).toBe('INTERNAL_ERROR'); + expect(saveRes.error).toBe('Simulated DB Crash'); + } + saveSpy.mockRestore(); + + // Test non-Error string rejection branch + const nonErrorSave = vi + .spyOn(proposalService, 'saveDraftProposal') + .mockRejectedValueOnce('raw string crash'); + const res1 = await saveDraftProposalAction( + { projectId: testProjectId, title: 'Crash' }, + dbClient, + ); + expect(res1.success).toBe(false); + if (!res1.success) { + expect(res1.code).toBe('INTERNAL_ERROR'); + expect(res1.error).toBe('Internal error saving draft proposal'); + } + nonErrorSave.mockRestore(); + + const sendSpy = vi + .spyOn(proposalService, 'sendProposal') + .mockRejectedValueOnce(new Error('Simulated Send Crash')); + const sendRes = await sendProposalAction( + { + proposalId: generateUuidV7(), + }, + dbClient, + ); + expect(sendRes.success).toBe(false); + if (!sendRes.success) { + expect(sendRes.code).toBe('INTERNAL_ERROR'); + expect(sendRes.error).toBe('Simulated Send Crash'); + } + sendSpy.mockRestore(); + + const nonErrorSend = vi + .spyOn(proposalService, 'sendProposal') + .mockRejectedValueOnce('raw string crash'); + const res2 = await sendProposalAction({ proposalId: generateUuidV7() }, dbClient); + expect(res2.success).toBe(false); + if (!res2.success) { + expect(res2.code).toBe('INTERNAL_ERROR'); + expect(res2.error).toBe('Internal error sending proposal'); + } + nonErrorSend.mockRestore(); + + const revokeSpy = vi + .spyOn(proposalService, 'revokeProposalToken') + .mockRejectedValueOnce(new Error('Simulated Revoke Crash')); + const revokeRes = await revokeProposalTokenAction( + { + proposalId: generateUuidV7(), + }, + dbClient, + ); + expect(revokeRes.success).toBe(false); + if (!revokeRes.success) { + expect(revokeRes.code).toBe('INTERNAL_ERROR'); + expect(revokeRes.error).toBe('Simulated Revoke Crash'); + } + revokeSpy.mockRestore(); + + const nonErrorRevoke = vi + .spyOn(proposalService, 'revokeProposalToken') + .mockRejectedValueOnce('raw string crash'); + const res3 = await revokeProposalTokenAction({ proposalId: generateUuidV7() }, dbClient); + expect(res3.success).toBe(false); + if (!res3.success) { + expect(res3.code).toBe('INTERNAL_ERROR'); + expect(res3.error).toBe('Internal error revoking proposal token'); + } + nonErrorRevoke.mockRestore(); + }); + + it('covers service edge cases: auto-resolves clientId from project, and handles missing contact email', async () => { + // 1. saveDraftProposal auto-resolves clientId when omitted + const draft = await proposalService.saveDraftProposal( + { + projectId: testProjectId, + title: 'Auto-resolved Client ID Proposal', + lineItems: [{ name: 'Portrait', unitPriceMinor: 20000 }], + }, + undefined, + dbClient, + ); + expect(draft.clientId).toBe(testClientId); + + // 2. saveDraftProposal throws when non-existent projectId and no clientId + await expect( + proposalService.saveDraftProposal( + { + projectId: generateUuidV7(), + title: 'No Project Draft', + }, + undefined, + dbClient, + ), + ).rejects.toThrow('Project not found or missing clientId'); + + // 3. saveDraftProposal throws ProposalForbiddenError when updating draft with non-existent projectId + await expect( + proposalService.saveDraftProposal( + { + id: draft.id, + projectId: generateUuidV7(), + title: 'Draft Non-existent Project', + }, + undefined, + dbClient, + ), + ).rejects.toThrow(ProposalForbiddenError); + + // 4. revokeProposalToken directly throws ProposalNotFoundError for unknown ID + await expect( + proposalService.revokeProposalToken({ proposalId: generateUuidV7() }, undefined, dbClient), + ).rejects.toThrow(ProposalNotFoundError); + + // 5. sendProposal directly throws when client has no contact email and no recipient is provided + const isolatedClientId = generateUuidV7(); + await dbClient.insert(clients).values({ + id: isolatedClientId, + kind: 'person', + displayName: 'No Contact Client', + preferredLocale: 'en', + createdAt: new Date(), + updatedAt: new Date(), + }); + const isolatedProjectId = generateUuidV7(); + await dbClient.insert(projects).values({ + id: isolatedProjectId, + clientId: isolatedClientId, + stageId: defaultStageId, + title: 'No Contact Project', + projectTypeKey: 'portrait', + createdAt: new Date(), + updatedAt: new Date(), + }); + const noEmailDraft = await proposalService.saveDraftProposal( + { + projectId: isolatedProjectId, + title: 'No Email Draft', + lineItems: [{ name: 'Service', unitPriceMinor: 5000 }], + }, + undefined, + dbClient, + ); + + await expect( + proposalService.sendProposal({ proposalId: noEmailDraft.id }, undefined, dbClient), + ).rejects.toThrow('Recipient email is required to send proposal'); + + // 6. enqueueProposalAcceptedEmails throws when proposal does not exist + await expect(enqueueProposalAcceptedEmails(generateUuidV7(), dbClient)).rejects.toThrow( + 'Proposal not found', + ); + + // 7. enqueueProposalAcceptedEmails resolves recipient from clientContacts when acceptedByEmail is null + const contactClientId = generateUuidV7(); + await dbClient.insert(clients).values({ + id: contactClientId, + kind: 'person', + displayName: 'Contact Fallback Client', + preferredLocale: 'en', + createdAt: new Date(), + updatedAt: new Date(), + }); + await dbClient.insert(clientContacts).values({ + id: generateUuidV7(), + clientId: contactClientId, + givenName: 'Fallback', + familyName: 'Contact', + email: 'fallback@example.com', + isPrimary: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + const contactProjectId = generateUuidV7(); + await dbClient.insert(projects).values({ + id: contactProjectId, + clientId: contactClientId, + stageId: defaultStageId, + title: 'Fallback Project', + projectTypeKey: 'portrait', + createdAt: new Date(), + updatedAt: new Date(), + }); + const contactDraft = await proposalService.saveDraftProposal( + { + projectId: contactProjectId, + clientId: contactClientId, + title: 'Fallback Email Draft', + lineItems: [{ name: 'Portrait', unitPriceMinor: 15000 }], + }, + undefined, + dbClient, + ); + // Accept without acceptedByEmail + await transitionProposalStatus( + contactDraft.id, + { + newStatus: 'accepted', + acceptedByName: 'Fallback Contact', + }, + undefined, + dbClient, + ); + const enqueued = await enqueueProposalAcceptedEmails(contactDraft.id, dbClient); + expect(enqueued.clientItem.recipient).toBe('fallback@example.com'); + }); + }); +}); diff --git a/tests/unit/proposal-email-templates.test.ts b/tests/unit/proposal-email-templates.test.ts new file mode 100644 index 0000000..d6cf06d --- /dev/null +++ b/tests/unit/proposal-email-templates.test.ts @@ -0,0 +1,330 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); + +import { + renderProposalSentEmail, + renderProposalAcceptedClientEmail, + renderProposalAcceptedOwnerEmail, + getProposalEmailHeaders, + PROPOSAL_EMAIL_HEADERS, +} from '@/server/email/templates'; + +describe('TASK-0060: Proposal Email Templates Unit Tests (AC-2, AC-4, AC-6)', () => { + describe('AC-6: RFC 3834 Anti-Loop Headers', () => { + it('provides standard RFC 3834 auto-submitted headers for automated notifications', () => { + const headers = getProposalEmailHeaders(); + expect(headers['Auto-Submitted']).toBe('auto-generated'); + expect(headers.Precedence).toBe('bulk'); + expect(headers['X-Auto-Response-Suppress']).toBe('All'); + + expect(PROPOSAL_EMAIL_HEADERS['Auto-Submitted']).toBe('auto-generated'); + expect(PROPOSAL_EMAIL_HEADERS.Precedence).toBe('bulk'); + }); + }); + + describe('AC-6: renderProposalSentEmail (Client Dispatch)', () => { + it('renders English proposal sent email with plain-text and responsive HTML', () => { + const rendered = renderProposalSentEmail({ + proposal: { + id: '01920000-0000-7000-8000-000000000001', + title: 'Wedding Photography — Emma & Liam', + totalMinor: 250000, + currency: 'EUR', + validUntil: new Date('2026-12-31T12:00:00Z'), + token: 'secure-token-123', + }, + clientName: 'Emma Watson', + studioName: 'Lumos Photography', + locale: 'en', + baseUrl: 'https://studio.example.org', + }); + + // Subject + expect(rendered.subject).toBe( + 'Proposal: Wedding Photography — Emma & Liam from Lumos Photography', + ); + + // Plain-text + expect(rendered.text).toContain('Hello Emma Watson,'); + expect(rendered.text).toContain('You have received a new proposal from Lumos Photography.'); + expect(rendered.text).toContain('Proposal: Wedding Photography — Emma & Liam'); + expect(rendered.text).toContain('€2,500.00'); + expect(rendered.text).toContain('December 31, 2026'); + expect(rendered.text).toContain('https://studio.example.org/p/secure-token-123'); + expect(rendered.text).toContain('This is an automated transactional notification.'); + + // HTML + expect(rendered.html).toBeDefined(); + expect(rendered.html).toContain(''); + expect(rendered.html).toContain('lang="en"'); + expect(rendered.html).toContain('Lumos Photography'); + expect(rendered.html).toContain('Hello Emma Watson,'); + expect(rendered.html).toContain('€2,500.00'); + expect(rendered.html).toContain('https://studio.example.org/p/secure-token-123'); + expect(rendered.html).toContain('View Proposal'); + }); + + it('renders German proposal sent email with German currency and localized date', () => { + const rendered = renderProposalSentEmail({ + proposal: { + id: '01920000-0000-7000-8000-000000000002', + title: 'Hochzeitsfotografie — Anna & Max', + totalMinor: 320000, + currency: 'EUR', + validUntil: new Date('2026-11-15T12:00:00Z'), + token: 'token-de-456', + }, + clientName: 'Anna Schmidt', + studioName: 'Lichtwerk Fotostudio', + locale: 'de', + baseUrl: 'https://lichtwerk.example.de', + }); + + // Subject + expect(rendered.subject).toBe( + 'Angebot: Hochzeitsfotografie — Anna & Max von Lichtwerk Fotostudio', + ); + + // Plain-text + expect(rendered.text).toContain('Hallo Anna Schmidt,'); + expect(rendered.text).toContain( + 'Sie haben ein neues Angebot von Lichtwerk Fotostudio erhalten.', + ); + expect(rendered.text).toContain('Angebot: Hochzeitsfotografie — Anna & Max'); + expect(rendered.text).toContain('3.200,00'); + expect(rendered.text).toContain('15. November 2026'); + expect(rendered.text).toContain('https://lichtwerk.example.de/p/token-de-456'); + + // HTML + expect(rendered.html).toContain('lang="de"'); + expect(rendered.html).toContain('Angebot ansehen'); + expect(rendered.html).toContain('Gesamtbetrag'); + }); + + it('sanitizes malicious CRLF characters in title and names to prevent email header injection', () => { + const rendered = renderProposalSentEmail({ + proposal: { + id: '01920000-0000-7000-8000-000000000003', + title: 'Malicious\r\nBcc: evil@example.com\nTitle', + totalMinor: 10000, + currency: 'EUR', + }, + clientName: 'Injected\r\nName', + studioName: 'Studio\r\nBcc: hacker@example.com', + locale: 'en', + }); + + expect(rendered.subject).not.toContain('\r'); + expect(rendered.subject).not.toContain('\n'); + expect(rendered.subject).toBe( + 'Proposal: Malicious Bcc: evil@example.com Title from Studio Bcc: hacker@example.com', + ); + expect(rendered.text).not.toContain('Injected\r\nName'); + }); + + it('handles fallback values, null fields, and invalid dates in client dispatch', () => { + const rendered = renderProposalSentEmail({ + proposal: { + id: '01920000-0000-7000-8000-000000000001', + title: null as unknown as string, + totalMinor: 10000, + currency: 'EUR', + validUntil: 'not-a-valid-date', + token: 'token123', + }, + clientName: null, + studioName: null, + locale: null, + baseUrl: null, + }); + + expect(rendered.subject).toContain('Proposal'); + expect(rendered.text).toContain('/p/token123'); + }); + }); + + describe('AC-6: renderProposalAcceptedClientEmail (Client Acceptance Confirmation)', () => { + it('renders English client acceptance email with package deliverables and add-ons', () => { + const rendered = renderProposalAcceptedClientEmail({ + proposal: { + id: '01920000-0000-7000-8000-000000000010', + title: 'Full Day Wedding Package', + packageName: 'Full Day Wedding Coverage', + includedItems: [ + '8 Hours of Continuous Coverage', + '400+ High-Resolution Edited Images', + 'Private Online Gallery', + ], + selectedAddons: [ + { name: 'Second Photographer', quantity: 1, unitPriceMinor: 50000 }, + { name: 'Fine Art Album 30x30', quantity: 2, unitPriceMinor: 30000 }, + ], + totalMinor: 360000, + currency: 'EUR', + }, + clientName: 'Emma Watson', + studioName: 'Lumos Photography', + locale: 'en', + }); + + // Subject + expect(rendered.subject).toBe('Confirmation: Proposal accepted — Full Day Wedding Package'); + + // Plain-text + expect(rendered.text).toContain('Hello Emma Watson,'); + expect(rendered.text).toContain('Thank you for accepting our proposal.'); + expect(rendered.text).toContain('Accepted Package: Full Day Wedding Coverage'); + expect(rendered.text).toContain('Included Deliverables:'); + expect(rendered.text).toContain('8 Hours of Continuous Coverage'); + expect(rendered.text).toContain('Second Photographer: €500.00'); + expect(rendered.text).toContain('Fine Art Album 30x30 (2x): €600.00'); + expect(rendered.text).toContain('Total Amount: €3,600.00'); + expect(rendered.text).toContain('Next Steps: We are preparing your booking contract.'); + + // HTML + expect(rendered.html).toContain(''); + expect(rendered.html).toContain('lang="en"'); + expect(rendered.html).toContain('Accepted Package'); + expect(rendered.html).toContain('8 Hours of Continuous Coverage'); + expect(rendered.html).toContain('Fine Art Album 30x30 (2x)'); + expect(rendered.html).toContain('€3,600.00'); + }); + + it('renders German client acceptance email when no add-ons were selected', () => { + const rendered = renderProposalAcceptedClientEmail({ + proposal: { + id: '01920000-0000-7000-8000-000000000011', + title: 'Portrait-Session', + packageName: 'Standard Portraitpaket', + includedItems: ['60 Minuten Shooting', '15 bearbeitete Fotos'], + selectedAddons: [], + totalMinor: 25000, + currency: 'EUR', + }, + clientName: 'Anna Schmidt', + studioName: 'Lichtwerk Fotostudio', + locale: 'de', + }); + + expect(rendered.subject).toBe('Bestätigung: Angebot angenommen — Portrait-Session'); + expect(rendered.text).toContain('Hallo Anna Schmidt,'); + expect(rendered.text).toContain('Vielen Dank für die Annahme unseres Angebots.'); + expect(rendered.text).toContain('Angenommenes Paket: Standard Portraitpaket'); + expect(rendered.text).toContain('Keine'); + expect(rendered.text).toContain('250,00'); + expect(rendered.text).toContain('Nächste Schritte: Wir bereiten Ihren Buchungsvertrag vor.'); + }); + + it('handles fallback values and missing fields gracefully in client confirmation', () => { + const rendered = renderProposalAcceptedClientEmail({ + proposal: { + id: '01920000-0000-7000-8000-000000000011', + title: null as unknown as string, + packageName: null, + totalMinor: 10000, + currency: 'EUR', + }, + clientName: null, + studioName: null, + locale: null, + }); + + expect(rendered.subject).toContain('Proposal'); + expect(rendered.text).toContain('€100.00'); + }); + }); + + describe('AC-6: renderProposalAcceptedOwnerEmail (Studio Owner Alert)', () => { + it('renders English owner alert with direct link to project studio workspace', () => { + const rendered = renderProposalAcceptedOwnerEmail({ + proposal: { + id: '01920000-0000-7000-8000-000000000020', + projectId: '01920000-0000-7000-8000-000000000099', + title: 'Emma & Liam Wedding', + packageName: 'Full Day Coverage', + totalMinor: 300000, + currency: 'EUR', + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma@example.com', + }, + clientName: 'Emma Watson', + studioName: 'Lumos Studio', + locale: 'en', + baseUrl: 'https://app.example.org', + }); + + // Subject + expect(rendered.subject).toBe('Proposal Accepted: Emma & Liam Wedding'); + + // Plain text + expect(rendered.text).toContain( + 'Great news! The proposal "Emma & Liam Wedding" has been accepted by Emma Watson.', + ); + expect(rendered.text).toContain('Package: Full Day Coverage'); + expect(rendered.text).toContain('Total Amount: €3,000.00'); + expect(rendered.text).toContain('Accepted by: Emma Watson (emma@example.com)'); + expect(rendered.text).toContain( + 'https://app.example.org/projects/01920000-0000-7000-8000-000000000099', + ); + + // HTML + expect(rendered.html).toContain('View Project'); + expect(rendered.html).toContain( + 'https://app.example.org/projects/01920000-0000-7000-8000-000000000099', + ); + }); + + it('renders German owner alert with correct German strings and links', () => { + const rendered = renderProposalAcceptedOwnerEmail({ + proposal: { + id: '01920000-0000-7000-8000-000000000021', + projectId: '01920000-0000-7000-8000-000000000098', + title: 'Anna & Max Hochzeit', + packageName: 'Komplettpaket', + totalMinor: 350000, + currency: 'EUR', + acceptedByName: 'Anna Schmidt', + acceptedByEmail: 'anna@example.de', + }, + clientName: 'Anna Schmidt', + studioName: 'Lichtwerk', + locale: 'de', + baseUrl: 'https://app.example.de', + }); + + expect(rendered.subject).toBe('Angebot angenommen: Anna & Max Hochzeit'); + expect(rendered.text).toContain( + 'Gute Neuigkeiten! Das Angebot „Anna & Max Hochzeit“ wurde von Anna Schmidt angenommen.', + ); + expect(rendered.text).toContain('3.500,00'); + expect(rendered.text).toContain('Angenommen von: Anna Schmidt (anna@example.de)'); + expect(rendered.html).toContain('Projekt ansehen'); + expect(rendered.html).toContain( + 'https://app.example.de/projects/01920000-0000-7000-8000-000000000098', + ); + }); + + it('handles fallback values and missing fields gracefully in owner alert', () => { + const rendered = renderProposalAcceptedOwnerEmail({ + proposal: { + id: '01920000-0000-7000-8000-000000000021', + projectId: '01920000-0000-7000-8000-000000000098', + title: null as unknown as string, + packageName: null, + totalMinor: 10000, + currency: 'EUR', + acceptedByName: null, + acceptedByEmail: null, + }, + clientName: null, + studioName: null, + locale: null, + baseUrl: null, + }); + + expect(rendered.subject).toContain('Proposal'); + expect(rendered.text).toContain('/projects/01920000-0000-7000-8000-000000000098'); + }); + }); +}); diff --git a/tests/unit/proposal-export-service.test.ts b/tests/unit/proposal-export-service.test.ts new file mode 100644 index 0000000..66ff909 --- /dev/null +++ b/tests/unit/proposal-export-service.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); + +import { createIsolatedTestDatabase, type TestDatabaseInstance } from '../helpers/db-test-helper'; +import type { DbClient } from '@/server/db/client'; +import { user } from '@/server/db/schema/auth'; +import { clients, clientContacts } from '@/server/db/schema/clients'; +import { projects, pipelineStages } from '@/server/db/schema/projects'; +import { seedDefaultPipelineStages } from '@/server/db/seeds/pipeline-stages'; +import { packages, packageIncludedItems } from '@/server/db/schema/packages'; +import { proposals, proposalLineItems } from '@/server/db/schema/proposals'; +import { getAcceptedProposalForProject } from '@/server/proposals/service'; +import { generateUuidV7 } from '@/lib/id'; + +describe('TASK-0060: Downstream Contract & Accounting Export Query (AC-9)', () => { + let testDb: TestDatabaseInstance; + let dbClient: DbClient; + let testClientId: string; + let testProjectId: string; + let testPackageId: string; + + beforeEach(async () => { + testDb = await createIsolatedTestDatabase(); + dbClient = testDb.db as unknown as DbClient; + + await seedDefaultPipelineStages(dbClient); + const [stage] = await dbClient.select().from(pipelineStages).limit(1); + if (!stage) throw new Error('Stage must exist'); + + // Create Owner User + await dbClient.insert(user).values({ + id: generateUuidV7(), + name: 'Studio Owner', + email: 'owner@example.org', + role: 'owner', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Create Client + testClientId = generateUuidV7(); + await dbClient.insert(clients).values({ + id: testClientId, + kind: 'person', + displayName: 'Emma & Liam Watson', + preferredLocale: 'en', + createdAt: new Date(), + updatedAt: new Date(), + }); + + await dbClient.insert(clientContacts).values({ + id: generateUuidV7(), + clientId: testClientId, + givenName: 'Emma', + familyName: 'Watson', + email: 'emma@example.com', + isPrimary: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Create Project + testProjectId = generateUuidV7(); + await dbClient.insert(projects).values({ + id: testProjectId, + clientId: testClientId, + stageId: stage.id, + title: 'Emma & Liam Wedding Shoot', + projectTypeKey: 'wedding', + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Create Master Package & Included Items + testPackageId = generateUuidV7(); + await dbClient.insert(packages).values({ + id: testPackageId, + name: 'Full Day Master Wedding Package', + slug: 'full-day-master', + basePriceMinor: 250000, + currency: 'EUR', + taxRateBasisPoints: 1900, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await dbClient.insert(packageIncludedItems).values([ + { + id: generateUuidV7(), + packageId: testPackageId, + title: '8 Hours Continuous Coverage', + position: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: generateUuidV7(), + packageId: testPackageId, + title: 'Private Online Gallery with Downloads', + position: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + }); + + afterEach(async () => { + await testDb.destroy(); + }); + + it('AC-9: returns structured AcceptedProposalSummary for downstream E6 contract and E12 accounting interfaces', async () => { + const proposalId = generateUuidV7(); + const acceptedAt = new Date('2026-10-28T14:00:00Z'); + + // Insert accepted proposal + await dbClient.insert(proposals).values({ + id: proposalId, + projectId: testProjectId, + clientId: testClientId, + title: 'Wedding Proposal Emma & Liam', + status: 'accepted', + currency: 'EUR', + subtotalMinor: 290000, + taxMinor: 55100, + totalMinor: 345100, + tokenHash: 'dummy-sha256-hash-1', + acceptedAt, + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma@example.com', + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Insert line items + await dbClient.insert(proposalLineItems).values([ + { + id: generateUuidV7(), + proposalId, + packageId: testPackageId, + itemType: 'base_package', + name: 'Full Day Master Wedding Package', + unitPriceMinor: 250000, + quantity: 1, + isSelected: true, + position: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: generateUuidV7(), + proposalId, + itemType: 'addon', + name: 'Second Photographer', + unitPriceMinor: 40000, + quantity: 1, + isSelected: true, + position: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: generateUuidV7(), + proposalId, + itemType: 'addon', + name: 'Unselected Drone Coverage', + unitPriceMinor: 30000, + quantity: 1, + isSelected: false, // not selected by client! + position: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + const summary = await getAcceptedProposalForProject(testProjectId, dbClient); + + expect(summary).toBeDefined(); + expect(summary?.proposalId).toBe(proposalId); + expect(summary?.projectId).toBe(testProjectId); + expect(summary?.packageName).toBe('Full Day Master Wedding Package'); + expect(summary?.includedItems).toEqual([ + '8 Hours Continuous Coverage', + 'Private Online Gallery with Downloads', + ]); + expect(summary?.selectedAddons).toHaveLength(1); + expect(summary?.selectedAddons[0]).toEqual({ + name: 'Second Photographer', + quantity: 1, + unitPriceMinor: 40000, + }); + expect(summary?.subtotalMinor).toBe(290000); + expect(summary?.taxMinor).toBe(55100); + expect(summary?.totalMinor).toBe(345100); + expect(summary?.currency).toBe('EUR'); + expect(summary?.acceptedAt).toEqual(acceptedAt); + expect(summary?.acceptedByName).toBe('Emma Watson'); + expect(summary?.acceptedByEmail).toBe('emma@example.com'); + }); + + it('AC-9: returns null when project has no accepted proposal', async () => { + // Project with only a draft proposal + await dbClient.insert(proposals).values({ + id: generateUuidV7(), + projectId: testProjectId, + clientId: testClientId, + title: 'Draft Proposal', + status: 'draft', + currency: 'EUR', + tokenHash: 'dummy-sha256-hash-draft', + createdAt: new Date(), + updatedAt: new Date(), + }); + + const summary = await getAcceptedProposalForProject(testProjectId, dbClient); + expect(summary).toBeNull(); + }); + + it('AC-9: handles custom base package with no master package reference', async () => { + const proposalId = generateUuidV7(); + + await dbClient.insert(proposals).values({ + id: proposalId, + projectId: testProjectId, + clientId: testClientId, + title: 'Custom Commercial Proposal', + status: 'accepted', + currency: 'EUR', + subtotalMinor: 150000, + taxMinor: 28500, + totalMinor: 178500, + tokenHash: 'dummy-sha256-hash-custom', + acceptedAt: new Date(), + acceptedByName: 'Corporate Liaison', + acceptedByEmail: 'corp@example.com', + createdAt: new Date(), + updatedAt: new Date(), + }); + + await dbClient.insert(proposalLineItems).values({ + id: generateUuidV7(), + proposalId, + packageId: null, // custom package without catalogue reference + itemType: 'base_package', + name: 'Custom Studio Branding Shoot', + unitPriceMinor: 150000, + quantity: 1, + isSelected: true, + position: 0, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const summary = await getAcceptedProposalForProject(testProjectId, dbClient); + + expect(summary).toBeDefined(); + expect(summary?.packageName).toBe('Custom Studio Branding Shoot'); + expect(summary?.includedItems).toEqual([]); + expect(summary?.totalMinor).toBe(178500); + }); +}); diff --git a/tests/unit/proposal-schemas-validation.test.ts b/tests/unit/proposal-schemas-validation.test.ts new file mode 100644 index 0000000..997dcd2 --- /dev/null +++ b/tests/unit/proposal-schemas-validation.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest'; +import { + saveDraftProposalSchema, + sendProposalSchema, + revokeProposalTokenSchema, +} from '@/server/proposals/schema'; + +describe('TASK-0060: Proposal Schemas Validation Unit Tests (AC-7)', () => { + describe('AC-7: sendProposalSchema', () => { + it('accepts valid proposalId with future expiration date and optional recipient email', () => { + const valid = { + proposalId: '01920000-0000-7000-8000-000000000001', + validUntil: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + recipientEmail: 'client@example.org', + }; + + const parsed = sendProposalSchema.parse(valid); + expect(parsed.proposalId).toBe(valid.proposalId); + expect(parsed.validUntil).toBeInstanceOf(Date); + expect(parsed.recipientEmail).toBe('client@example.org'); + }); + + it('accepts valid proposalId without expiration date or recipient email', () => { + const valid = { + proposalId: '01920000-0000-7000-8000-000000000002', + }; + + const parsed = sendProposalSchema.parse(valid); + expect(parsed.proposalId).toBe(valid.proposalId); + expect(parsed.validUntil).toBeUndefined(); + expect(parsed.recipientEmail).toBeUndefined(); + }); + + it('AC-7: rejects expiration date in the past with localized schema error message', () => { + const pastDate = new Date(Date.now() - 60 * 1000).toISOString(); + const payload = { + proposalId: '01920000-0000-7000-8000-000000000003', + validUntil: pastDate, + }; + + const result = sendProposalSchema.safeParse(payload); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('Expiration date must be in the future'); + } + }); + + it('rejects invalid recipient email address format', () => { + const payload = { + proposalId: '01920000-0000-7000-8000-000000000004', + recipientEmail: 'not-an-email', + }; + + const result = sendProposalSchema.safeParse(payload); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('Invalid recipient email address'); + } + }); + + it('rejects non-UUID proposalId', () => { + const result = sendProposalSchema.safeParse({ + proposalId: 'invalid-uuid', + }); + expect(result.success).toBe(false); + }); + + it('strictly rejects unpermitted extra properties', () => { + const result = sendProposalSchema.safeParse({ + proposalId: '01920000-0000-7000-8000-000000000005', + unrecognizedField: 'malicious-data', + }); + expect(result.success).toBe(false); + }); + }); + + describe('AC-7: saveDraftProposalSchema', () => { + it('accepts valid draft proposal creation payload', () => { + const valid = { + projectId: '01920000-0000-7000-8000-000000000010', + clientId: '01920000-0000-7000-8000-000000000011', + title: 'New Wedding Proposal', + currency: 'EUR', + pricesIncludeTax: true, + isKleinunternehmer: false, + lineItems: [ + { + name: 'Base Package', + itemType: 'base_package' as const, + unitPriceMinor: 250000, + quantity: 1, + taxRateBasisPoints: 1900, + }, + ], + }; + + const parsed = saveDraftProposalSchema.parse(valid); + expect(parsed.title).toBe('New Wedding Proposal'); + expect(parsed.lineItems).toHaveLength(1); + }); + + it('AC-7: rejects draft with negative line-item prices', () => { + const payload = { + projectId: '01920000-0000-7000-8000-000000000020', + title: 'Draft Proposal with Negative Price', + lineItems: [ + { + name: 'Subsidized Package', + itemType: 'base_package' as const, + unitPriceMinor: -5000, + quantity: 1, + taxRateBasisPoints: 1900, + }, + ], + }; + + const result = saveDraftProposalSchema.safeParse(payload); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('Unit price cannot be negative'); + } + }); + + it('rejects draft with empty title', () => { + const result = saveDraftProposalSchema.safeParse({ + projectId: '01920000-0000-7000-8000-000000000021', + title: ' ', + }); + expect(result.success).toBe(false); + }); + + it('strictly rejects unpermitted extra properties', () => { + const result = saveDraftProposalSchema.safeParse({ + projectId: '01920000-0000-7000-8000-000000000022', + title: 'Valid Title', + forbiddenField: true, + }); + expect(result.success).toBe(false); + }); + }); + + describe('revokeProposalTokenSchema', () => { + it('accepts valid UUID proposalId', () => { + const parsed = revokeProposalTokenSchema.parse({ + proposalId: '01920000-0000-7000-8000-000000000030', + }); + expect(parsed.proposalId).toBe('01920000-0000-7000-8000-000000000030'); + }); + + it('rejects invalid proposalId UUID', () => { + const result = revokeProposalTokenSchema.safeParse({ + proposalId: 'not-a-uuid', + }); + expect(result.success).toBe(false); + }); + + it('strictly rejects extra fields', () => { + const result = revokeProposalTokenSchema.safeParse({ + proposalId: '01920000-0000-7000-8000-000000000031', + extra: 'not allowed', + }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/tests/unit/proposal-token.test.ts b/tests/unit/proposal-token.test.ts new file mode 100644 index 0000000..f39553b --- /dev/null +++ b/tests/unit/proposal-token.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { randomBytes, createHash } from 'node:crypto'; + +describe('TASK-0060: Proposal Secret Token & SHA-256 Hash Verification', () => { + it('AC-1: generates 32-byte (256-bit) cryptographically secure URL-safe base64 tokens', () => { + const rawToken = randomBytes(32).toString('base64url'); + + // 32 bytes encoded in base64url is 43 characters + expect(rawToken).toHaveLength(43); + // Matches URL-safe base64 character set (no +, /, or = padding) + expect(rawToken).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + it('AC-1: computes deterministic 64-character hex SHA-256 hash', () => { + const rawToken = randomBytes(32).toString('base64url'); + const hash1 = createHash('sha256').update(rawToken).digest('hex'); + const hash2 = createHash('sha256').update(rawToken).digest('hex'); + + expect(hash1).toHaveLength(64); + expect(hash1).toMatch(/^[a-f0-9]{64}$/); + expect(hash1).toBe(hash2); + }); + + it('AC-1: guarantees collision resistance across generated tokens and hashes', () => { + const tokenSet = new Set(); + const hashSet = new Set(); + const count = 1000; + + for (let i = 0; i < count; i++) { + const rawToken = randomBytes(32).toString('base64url'); + const hash = createHash('sha256').update(rawToken).digest('hex'); + + expect(tokenSet.has(rawToken)).toBe(false); + expect(hashSet.has(hash)).toBe(false); + + tokenSet.add(rawToken); + hashSet.add(hash); + } + + expect(tokenSet.size).toBe(count); + expect(hashSet.size).toBe(count); + }); + + it('AC-3: verifies that token revocation produces non-matching hash preventing previous link access', () => { + const originalToken = randomBytes(32).toString('base64url'); + const originalHash = createHash('sha256').update(originalToken).digest('hex'); + + const revokedFreshToken = randomBytes(32).toString('base64url'); + const revokedFreshHash = createHash('sha256').update(revokedFreshToken).digest('hex'); + + expect(originalToken).not.toBe(revokedFreshToken); + expect(originalHash).not.toBe(revokedFreshHash); + + // Old token hashed against new hash produces mismatch + const checkOldAgainstNew = createHash('sha256').update(originalToken).digest('hex'); + expect(checkOldAgainstNew).toBe(originalHash); + expect(checkOldAgainstNew).not.toBe(revokedFreshHash); + }); +}); From 81a653d7b0f8d990a1f59135a61824a2288f424f Mon Sep 17 00:00:00 2001 From: Ediminator <211899731+Ediminator@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:30:13 +0100 Subject: [PATCH 2/5] refactor(proposals): decouple accepted summary query and clean test tokens (TASK-0060) Signed-off-by: Ediminator <211899731+Ediminator@users.noreply.github.com> --- src/server/email/outbox-processor.ts | 2 +- src/server/proposals/accepted-summary.ts | 86 +++++++++++++++++++++ src/server/proposals/service.ts | 86 +-------------------- tests/unit/proposal-email-templates.test.ts | 10 +-- tests/unit/proposal-export-service.test.ts | 32 ++++++++ 5 files changed, 127 insertions(+), 89 deletions(-) create mode 100644 src/server/proposals/accepted-summary.ts diff --git a/src/server/email/outbox-processor.ts b/src/server/email/outbox-processor.ts index 41fcb4a..8859c48 100644 --- a/src/server/email/outbox-processor.ts +++ b/src/server/email/outbox-processor.ts @@ -37,7 +37,7 @@ import { renderProposalAcceptedOwnerEmail, getProposalEmailHeaders, } from './templates'; -import { getAcceptedProposalForProject } from '@/server/proposals/service'; +import { getAcceptedProposalForProject } from '@/server/proposals/accepted-summary'; export interface EmailOutboxBatchOptions { batchSize?: number; diff --git a/src/server/proposals/accepted-summary.ts b/src/server/proposals/accepted-summary.ts new file mode 100644 index 0000000..f5b621b --- /dev/null +++ b/src/server/proposals/accepted-summary.ts @@ -0,0 +1,86 @@ +import 'server-only'; +import { eq, and, desc, asc } from 'drizzle-orm'; +import { db as defaultDb, type DbClient } from '@/server/db/client'; +import { proposals, proposalLineItems } from '@/server/db/schema/proposals'; +import { packageIncludedItems } from '@/server/db/schema/packages'; + +export interface AcceptedProposalSummary { + proposalId: string; + projectId: string; + packageName: string; + includedItems: string[]; + selectedAddons: { name: string; quantity: number; unitPriceMinor: number }[]; + subtotalMinor: number; + taxMinor: number; + totalMinor: number; + currency: string; + acceptedAt: Date; + acceptedByName: string; + acceptedByEmail: string; +} + +/** + * Returns structured summary of accepted proposal on a project (ADR-0018 §4.1 / AC-9). + * Decoupled from Next.js request/auth contexts to ensure background worker bundle safety. + */ +export async function getAcceptedProposalForProject( + projectId: string, + tx?: DbClient, +): Promise { + const client = tx ?? defaultDb; + + const [proposal] = await client + .select() + .from(proposals) + .where(and(eq(proposals.projectId, projectId), eq(proposals.status, 'accepted'))) + .orderBy(desc(proposals.acceptedAt), desc(proposals.createdAt)) + .limit(1); + + if (!proposal) { + return null; + } + + const items = await client + .select() + .from(proposalLineItems) + .where(eq(proposalLineItems.proposalId, proposal.id)) + .orderBy(asc(proposalLineItems.position), asc(proposalLineItems.createdAt)); + + const basePackageItem = items.find((i) => i.itemType === 'base_package'); + let includedItems: string[] = []; + + if (basePackageItem?.packageId) { + const includedRows = await client + .select({ title: packageIncludedItems.title }) + .from(packageIncludedItems) + .where(eq(packageIncludedItems.packageId, basePackageItem.packageId)) + .orderBy(asc(packageIncludedItems.position), asc(packageIncludedItems.createdAt)); + + includedItems = includedRows.map((r) => r.title); + } + + const packageName = basePackageItem?.name ?? proposal.title; + + const selectedAddons = items + .filter((i) => (i.itemType === 'addon' || i.itemType === 'custom') && i.isSelected) + .map((i) => ({ + name: i.name, + quantity: i.quantity, + unitPriceMinor: i.unitPriceMinor, + })); + + return { + proposalId: proposal.id, + projectId: proposal.projectId, + packageName, + includedItems, + selectedAddons, + subtotalMinor: proposal.subtotalMinor, + taxMinor: proposal.taxMinor, + totalMinor: proposal.totalMinor, + currency: proposal.currency, + acceptedAt: proposal.acceptedAt ?? proposal.updatedAt, + acceptedByName: proposal.acceptedByName ?? '', + acceptedByEmail: proposal.acceptedByEmail ?? '', + }; +} diff --git a/src/server/proposals/service.ts b/src/server/proposals/service.ts index 014c23b..ac62423 100644 --- a/src/server/proposals/service.ts +++ b/src/server/proposals/service.ts @@ -1,7 +1,7 @@ import 'server-only'; import { randomBytes, createHash } from 'node:crypto'; -import { eq, and, desc, asc } from 'drizzle-orm'; -import { db as defaultDb, type DbClient } from '@/server/db/client'; +import { eq, desc, asc } from 'drizzle-orm'; +import { type DbClient } from '@/server/db/client'; import { generateUuidV7 } from '@/lib/id'; import { proposals, @@ -9,7 +9,6 @@ import { type Proposal, type ProposalLineItem, } from '@/server/db/schema/proposals'; -import { packageIncludedItems } from '@/server/db/schema/packages'; import { projects } from '@/server/db/schema/projects'; import { clientContacts } from '@/server/db/schema/clients'; import { resolveRepoAuth, type RepoAuthInput } from '@/server/auth/repo-auth'; @@ -34,20 +33,7 @@ import { type ProposalWithLineItems, } from './repo'; -export interface AcceptedProposalSummary { - proposalId: string; - projectId: string; - packageName: string; - includedItems: string[]; - selectedAddons: { name: string; quantity: number; unitPriceMinor: number }[]; - subtotalMinor: number; - taxMinor: number; - totalMinor: number; - currency: string; - acceptedAt: Date; - acceptedByName: string; - acceptedByEmail: string; -} +export { getAcceptedProposalForProject, type AcceptedProposalSummary } from './accepted-summary'; export interface SendProposalResult { proposalId: string; @@ -60,72 +46,6 @@ export interface RevokeProposalTokenActionResult { revokedAt: Date; } -/** - * Returns structured summary of accepted proposal on a project (ADR-0018 §4.1 / AC-9). - * Forwards-compatible with E6 contract merge fields and E12 Lexware invoicing. - */ -export async function getAcceptedProposalForProject( - projectId: string, - tx?: DbClient, -): Promise { - const client = tx ?? defaultDb; - - const [proposal] = await client - .select() - .from(proposals) - .where(and(eq(proposals.projectId, projectId), eq(proposals.status, 'accepted'))) - .orderBy(desc(proposals.acceptedAt), desc(proposals.createdAt)) - .limit(1); - - if (!proposal) { - return null; - } - - const items = await client - .select() - .from(proposalLineItems) - .where(eq(proposalLineItems.proposalId, proposal.id)) - .orderBy(asc(proposalLineItems.position), asc(proposalLineItems.createdAt)); - - const basePackageItem = items.find((i) => i.itemType === 'base_package'); - let includedItems: string[] = []; - - if (basePackageItem?.packageId) { - const includedRows = await client - .select({ title: packageIncludedItems.title }) - .from(packageIncludedItems) - .where(eq(packageIncludedItems.packageId, basePackageItem.packageId)) - .orderBy(asc(packageIncludedItems.position), asc(packageIncludedItems.createdAt)); - - includedItems = includedRows.map((r) => r.title); - } - - const packageName = basePackageItem?.name ?? proposal.title; - - const selectedAddons = items - .filter((i) => (i.itemType === 'addon' || i.itemType === 'custom') && i.isSelected) - .map((i) => ({ - name: i.name, - quantity: i.quantity, - unitPriceMinor: i.unitPriceMinor, - })); - - return { - proposalId: proposal.id, - projectId: proposal.projectId, - packageName, - includedItems, - selectedAddons, - subtotalMinor: proposal.subtotalMinor, - taxMinor: proposal.taxMinor, - totalMinor: proposal.totalMinor, - currency: proposal.currency, - acceptedAt: proposal.acceptedAt ?? proposal.updatedAt, - acceptedByName: proposal.acceptedByName ?? '', - acceptedByEmail: proposal.acceptedByEmail ?? '', - }; -} - /** * Creates a new draft proposal or updates an existing draft proposal and line items transactionally (TASK-0060 / AC-8). */ diff --git a/tests/unit/proposal-email-templates.test.ts b/tests/unit/proposal-email-templates.test.ts index d6cf06d..d7907ea 100644 --- a/tests/unit/proposal-email-templates.test.ts +++ b/tests/unit/proposal-email-templates.test.ts @@ -32,7 +32,7 @@ describe('TASK-0060: Proposal Email Templates Unit Tests (AC-2, AC-4, AC-6)', () totalMinor: 250000, currency: 'EUR', validUntil: new Date('2026-12-31T12:00:00Z'), - token: 'secure-token-123', + token: 'tok-123', }, clientName: 'Emma Watson', studioName: 'Lumos Photography', @@ -51,7 +51,7 @@ describe('TASK-0060: Proposal Email Templates Unit Tests (AC-2, AC-4, AC-6)', () expect(rendered.text).toContain('Proposal: Wedding Photography — Emma & Liam'); expect(rendered.text).toContain('€2,500.00'); expect(rendered.text).toContain('December 31, 2026'); - expect(rendered.text).toContain('https://studio.example.org/p/secure-token-123'); + expect(rendered.text).toContain('https://studio.example.org/p/tok-123'); expect(rendered.text).toContain('This is an automated transactional notification.'); // HTML @@ -61,7 +61,7 @@ describe('TASK-0060: Proposal Email Templates Unit Tests (AC-2, AC-4, AC-6)', () expect(rendered.html).toContain('Lumos Photography'); expect(rendered.html).toContain('Hello Emma Watson,'); expect(rendered.html).toContain('€2,500.00'); - expect(rendered.html).toContain('https://studio.example.org/p/secure-token-123'); + expect(rendered.html).toContain('https://studio.example.org/p/tok-123'); expect(rendered.html).toContain('View Proposal'); }); @@ -73,7 +73,7 @@ describe('TASK-0060: Proposal Email Templates Unit Tests (AC-2, AC-4, AC-6)', () totalMinor: 320000, currency: 'EUR', validUntil: new Date('2026-11-15T12:00:00Z'), - token: 'token-de-456', + token: 'tok-456', }, clientName: 'Anna Schmidt', studioName: 'Lichtwerk Fotostudio', @@ -94,7 +94,7 @@ describe('TASK-0060: Proposal Email Templates Unit Tests (AC-2, AC-4, AC-6)', () expect(rendered.text).toContain('Angebot: Hochzeitsfotografie — Anna & Max'); expect(rendered.text).toContain('3.200,00'); expect(rendered.text).toContain('15. November 2026'); - expect(rendered.text).toContain('https://lichtwerk.example.de/p/token-de-456'); + expect(rendered.text).toContain('https://lichtwerk.example.de/p/tok-456'); // HTML expect(rendered.html).toContain('lang="de"'); diff --git a/tests/unit/proposal-export-service.test.ts b/tests/unit/proposal-export-service.test.ts index 66ff909..5568252 100644 --- a/tests/unit/proposal-export-service.test.ts +++ b/tests/unit/proposal-export-service.test.ts @@ -259,4 +259,36 @@ describe('TASK-0060: Downstream Contract & Accounting Export Query (AC-9)', () = expect(summary?.includedItems).toEqual([]); expect(summary?.totalMinor).toBe(178500); }); + + it('AC-9: handles fallback values when accepted proposal has no base package item or nullable acceptance details', async () => { + const proposalId = generateUuidV7(); + const now = new Date(); + + await dbClient.insert(proposals).values({ + id: proposalId, + projectId: testProjectId, + clientId: testClientId, + title: 'A La Carte Only Proposal', + status: 'accepted', + currency: 'EUR', + subtotalMinor: 50000, + taxMinor: 9500, + totalMinor: 59500, + tokenHash: 'dummy-sha256-hash-alacarte', + acceptedAt: null, + acceptedByName: null, + acceptedByEmail: null, + createdAt: now, + updatedAt: now, + }); + + const summary = await getAcceptedProposalForProject(testProjectId, dbClient); + + expect(summary).toBeDefined(); + expect(summary?.packageName).toBe('A La Carte Only Proposal'); + expect(summary?.includedItems).toEqual([]); + expect(summary?.acceptedByName).toBe(''); + expect(summary?.acceptedByEmail).toBe(''); + expect(summary?.acceptedAt).toEqual(now); + }); }); From 0891fea964314a8c43d9704ec6970f17197955c3 Mon Sep 17 00:00:00 2001 From: Ediminator <211899731+Ediminator@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:49:23 +0100 Subject: [PATCH 3/5] test(proposals): use synthetic example.org emails in integration test fixtures (TASK-0060) Signed-off-by: Ediminator <211899731+Ediminator@users.noreply.github.com> --- tests/integration/proposal-send-action.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/proposal-send-action.test.ts b/tests/integration/proposal-send-action.test.ts index cea295b..163e0a8 100644 --- a/tests/integration/proposal-send-action.test.ts +++ b/tests/integration/proposal-send-action.test.ts @@ -97,7 +97,7 @@ describe('TASK-0060: Proposals Email Outbox, Templates, Send & Revoke Actions In .values({ id: generateUuidV7(), name: 'Studio Owner', - email: 'owner@solsticelight.com', + email: 'owner@example.org', role: 'owner', emailVerified: true, createdAt: new Date(), @@ -600,7 +600,7 @@ describe('TASK-0060: Proposals Email Outbox, Templates, Send & Revoke Actions In expect(clientItem?.status).toBe('PENDING'); expect(ownerItem).toBeDefined(); - expect(ownerItem?.recipient).toBe('owner@solsticelight.com'); + expect(ownerItem?.recipient).toBe('owner@example.org'); expect(ownerItem?.status).toBe('PENDING'); // 4. Process outbox items and verify email content and RFC 3834 headers @@ -630,7 +630,7 @@ describe('TASK-0060: Proposals Email Outbox, Templates, Send & Revoke Actions In (m) => (m.to as { address: string }).address === 'sophia.accepted@example.com', ); const ownerMail = sentMails.find( - (m) => (m.to as { address: string }).address === 'owner@solsticelight.com', + (m) => (m.to as { address: string }).address === 'owner@example.org', ); expect(clientMail).toBeDefined(); @@ -808,7 +808,7 @@ describe('TASK-0060: Proposals Email Outbox, Templates, Send & Revoke Actions In .values({ id: generateUuidV7(), name: 'Staff Member', - email: 'staff@solsticelight.com', + email: 'staff@example.org', role: 'member', emailVerified: true, createdAt: new Date(), From 45039aec9274275135ed94de13e934b934ec5b93 Mon Sep 17 00:00:00 2001 From: Ediminator <211899731+Ediminator@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:49:02 +0100 Subject: [PATCH 4/5] fix(proposals): enforce absolute URLs in email templates and suppress revoked deliveries (TASK-0060) Signed-off-by: Ediminator <211899731+Ediminator@users.noreply.github.com> --- src/server/email/outbox-processor.ts | 37 ++++++++++ .../templates/proposal-accepted-owner.ts | 3 +- src/server/email/templates/proposal-sent.ts | 36 +++++++--- .../integration/proposal-send-action.test.ts | 71 ++++++++++++++++++- tests/unit/proposal-email-templates.test.ts | 63 ++++++++++++++++ 5 files changed, 198 insertions(+), 12 deletions(-) diff --git a/src/server/email/outbox-processor.ts b/src/server/email/outbox-processor.ts index 8859c48..c93c5a8 100644 --- a/src/server/email/outbox-processor.ts +++ b/src/server/email/outbox-processor.ts @@ -264,6 +264,43 @@ export async function processEmailOutboxBatch( continue; } + // Suppress proposal_sent delivery if token is revoked or proposal is no longer in 'sent' status (I1-S02 / ASVS V2.2.1) + if ( + item.kind === 'proposal_sent' && + (proposal.tokenRevokedAt !== null || proposal.status !== 'sent') + ) { + logger.info( + { + outboxId: item.id, + proposalId: proposal.id, + status: proposal.status, + isRevoked: proposal.tokenRevokedAt !== null, + }, + 'Suppressing proposal_sent email for revoked or non-sent proposal', + ); + await deleteEmailOutboxItem(item.id, dbClient); + suppressedCount += 1; + continue; + } + + // Suppress proposal_accepted_* if proposal is no longer in 'accepted' status + if ( + (item.kind === 'proposal_accepted_client' || item.kind === 'proposal_accepted_owner') && + proposal.status !== 'accepted' + ) { + logger.info( + { + outboxId: item.id, + proposalId: proposal.id, + status: proposal.status, + }, + 'Suppressing proposal_accepted email for non-accepted proposal', + ); + await deleteEmailOutboxItem(item.id, dbClient); + suppressedCount += 1; + continue; + } + const [clientRow] = await dbClient .select() .from(clients) diff --git a/src/server/email/templates/proposal-accepted-owner.ts b/src/server/email/templates/proposal-accepted-owner.ts index c540e97..b5c40bb 100644 --- a/src/server/email/templates/proposal-accepted-owner.ts +++ b/src/server/email/templates/proposal-accepted-owner.ts @@ -64,7 +64,8 @@ export function renderProposalAcceptedOwnerEmail({ const formattedAmount = formatCurrency(proposal.totalMinor, proposal.currency, activeLocale); - const safeBaseUrl = (baseUrl ?? '').replaceAll(/\/+$/g, ''); + const activeBaseUrl = baseUrl?.trim() ? baseUrl : process.env.NEXT_PUBLIC_APP_URL; + const safeBaseUrl = (activeBaseUrl ?? '').replaceAll(/\/+$/g, ''); const projectUrl = safeBaseUrl ? `${safeBaseUrl}/projects/${proposal.projectId}` : `/projects/${proposal.projectId}`; diff --git a/src/server/email/templates/proposal-sent.ts b/src/server/email/templates/proposal-sent.ts index 4be2690..2b0657d 100644 --- a/src/server/email/templates/proposal-sent.ts +++ b/src/server/email/templates/proposal-sent.ts @@ -44,6 +44,18 @@ function escapeHtml(input: string): string { .replaceAll("'", '''); } +/** + * Checks if a URL string is an absolute HTTP or HTTPS URL (I1-S01). + */ +function isAbsoluteHttpUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + /** * Renders the localized proposal-sent email in both plain-text and responsive HTML (TASK-0060 / AC-1, AC-2, AC-6). */ @@ -77,15 +89,21 @@ export function renderProposalSentEmail({ } } - // Construct proposal acceptance link - const safeBaseUrl = (baseUrl ?? '').replaceAll(/\/+$/g, ''); - const rawUrl = - proposal.tokenUrl ?? - (proposal.token - ? safeBaseUrl - ? `${safeBaseUrl}/p/${proposal.token}` - : `/p/${proposal.token}` - : ''); + // Construct proposal acceptance link (I1-S01 / ASVS V1.2.1 / V3.1.1) + const activeBaseUrl = baseUrl?.trim() ? baseUrl : process.env.NEXT_PUBLIC_APP_URL; + const safeBaseUrl = (activeBaseUrl ?? '').replaceAll(/\/+$/g, ''); + let rawUrl = ''; + if (proposal.tokenUrl) { + if (isAbsoluteHttpUrl(proposal.tokenUrl)) { + rawUrl = proposal.tokenUrl; + } else { + const safePath = proposal.tokenUrl.replaceAll(/^\/+/g, ''); + rawUrl = safePath ? (safeBaseUrl ? `${safeBaseUrl}/${safePath}` : `/${safePath}`) : ''; + } + } else if (proposal.token) { + const safeToken = proposal.token.replaceAll(/^\/+/g, ''); + rawUrl = safeToken ? (safeBaseUrl ? `${safeBaseUrl}/p/${safeToken}` : `/p/${safeToken}`) : ''; + } const subject = dict.subject .replace('{title}', sanitizedTitle) diff --git a/tests/integration/proposal-send-action.test.ts b/tests/integration/proposal-send-action.test.ts index 163e0a8..4c4e808 100644 --- a/tests/integration/proposal-send-action.test.ts +++ b/tests/integration/proposal-send-action.test.ts @@ -460,9 +460,9 @@ describe('TASK-0060: Proposals Email Outbox, Templates, Send & Revoke Actions In expect(dispatched.to).toEqual({ address: 'sophia@example.com' }); expect(dispatched.subject).toContain('Fine Art Session'); expect(dispatched.subject).toContain('Solstice Light Studio'); - expect(dispatched.text).toContain(`/p/${sendRes.data.rawToken}`); + expect(dispatched.text).toContain(`http://localhost:3000/p/${sendRes.data.rawToken}`); expect(dispatched.text).toContain('€2,500.00'); - expect(dispatched.html).toContain(`/p/${sendRes.data.rawToken}`); + expect(dispatched.html).toContain(`href="http://localhost:3000/p/${sendRes.data.rawToken}"`); expect(dispatched.html).toContain('View Proposal'); expect(dispatched.headers?.['Auto-Submitted']).toBe('auto-generated'); expect(dispatched.headers?.Precedence).toBe('bulk'); @@ -474,6 +474,73 @@ describe('TASK-0060: Proposals Email Outbox, Templates, Send & Revoke Actions In .where(eq(emailOutbox.proposalId, draftRes.data.id)); expect(remainingOutbox).toHaveLength(0); }); + + it('I1-S02: outbox worker suppresses proposal_sent emails when proposal token is revoked before delivery', async () => { + // 1. Create and send proposal + const draftRes = await saveDraftProposalAction( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Engagement Shoot', + lineItems: [ + { + name: 'Couples Session', + unitPriceMinor: 80000, + quantity: 1, + taxRateBasisPoints: 1900, + }, + ], + }, + dbClient, + ); + if (!draftRes.success) throw new Error('Draft failed'); + + const sendRes = await sendProposalAction( + { + proposalId: draftRes.data.id, + validUntil: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + }, + dbClient, + ); + if (!sendRes.success) throw new Error('Send failed'); + + // 2. Owner immediately revokes token before outbox processing + const revokeRes = await revokeProposalTokenAction( + { + proposalId: draftRes.data.id, + }, + dbClient, + ); + expect(revokeRes.success).toBe(true); + + // 3. Set up mock transporter + const sentMails: unknown[] = []; + const testTransporter = nodemailer.createTransport({ + streamTransport: true, + buffer: true, + }); + vi.spyOn(testTransporter, 'sendMail').mockImplementation((mailOptions) => { + sentMails.push(mailOptions); + return Promise.resolve({ messageId: 'msg-should-not-be-sent' }) as never; + }); + + // 4. Run outbox worker + const batchResult = await processEmailOutboxBatch({ + dbClient, + transporter: testTransporter, + }); + + // 5. Verify email was suppressed and not sent via SMTP + expect(batchResult.suppressedCount).toBeGreaterThanOrEqual(1); + expect(sentMails).toHaveLength(0); + + // 6. Verify outbox row deleted upon suppression + const remainingOutbox = await dbClient + .select() + .from(emailOutbox) + .where(eq(emailOutbox.proposalId, draftRes.data.id)); + expect(remainingOutbox).toHaveLength(0); + }); }); // AC-3 diff --git a/tests/unit/proposal-email-templates.test.ts b/tests/unit/proposal-email-templates.test.ts index d7907ea..7b5f147 100644 --- a/tests/unit/proposal-email-templates.test.ts +++ b/tests/unit/proposal-email-templates.test.ts @@ -142,6 +142,69 @@ describe('TASK-0060: Proposal Email Templates Unit Tests (AC-2, AC-4, AC-6)', () expect(rendered.subject).toContain('Proposal'); expect(rendered.text).toContain('/p/token123'); }); + + it('I1-S01: prepends baseUrl to relative tokenUrl (/p/[token]) so rendered link is absolute', () => { + const rendered = renderProposalSentEmail({ + proposal: { + id: '01920000-0000-7000-8000-000000000001', + title: 'Proposal with relative tokenUrl', + totalMinor: 150000, + currency: 'EUR', + tokenUrl: '/p/tok-relative-456', + }, + clientName: 'Jane Doe', + studioName: 'Studio Doe', + locale: 'en', + baseUrl: 'https://studio.example.org', + }); + + expect(rendered.text).toContain('https://studio.example.org/p/tok-relative-456'); + expect(rendered.text).not.toMatch(/View proposal:\s+\/p\//); + expect(rendered.html).toContain('href="https://studio.example.org/p/tok-relative-456"'); + }); + + it('I1-S01: preserves already absolute tokenUrl', () => { + const rendered = renderProposalSentEmail({ + proposal: { + id: '01920000-0000-7000-8000-000000000001', + title: 'Proposal with absolute tokenUrl', + totalMinor: 150000, + currency: 'EUR', + tokenUrl: 'https://custom-domain.example.org/p/tok-abs-789', + }, + clientName: 'Jane Doe', + studioName: 'Studio Doe', + locale: 'en', + baseUrl: 'https://studio.example.org', + }); + + expect(rendered.text).toContain('https://custom-domain.example.org/p/tok-abs-789'); + expect(rendered.html).toContain('href="https://custom-domain.example.org/p/tok-abs-789"'); + }); + + it('I1-S01: falls back to NEXT_PUBLIC_APP_URL when baseUrl is omitted', () => { + const origUrl = process.env.NEXT_PUBLIC_APP_URL; + try { + process.env.NEXT_PUBLIC_APP_URL = 'https://app.example.com'; + const rendered = renderProposalSentEmail({ + proposal: { + id: '01920000-0000-7000-8000-000000000001', + title: 'Proposal with env fallback', + totalMinor: 150000, + currency: 'EUR', + tokenUrl: '/p/tok-env-fallback', + }, + clientName: 'Jane Doe', + studioName: 'Studio Doe', + locale: 'en', + }); + + expect(rendered.text).toContain('https://app.example.com/p/tok-env-fallback'); + expect(rendered.html).toContain('href="https://app.example.com/p/tok-env-fallback"'); + } finally { + process.env.NEXT_PUBLIC_APP_URL = origUrl; + } + }); }); describe('AC-6: renderProposalAcceptedClientEmail (Client Acceptance Confirmation)', () => { From 927f3ff5a18689f64b87cd9afb9cc7cec07c5863 Mon Sep 17 00:00:00 2001 From: Ediminator <211899731+Ediminator@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:05:55 +0100 Subject: [PATCH 5/5] test(clients): increase AC-10 benchmark timeout under full coverage load Signed-off-by: Ediminator <211899731+Ediminator@users.noreply.github.com> --- tests/integration/ac-1-to-ac-13-tags-and-search.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/ac-1-to-ac-13-tags-and-search.test.ts b/tests/integration/ac-1-to-ac-13-tags-and-search.test.ts index bc30ae6..e04bc40 100644 --- a/tests/integration/ac-1-to-ac-13-tags-and-search.test.ts +++ b/tests/integration/ac-1-to-ac-13-tags-and-search.test.ts @@ -665,7 +665,7 @@ describe('TASK-0010: Client tags and search (AC-1 through AC-13)', () => { const p95Duration = bestLatencies[p95Index] ?? 0; expect(p95Duration).toBeLessThanOrEqual(300); - }, 120_000); + }, 240_000); // AC-11: Authorization boundaries (API key with clients:read cannot write tags, records denied audit; unauthenticated returns UNAUTHORIZED) it('AC-11: returns FORBIDDEN + denied audit event for read-only API key and UNAUTHORIZED without auth', async () => {