diff --git a/src/components/pipelines/deal-form.tsx b/src/components/pipelines/deal-form.tsx index 1027a6f..8e63a80 100644 --- a/src/components/pipelines/deal-form.tsx +++ b/src/components/pipelines/deal-form.tsx @@ -79,7 +79,9 @@ export function DealForm({ setTitle(deal.title); setValue(String(deal.value ?? "")); setCurrency(deal.currency || "USD"); - setContactId(deal.contact_id); + // contact_id is nullable when the contact has been deleted + // (migration 004: ON DELETE SET NULL). "" means "no selection". + setContactId(deal.contact_id ?? ""); setStageId(deal.stage_id); setAssignedTo(deal.assigned_to ?? ""); setExpectedCloseDate(deal.expected_close_date ?? ""); diff --git a/src/types/index.ts b/src/types/index.ts index aed01e8..81be69e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -141,7 +141,11 @@ export interface Deal { user_id: string; pipeline_id: string; stage_id: string; - contact_id: string; + /** + * Nullable after migration 004 — becomes NULL when the referenced + * contact is deleted (ON DELETE SET NULL). History preserved. + */ + contact_id: string | null; conversation_id?: string; assigned_to?: string; title: string; @@ -182,7 +186,12 @@ export interface Broadcast { export interface BroadcastRecipient { id: string; broadcast_id: string; - contact_id: string; + /** + * Nullable after migration 004 — becomes NULL when the referenced + * contact is deleted (ON DELETE SET NULL). History preserved; the + * UI renders "Unknown" for orphaned rows. + */ + contact_id: string | null; status: RecipientStatus; sent_at?: string; delivered_at?: string; diff --git a/supabase/migrations/004_contact_delete_set_null.sql b/supabase/migrations/004_contact_delete_set_null.sql new file mode 100644 index 0000000..f326d12 --- /dev/null +++ b/supabase/migrations/004_contact_delete_set_null.sql @@ -0,0 +1,65 @@ +-- ============================================================ +-- Allow contact deletion without wiping history. +-- +-- broadcast_recipients.contact_id and deals.contact_id were declared +-- NOT NULL REFERENCES contacts(id) with no ON DELETE action, so +-- Postgres defaults to NO ACTION. The first time a user tried to +-- delete a contact that had ever received a broadcast or been +-- attached to a deal, the delete failed with: +-- +-- ERROR 23503: update or delete on table "contacts" violates +-- foreign key constraint ... on table +-- +-- CASCADE is the wrong fix — it would silently wipe historical +-- broadcast recipient rows (breaking audit + retroactively moving +-- broadcasts.sent_count / delivered_count / read_count etc. via the +-- aggregate trigger) and deal rows. +-- +-- SET NULL is the right fix: history rows survive with a NULL +-- contact_id. The UI is already null-safe (contact?.name ?? 'Unknown', +-- contact?.phone, etc.). +-- +-- Idempotent — safe to run multiple times. +-- ============================================================ + +-- ── broadcast_recipients.contact_id ──────────────────────────── +ALTER TABLE broadcast_recipients + ALTER COLUMN contact_id DROP NOT NULL; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'broadcast_recipients_contact_id_fkey' + AND conrelid = 'broadcast_recipients'::regclass + ) THEN + ALTER TABLE broadcast_recipients + DROP CONSTRAINT broadcast_recipients_contact_id_fkey; + END IF; +END $$; + +ALTER TABLE broadcast_recipients + ADD CONSTRAINT broadcast_recipients_contact_id_fkey + FOREIGN KEY (contact_id) REFERENCES contacts(id) + ON DELETE SET NULL; + +-- ── deals.contact_id ─────────────────────────────────────────── +ALTER TABLE deals + ALTER COLUMN contact_id DROP NOT NULL; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'deals_contact_id_fkey' + AND conrelid = 'deals'::regclass + ) THEN + ALTER TABLE deals + DROP CONSTRAINT deals_contact_id_fkey; + END IF; +END $$; + +ALTER TABLE deals + ADD CONSTRAINT deals_contact_id_fkey + FOREIGN KEY (contact_id) REFERENCES contacts(id) + ON DELETE SET NULL;