feat(projects): linking a quote/contract cascades the whole deal into the project

linkDealToProject(dealUuid, projectId): links every quote + contract sharing
the deal_uuid, re-points the events the deal converted into (so their
invoices/emails/gallery roll up), and adopts the deal's customer onto an
empty project. Invoked from the assign endpoints AND the quote/contract
editors' project picker (create + update). Drop a quote on an empty project
and its linked contract, event and invoices populate the cockpit automatically.

Verified on a booted DB: assignQuote on an empty project propagates project_id
to the contract + event, adopts the customer, and the overview rolls up all
four document types.
This commit is contained in:
Luca
2026-06-07 01:03:27 +02:00
parent 89bfb6c519
commit a702f33004
3 changed files with 82 additions and 1 deletions
+9
View File
@@ -808,6 +808,9 @@ async function createContract(payload, adminId) {
row.project_id = payload.projectId || null; row.project_id = payload.projectId || null;
} }
const inserted = await trx('contracts').insert(row).returning('id'); 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]; const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
// Seed with every active system block, toggled on. Per-section // 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); 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. // Replace inclusions only when the caller sent an explicit list.
// (Editor's "save" sends every row; an inline "toggle" save could // (Editor's "save" sends every row; an inline "toggle" save could
// send a partial update — current frontend always sends full list.) // send a partial update — current frontend always sends full list.)
+59 -1
View File
@@ -140,7 +140,61 @@ async function assignEvent(projectId, eventId) {
return { 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) { async function assignDocument(table, projectId, documentId) {
if (!(await hasColumnCached(table, 'project_id'))) { if (!(await hasColumnCached(table, 'project_id'))) {
throw new AppError('This instance has no project_id column yet — run migrations', 409); 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(); const doc = await db(table).where({ id: documentId }).first();
if (!doc) throw new AppError('Document not found', 404); if (!doc) throw new AppError('Document not found', 404);
await db(table).where({ id: documentId }).update({ project_id: projectId || null }); 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 }; return { projectId: projectId || null, documentId };
} }
@@ -419,6 +476,7 @@ module.exports = {
assignEvent, assignEvent,
assignQuote, assignQuote,
assignContract, assignContract,
linkDealToProject,
computeValuation, computeValuation,
getProjectOverview, getProjectOverview,
getEmailPreview, getEmailPreview,
+14
View File
@@ -561,6 +561,13 @@ async function createQuote(payload, adminId) {
const inserted = await trx('quotes').insert(row).returning('id'); const inserted = await trx('quotes').insert(row).returning('id');
const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; 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) { if (totals.lineItems.length > 0) {
// Normalise rows for the hierarchical-insert helper. We preserve // Normalise rows for the hierarchical-insert helper. We preserve
// the wire-only `parent_position` field here so the helper can // 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); 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 // Delete + reinsert keeps the editor flow simple: the frontend
// sends the canonical line-item set on every save, we drop the // sends the canonical line-item set on every save, we drop the
// old rows and rebuild from scratch. CASCADE on parent_line_item_id // old rows and rebuild from scratch. CASCADE on parent_line_item_id