fix(journeys): lazy-upsert contact row so session persistence never violates FK (EVO-1929)#103
Conversation
… never violates contact FK (EVO-1929) journey_sessions.contact_id carries FK_journey_sessions_contact_id to evo_campaign.contacts (ON DELETE CASCADE), but the evo-flow community surface never populates that table (ContactsService/ContactsClientService are HTTP proxies to the CRM). EVO-1892 made only the journey START path swallow the FK violation (best-effort persist); the per-node runtime writes from the Temporal activity still go through the durable (throwing) persist path, so the first per-node session save aborts the journey at node 1 for any contact not pre-seeded into evo_campaign.contacts. Fix (option b — lazy upsert): persistToDatabase ensures a minimal contacts row (id only) via INSERT ... ON CONFLICT (id) DO NOTHING before saving the session. The contacts table requires only id (every other column has a DB default or is nullable), so an id-only row is valid; the ON CONFLICT keeps it idempotent and never clobbers a real CRM-synced contact. This resolves the root cause on ALL write paths (start and runtime) while preserving referential integrity, instead of relaxing the FK.
There was a problem hiding this comment.
Sorry @daniloleonecarneiro, you have reached your weekly rate limit of 500000 diff characters.
Please try again later or upgrade to continue using Sourcery
dpaes
left a comment
There was a problem hiding this comment.
Approved (merge held)
Verified the fix end-to-end. ensureContactRow runs unconditionally inside persistToDatabase — the single funnel for every set()/updateSessionStatus() — so it covers both the start path and the per-node runtime writes that EVO-1892 left throwing on the FK. The INSERT INTO contacts (id) ... ON CONFLICT (id) DO NOTHING is idempotent, concurrency-safe, and never clobbers a real contact.
On the headline concern (would the id-only insert violate NOT NULL on created_at/updated_at?): for this service's database (evo_campaign), contacts is evo-flow's own table from init-base-tables, where created_at/updated_at are NOT NULL DEFAULT CURRENT_TIMESTAMP — so an id-only insert is valid. A Postgres FK can't cross databases, and the e2e seeds evo_campaign.contacts, which confirms the FK target is evo-flow's own table, not the CRM's. So the fix works in the actual topology.
Non-blocking follow-ups:
- The code comment "The contacts table is owned by the CRM (Rails)" is inaccurate — it's evo-flow's own
evo_campaign.contacts(init-base-tables). Please correct it. It's also a real deployment caveat: if evo-flow is ever pointed at the CRM's Postgres (evo_community), thatcontacts(Rails) hascreated_at/updated_atNOT NULL without a DB default and the id-only insert would fail — worth a one-line note. - The 3 new tests mock
manager.query(they assert the SQL is issued, not that a real DB accepts an id-only row). Please confirmverify-fixes.shD1 is green without seeding as the runtime proof. - Minor: a failed save leaves a committed id-only stub row (cosmetic — ON CONFLICT covers re-runs); the JSDoc "a missing/non-uuid id is skipped" actually fails loud, not skips.
Merge intentionally held pending the batch's merge ordering (this resolves EVO-1892's FK problem; #96's best-effort approach was rejected, and both touch the same file). #103 is clean vs develop and can merge standalone when ready.
Summary
journey_sessions.contact_idcarriesFK_journey_sessions_contact_idtoevo_campaign.contacts(ON DELETE CASCADE), but the evo-flow community surface never populates that table —ContactsService/ContactsClientServiceare HTTP proxies to the CRM (Rails), there is no contact sync/backfill.EVO-1892 desacoplou apenas o START da jornada (persist best-effort, swallow do FK violation, sem sessão órfã). Porém a persistência DURANTE a execução (writes per-node vindas da Temporal activity via
set()/updateSessionStatus()) continua usando o caminho durável (que fazthrow). Resultado: para um contato existente só no CRM, a 1ª persistência de sessão runtime disparaviolates foreign key constraint "FK_journey_sessions_contact_id"e a jornada falha no 1º nó. Contatos semeados emevo_campaigncompletam; não-semeados falham.Decisão: upsert lazy (opção b) vs relaxar FK (opção c) vs sync (opção a)
Escolhida a opção (b) upsert lazy por ser a correção da causa-raiz mais cirúrgica que mantém a integridade referencial:
persistToDatabaseagora garante uma linha mínima emcontacts(INSERT INTO contacts (id) VALUES ($1) ON CONFLICT (id) DO NOTHING) antes de salvar a sessão.contacts(ver1745200000001-init-base-tables) exige apenasid; todas as outras colunas têm default no banco (name='',blocked=false,custom_attributes='{}'::jsonb, …) ou são nullable. Logo um insert só comidproduz uma linha válida.ON CONFLICT (id) DO NOTHINGtorna idempotente e nunca sobrescreve um contato real já sincronizado pelo CRM — seguro chamar em toda escrita de sessão.persistToDatabaseé o funil único de TODAS as escritas (start E runtime per-node), o fix resolve a causa-raiz nos dois caminhos de uma vez.Opção (a) sync foi descartada (não há colunas/origem para sincronizar contatos CRM→evo_campaign de forma confiável no runtime). Opção (c) relaxar/remover a FK foi descartada por sacrificar integridade referencial sem necessidade — o upsert mínimo já satisfaz a FK.
Branch criada de
origin/develop(que ainda não contém EVO-1892); o fix é complementar e independente do best-effort de start.Test plan
npx tsc -blimpo.npx jest src/modules/journeys src/modules/cache→ 63/63 verde (10 suites), incluindo 3 novos testes EVO-1929:updateSessionStatus), não só no start;contactId.EVOAI_CRM_API_TOKEN(crm-client) são independentes; nenhuma nova introduzida.Notas