diff --git a/backend/src/services/contractService.js b/backend/src/services/contractService.js index 0d0dbd5c..54af7e41 100644 --- a/backend/src/services/contractService.js +++ b/backend/src/services/contractService.js @@ -808,6 +808,9 @@ async function createContract(payload, adminId) { row.project_id = payload.projectId || null; } const inserted = await trx('contracts').insert(row).returning('id'); + if (row.project_id && row.deal_uuid) { + await require('./projectService').linkDealToProject(row.deal_uuid, row.project_id, trx); + } const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; // Seed with every active system block, toggled on. Per-section @@ -900,6 +903,12 @@ async function updateContract(id, payload, adminId) { } await trx('contracts').where({ id }).update(updates); + // Cascade across the deal lineage (linked quote / event / invoices). + if (updates.project_id) { + const dealRow = await trx('contracts').where({ id }).select('deal_uuid').first(); + await require('./projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx); + } + // Replace inclusions only when the caller sent an explicit list. // (Editor's "save" sends every row; an inline "toggle" save could // send a partial update — current frontend always sends full list.) diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js index 7b635f4e..161fb0d1 100644 --- a/backend/src/services/projectService.js +++ b/backend/src/services/projectService.js @@ -140,7 +140,61 @@ async function assignEvent(projectId, eventId) { return { projectId, eventId }; } -/** Attach (or, with projectId=null, detach) a quote/contract to a project. */ +/** + * Cascade a project link across a whole deal's lineage. Given a deal_uuid, link + * every quote + contract in that deal to the project, re-point every event the + * deal produced (so its invoices / emails / gallery roll up automatically), and + * adopt the deal's customer onto the project when it has none. This is what + * makes "drop a quote on an empty project" fill the cockpit with the linked + * contract, event and invoices. Idempotent; pass a trx to run inside a txn. + */ +async function linkDealToProject(dealUuid, projectId, conn = db) { + if (!dealUuid || !projectId) return; + + if (await hasColumnCached('quotes', 'project_id') && await hasColumnCached('quotes', 'deal_uuid')) { + await conn('quotes').where({ deal_uuid: dealUuid }).update({ project_id: projectId }); + } + if (await hasColumnCached('contracts', 'project_id') && await hasColumnCached('contracts', 'deal_uuid')) { + await conn('contracts').where({ deal_uuid: dealUuid }).update({ project_id: projectId }); + } + + // Collect every event the deal converted into: quote/contract converted_event_id + // + any invoice's event_id. Re-point them so invoices/emails/gallery roll up. + const eventIds = new Set(); + let dealCustomerId = null; + if (await hasColumnCached('quotes', 'deal_uuid')) { + for (const q of await conn('quotes').where({ deal_uuid: dealUuid }).select('converted_event_id', 'customer_account_id')) { + if (q.converted_event_id) eventIds.add(q.converted_event_id); + if (q.customer_account_id && !dealCustomerId) dealCustomerId = q.customer_account_id; + } + } + if (await hasColumnCached('contracts', 'deal_uuid') && await hasColumnCached('contracts', 'converted_event_id')) { + for (const c of await conn('contracts').where({ deal_uuid: dealUuid }).select('converted_event_id', 'customer_account_id')) { + if (c.converted_event_id) eventIds.add(c.converted_event_id); + if (c.customer_account_id && !dealCustomerId) dealCustomerId = c.customer_account_id; + } + } + if (await hasColumnCached('invoices', 'deal_uuid')) { + for (const inv of await conn('invoices').where({ deal_uuid: dealUuid }).select('event_id', 'customer_account_id')) { + if (inv.event_id) eventIds.add(inv.event_id); + if (inv.customer_account_id && !dealCustomerId) dealCustomerId = inv.customer_account_id; + } + } + if (eventIds.size && await hasColumnCached('events', 'project_id')) { + await conn('events').whereIn('id', Array.from(eventIds)).update({ project_id: projectId }); + } + + // Adopt the deal's customer onto a still-unassigned project. + if (dealCustomerId) { + const project = await conn('projects').where({ id: projectId }).select('customer_account_id').first(); + if (project && project.customer_account_id == null) { + await conn('projects').where({ id: projectId }).update({ customer_account_id: dealCustomerId, updated_at: new Date() }); + } + } +} + +/** Attach (or, with projectId=null, detach) a quote/contract to a project. + * Attaching cascades the link across the deal lineage (see linkDealToProject). */ async function assignDocument(table, projectId, documentId) { if (!(await hasColumnCached(table, 'project_id'))) { throw new AppError('This instance has no project_id column yet — run migrations', 409); @@ -152,6 +206,9 @@ async function assignDocument(table, projectId, documentId) { const doc = await db(table).where({ id: documentId }).first(); if (!doc) throw new AppError('Document not found', 404); await db(table).where({ id: documentId }).update({ project_id: projectId || null }); + if (projectId && doc.deal_uuid) { + await linkDealToProject(doc.deal_uuid, projectId); + } return { projectId: projectId || null, documentId }; } @@ -419,6 +476,7 @@ module.exports = { assignEvent, assignQuote, assignContract, + linkDealToProject, computeValuation, getProjectOverview, getEmailPreview, diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 81db3d10..514864fa 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -561,6 +561,13 @@ async function createQuote(payload, adminId) { const inserted = await trx('quotes').insert(row).returning('id'); const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + // Cascade the project link across the deal lineage (no-op for a brand-new + // quote with no contract/event yet — just adopts the customer onto an + // empty project). + if (row.project_id) { + await require('./projectService').linkDealToProject(row.deal_uuid, row.project_id, trx); + } + if (totals.lineItems.length > 0) { // Normalise rows for the hierarchical-insert helper. We preserve // the wire-only `parent_position` field here so the helper can @@ -677,6 +684,13 @@ async function updateQuote(id, payload, adminId) { } await trx('quotes').where({ id }).update(updates); + // When linked to a project, cascade across the deal lineage so the linked + // contract / event / invoices roll up into the same project automatically. + if (updates.project_id) { + const dealRow = await trx('quotes').where({ id }).select('deal_uuid').first(); + await require('./projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx); + } + // Delete + reinsert keeps the editor flow simple: the frontend // sends the canonical line-item set on every save, we drop the // old rows and rebuild from scratch. CASCADE on parent_line_item_id