fix(migrations): defer cross-table FKs in 107_crm_consolidated

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.
This commit is contained in:
Luca
2026-05-26 18:49:38 +02:00
parent e7db0bb866
commit 88a6b34c5e
@@ -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();