From ae17c8dead43e2cb690219f4fcbc9072a693ebd5 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 27 Aug 2026 03:43:54 -0700 Subject: [PATCH] feat: give models.AddContact an optional Subscribed pointer and honour it in the pg_contact upsert (passed twice with explicit boolean casts because one placeholder cannot serve both the INSERT value and the DO UPDATE set without tripping Postgres parameter inference), where nil now means leave the flag alone, and stop the same upsert erasing a populated first_name, last_name, company or phone when the incoming row's cell is blank so re-importing a partial export enriches contacts instead of wiping them --- internal/models/contact.go | 6 ++++++ internal/repository/pg_contact.go | 20 ++++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/internal/models/contact.go b/internal/models/contact.go index b99a2f6a..59c3baa4 100644 --- a/internal/models/contact.go +++ b/internal/models/contact.go @@ -321,6 +321,12 @@ type AddContact struct { Categories []string `json:"categories"` CustomFields map[string]string `json:"custom_fields"` + + // Subscribed is the marketing-consent flag to store. nil means "don't + // decide": a new contact defaults to subscribed, an existing one keeps + // whatever it already had. Set explicitly by the importer when the file + // carries a subscribed column. + Subscribed *bool `json:"subscribed,omitempty"` } type SearchContactsFilterType string diff --git a/internal/repository/pg_contact.go b/internal/repository/pg_contact.go index 294afffb..12de55a4 100644 --- a/internal/repository/pg_contact.go +++ b/internal/repository/pg_contact.go @@ -232,21 +232,29 @@ func (r *contactRepository) Add(ctx context.Context, userID string, orgID uuid.U insertBatch := pgx.Batch{} for _, lead := range normalized { insertBatch.Queue( + // $9 and $10 are the same value: a parameter used both as an + // INSERT value and inside the DO UPDATE set gives Postgres two + // inference sites for one placeholder, so it is passed twice with + // explicit casts. NULL means "leave the flag alone". `INSERT INTO contacts ( - id, user_id, organization_id, first_name, last_name, email, company, phone, custom_fields + id, user_id, organization_id, first_name, last_name, email, company, phone, custom_fields, subscribed ) VALUES ( - gen_random_uuid(), $1, $2, $3, $4, LOWER($5), $6, $7, $8 + gen_random_uuid(), $1, $2, $3, $4, LOWER($5), $6, $7, $8, COALESCE($9::boolean, TRUE) ) ON CONFLICT (user_id, (LOWER(email))) DO UPDATE SET organization_id = COALESCE(contacts.organization_id, EXCLUDED.organization_id), - first_name = EXCLUDED.first_name, - last_name = EXCLUDED.last_name, - company = EXCLUDED.company, - phone = EXCLUDED.phone, + -- Enrich, never erase: a blank cell in a re-imported file must + -- not wipe a name we already have. + first_name = COALESCE(NULLIF(EXCLUDED.first_name, ''), contacts.first_name), + last_name = COALESCE(NULLIF(EXCLUDED.last_name, ''), contacts.last_name), + company = COALESCE(NULLIF(EXCLUDED.company, ''), contacts.company), + phone = COALESCE(NULLIF(EXCLUDED.phone, ''), contacts.phone), custom_fields = contacts.custom_fields || EXCLUDED.custom_fields, + subscribed = COALESCE($10::boolean, contacts.subscribed), updated_at = NOW() RETURNING id, first_name, last_name, email, company, phone, custom_fields, subscribed, updated_at, created_at`, userID, orgID, lead.FirstName, lead.LastName, lead.Email, lead.Company, lead.Phone, lead.CustomFields, + lead.Subscribed, lead.Subscribed, ) }