From 88a6b34c5e8a48c9e6419554b8f813ae02f2e519 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 26 May 2026 18:49:38 +0200 Subject: [PATCH] fix(migrations): defer cross-table FKs in 107_crm_consolidated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quotes.converted_contract_id and invoices.source_contract_id were declared with inline FKs to contracts(id), but contracts is created later in the same migration. SQLite accepted the forward reference; Postgres rejected it ("relation \"contracts\" does not exist"), which broke the Schema drift (#530) workflow and any fresh Postgres install. Same pattern as events.hero_photo_id → photos.id in db.js: declare the column without a constraint, then add the FK in a separate alterTable after both sides exist. Wrapped in try/catch so re-runs against a DB that already has the constraint are a no-op. Verified locally against the #530 recovery scenario (initializeDatabase then migrate:safe) and the fresh-install path: both converge cleanly, both FKs land on the expected tables. --- .../migrations/core/107_crm_consolidated.js | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/backend/migrations/core/107_crm_consolidated.js b/backend/migrations/core/107_crm_consolidated.js index b3608c89..88b4801c 100644 --- a/backend/migrations/core/107_crm_consolidated.js +++ b/backend/migrations/core/107_crm_consolidated.js @@ -838,8 +838,8 @@ exports.up = async function (knex) { // Back-pointer to the contract this quote spawned (mig 131). // Quote detail page deep-links to the contract; service refuses // double conversion to event/invoice when this is set. - table.integer('converted_contract_id').unsigned() - .references('id').inTable('contracts').onDelete('SET NULL'); + // FK added below after contracts table exists (forward ref). + table.integer('converted_contract_id').unsigned(); table.string('pdf_path', 512); table.integer('business_bank_account_id').unsigned() @@ -934,8 +934,8 @@ exports.up = async function (knex) { .references('id').inTable('quotes').onDelete('SET NULL'); // Back-pointer to the source contract (mig 131) when the invoice // was created from a signed contract rather than a quote. - table.integer('source_contract_id').unsigned() - .references('id').inTable('contracts').onDelete('SET NULL'); + // FK added below after contracts table exists (forward ref). + table.integer('source_contract_id').unsigned(); table.integer('event_id').unsigned() .references('id').inTable('events').onDelete('SET NULL'); // Storno + reissue self-refs (mig 114). ON DELETE SET NULL so @@ -1292,6 +1292,31 @@ exports.up = async function (knex) { }); } + // Deferred FKs: quotes.converted_contract_id + invoices.source_contract_id + // both reference contracts(id), but the contracts table is created after + // quotes/invoices in this migration (lineage flows quote → contract → + // invoice, but the back-pointers were added in later originals — see + // migs 131 + 141). Add the constraints now that contracts exists. + // Wrapped in try/catch so a re-run on a DB that already has them is a + // no-op (matches the events.hero_photo_id pattern in db.js). + for (const [parent, column] of [ + ['quotes', 'converted_contract_id'], + ['invoices', 'source_contract_id'], + ]) { + try { + await knex.schema.alterTable(parent, (table) => { + table.foreign(column) + .references('id').inTable('contracts') + .onDelete('SET NULL'); + }); + } catch (err) { + const msg = err?.message || ''; + if (!/already exists|duplicate|exists/i.test(msg)) { + throw err; + } + } + } + if (!(await knex.schema.hasTable('contract_block_inclusions'))) { await knex.schema.createTable('contract_block_inclusions', (table) => { table.increments('id').primary();