From 6420047e7cda046057d27539b85334874081b51d Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:05:36 +0200 Subject: [PATCH] feat(projects): link quotes & contracts to a project (precise cockpit rollup) - Migration 121 adds quotes.project_id + contracts.project_id (nullable FK, index) and backfills the unambiguous single-project-per-customer case. - projectService rolls quotes/contracts up by project_id, with a customer-based fallback on pre-121 DBs (hasColumnCached guarded). - quote/contract create+update accept an optional projectId; detail transforms surface it for editor prefill. - POST /projects/:id/quotes and /:id/contracts assign endpoints. --- .../121_add_project_id_to_quotes_contracts.js | 65 +++++++++++++++++++ backend/src/routes/adminContracts.js | 2 + backend/src/routes/adminProjects.js | 22 +++++++ backend/src/routes/adminQuotes.js | 4 ++ backend/src/services/contractService.js | 8 +++ backend/src/services/projectService.js | 57 ++++++++++++---- backend/src/services/quoteService.js | 8 +++ 7 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 backend/migrations/core/121_add_project_id_to_quotes_contracts.js diff --git a/backend/migrations/core/121_add_project_id_to_quotes_contracts.js b/backend/migrations/core/121_add_project_id_to_quotes_contracts.js new file mode 100644 index 00000000..867dd703 --- /dev/null +++ b/backend/migrations/core/121_add_project_id_to_quotes_contracts.js @@ -0,0 +1,65 @@ +/** + * Migration: link quotes + contracts to a project. + * + * Quotes and contracts carry no event_id (see migration 107), so the Project + * Overview cockpit originally rolled them up by the project's customer — which + * is imprecise once a customer has more than one project. This adds an explicit + * `project_id` FK to both tables so the rollup is exact, and the quote/contract + * editors get a project picker. + * + * quotes.project_id FK → projects (nullable, SET NULL on project delete). + * contracts.project_id FK → projects (nullable, SET NULL on project delete). + * + * Backfill: only the unambiguous case. For every customer that owns EXACTLY ONE + * project, link that customer's still-unassigned quotes/contracts to it. Customers + * with several projects stay unassigned — the admin links them via the picker + * (we can't guess which project a document belongs to). + * + * Idempotent: columns guarded; backfill touches only NULL project_id rows. + */ + +exports.up = async function (knex) { + for (const tbl of ['quotes', 'contracts']) { + if ((await knex.schema.hasTable(tbl)) && !(await knex.schema.hasColumn(tbl, 'project_id'))) { + await knex.schema.alterTable(tbl, (table) => { + table.integer('project_id').unsigned() + .references('id').inTable('projects').onDelete('SET NULL'); + table.index(['project_id']); + }); + } + } + + if (!(await knex.schema.hasTable('projects'))) return; + + // Customers that own exactly one project → the unambiguous backfill target. + const projects = await knex('projects').whereNotNull('customer_account_id').select('id', 'customer_account_id'); + const byCustomer = new Map(); + for (const p of projects) { + const list = byCustomer.get(p.customer_account_id) || []; + list.push(p.id); + byCustomer.set(p.customer_account_id, list); + } + + for (const [customerId, projectIds] of byCustomer.entries()) { + if (projectIds.length !== 1) continue; + const projectId = projectIds[0]; + for (const tbl of ['quotes', 'contracts']) { + if (!(await knex.schema.hasTable(tbl))) continue; + if (!(await knex.schema.hasColumn(tbl, 'customer_account_id'))) continue; + await knex(tbl) + .where({ customer_account_id: customerId }) + .whereNull('project_id') + .update({ project_id: projectId }); + } + } +}; + +exports.down = async function (knex) { + for (const tbl of ['quotes', 'contracts']) { + if ((await knex.schema.hasTable(tbl)) && (await knex.schema.hasColumn(tbl, 'project_id'))) { + await knex.schema.alterTable(tbl, (table) => { + table.dropColumn('project_id'); + }); + } + } +}; diff --git a/backend/src/routes/adminContracts.js b/backend/src/routes/adminContracts.js index b97137e9..d6c3564e 100644 --- a/backend/src/routes/adminContracts.js +++ b/backend/src/routes/adminContracts.js @@ -90,6 +90,8 @@ function transformContract(c, inclusions) { id: c.id, contractNumber: c.contract_number, customerAccountId: c.customer_account_id, + // Migration 121 — Project Overview link (undefined on pre-121 DBs). + projectId: c.project_id ?? null, customer: { email: c.customer_email, displayName: c.customer_display_name, diff --git a/backend/src/routes/adminProjects.js b/backend/src/routes/adminProjects.js index 180566b4..e8d3ddbc 100644 --- a/backend/src/routes/adminProjects.js +++ b/backend/src/routes/adminProjects.js @@ -93,6 +93,28 @@ router.post('/:id/events', }), ); +// Attach a quote to the project (quotes carry no event_id — migration 121). +router.post('/:id/quotes', + requirePermission('events.manage'), + [param('id').isInt({ min: 1 }), body('quoteId').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const result = await projectService.assignQuote(parseInt(req.params.id, 10), parseInt(req.body.quoteId, 10)); + return successResponse(res, result, 200, 'Quote attached to project'); + }), +); + +// Attach a contract to the project. +router.post('/:id/contracts', + requirePermission('events.manage'), + [param('id').isInt({ min: 1 }), body('contractId').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const result = await projectService.assignContract(parseInt(req.params.id, 10), parseInt(req.body.contractId, 10)); + return successResponse(res, result, 200, 'Contract attached to project'); + }), +); + // The cockpit aggregation — doc types gated on the admin's own permissions router.get('/:id/overview', requirePermission('events.view'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => { validateRequest(req); diff --git a/backend/src/routes/adminQuotes.js b/backend/src/routes/adminQuotes.js index fd8664b4..a130f4c1 100644 --- a/backend/src/routes/adminQuotes.js +++ b/backend/src/routes/adminQuotes.js @@ -63,6 +63,8 @@ function transformQuote(q) { id: q.id, quoteNumber: q.quote_number, customerAccountId: q.customer_account_id, + // Migration 121 — Project Overview link (undefined on pre-121 DBs). + projectId: q.project_id ?? null, customer: { email: q.customer_email, displayName: q.customer_display_name, @@ -223,6 +225,8 @@ function mapPayloadToService(body) { introText: 'introText', outroText: 'outroText', internalNotes: 'internalNotes', ccPdfEmail: 'ccPdfEmail', businessBankAccountId: 'businessBankAccountId', + // Migration 121 — optional Project Overview link. + projectId: 'projectId', }; for (const [api, svc] of Object.entries(map)) { if (Object.prototype.hasOwnProperty.call(body, api)) out[svc] = body[api]; diff --git a/backend/src/services/contractService.js b/backend/src/services/contractService.js index 46cc448d..0d0dbd5c 100644 --- a/backend/src/services/contractService.js +++ b/backend/src/services/contractService.js @@ -803,6 +803,10 @@ async function createContract(payload, adminId) { row.event_time_start = payload.eventTimeStart || null; row.event_time_end = payload.eventTimeEnd || null; } + // Migration 121 — optional link to a Project Overview project. + if (payload.projectId !== undefined && await hasColumnCached('contracts', 'project_id')) { + row.project_id = payload.projectId || null; + } const inserted = await trx('contracts').insert(row).returning('id'); const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; @@ -890,6 +894,10 @@ async function updateContract(id, payload, adminId) { for (const [api, col] of Object.entries(map)) { if (api in payload) updates[col] = payload[api] || null; } + // Migration 121 — optional Project Overview link. + if ('projectId' in payload && await hasColumnCached('contracts', 'project_id')) { + updates.project_id = payload.projectId || null; + } await trx('contracts').where({ id }).update(updates); // Replace inclusions only when the caller sent an explicit list. diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js index 9755e22b..eca06f49 100644 --- a/backend/src/services/projectService.js +++ b/backend/src/services/projectService.js @@ -15,6 +15,7 @@ const { db } = require('../database/db'); const { AppError } = require('../utils/errors'); +const { hasColumnCached } = require('../utils/schemaCache'); function transformProject(p) { if (!p) return null; @@ -94,6 +95,24 @@ async function assignEvent(projectId, eventId) { return { projectId, eventId }; } +/** Attach (or, with projectId=null, detach) a quote/contract to a project. */ +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); + } + if (projectId != null) { + const project = await db('projects').where({ id: projectId }).first(); + if (!project) throw new AppError('Project not found', 404); + } + 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 }); + return { projectId: projectId || null, documentId }; +} + +const assignQuote = (projectId, quoteId) => assignDocument('quotes', projectId, quoteId); +const assignContract = (projectId, contractId) => assignDocument('contracts', projectId, contractId); + /** * Full overview aggregation for the cockpit. Returns the project, its events, * and the rolled-up emails / quotes / contracts / invoices / hours + a @@ -133,20 +152,28 @@ async function getProjectOverview(id, perms = {}) { .orderBy('issue_date', 'desc'); } - // Quotes / contracts (by customer — no event_id on those tables). - if (project.customerAccountId) { - if (perms.quotes !== false) { - out.quotes = await db('quotes') - .where({ customer_account_id: project.customerAccountId }) - .select('id', 'quote_number', 'status', 'issue_date', 'valid_until', 'total_amount_minor', 'currency', 'deal_uuid') - .orderBy('issue_date', 'desc'); - } - if (perms.contracts !== false) { - out.contracts = await db('contracts') - .where({ customer_account_id: project.customerAccountId }) - .select('id', 'contract_number', 'status', 'issue_date', 'signed_by_customer_at', 'deal_uuid') - .orderBy('issue_date', 'desc'); - } + // Quotes / contracts. These tables carry no event_id (migration 107), so + // they're linked to the project explicitly via project_id (migration 121). + // Where that column doesn't exist yet (pre-121 DB) we fall back to the + // project's customer — the original, less precise scoping. + const quotesHaveProjectId = await hasColumnCached('quotes', 'project_id'); + const contractsHaveProjectId = await hasColumnCached('contracts', 'project_id'); + + if (perms.quotes !== false && (quotesHaveProjectId || project.customerAccountId)) { + let q = db('quotes') + .select('id', 'quote_number', 'status', 'issue_date', 'valid_until', 'total_amount_minor', 'currency', 'deal_uuid') + .orderBy('issue_date', 'desc'); + if (quotesHaveProjectId) q = q.where({ project_id: id }); + else q = q.where({ customer_account_id: project.customerAccountId }); + out.quotes = await q; + } + if (perms.contracts !== false && (contractsHaveProjectId || project.customerAccountId)) { + let q = db('contracts') + .select('id', 'contract_number', 'status', 'issue_date', 'signed_by_customer_at', 'deal_uuid') + .orderBy('issue_date', 'desc'); + if (contractsHaveProjectId) q = q.where({ project_id: id }); + else q = q.where({ customer_account_id: project.customerAccountId }); + out.contracts = await q; } // Hours (by project_id) — individual entries + total. @@ -244,6 +271,8 @@ module.exports = { createProject, updateProject, assignEvent, + assignQuote, + assignContract, getProjectOverview, getEmailPreview, resendEmail, diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index c6304309..81db3d10 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -554,6 +554,10 @@ async function createQuote(payload, adminId) { created_at: new Date(), updated_at: new Date(), }; + // Migration 121 — optional link to a Project Overview project. + if (payload.projectId !== undefined && await hasColumnCached('quotes', 'project_id')) { + row.project_id = payload.projectId || null; + } const inserted = await trx('quotes').insert(row).returning('id'); const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; @@ -667,6 +671,10 @@ async function updateQuote(id, payload, adminId) { ? JSON.stringify(payload.installments) : null; } + // Migration 121 — optional Project Overview link. + if (Object.prototype.hasOwnProperty.call(payload, 'projectId') && await hasColumnCached('quotes', 'project_id')) { + updates.project_id = payload.projectId || null; + } await trx('quotes').where({ id }).update(updates); // Delete + reinsert keeps the editor flow simple: the frontend