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.
This commit is contained in:
Luca
2026-06-06 13:05:36 +02:00
parent 1bf0b34ea5
commit 6420047e7c
7 changed files with 152 additions and 14 deletions
@@ -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');
});
}
}
};
+2
View File
@@ -90,6 +90,8 @@ function transformContract(c, inclusions) {
id: c.id, id: c.id,
contractNumber: c.contract_number, contractNumber: c.contract_number,
customerAccountId: c.customer_account_id, customerAccountId: c.customer_account_id,
// Migration 121 — Project Overview link (undefined on pre-121 DBs).
projectId: c.project_id ?? null,
customer: { customer: {
email: c.customer_email, email: c.customer_email,
displayName: c.customer_display_name, displayName: c.customer_display_name,
+22
View File
@@ -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 // 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) => { router.get('/:id/overview', requirePermission('events.view'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
validateRequest(req); validateRequest(req);
+4
View File
@@ -63,6 +63,8 @@ function transformQuote(q) {
id: q.id, id: q.id,
quoteNumber: q.quote_number, quoteNumber: q.quote_number,
customerAccountId: q.customer_account_id, customerAccountId: q.customer_account_id,
// Migration 121 — Project Overview link (undefined on pre-121 DBs).
projectId: q.project_id ?? null,
customer: { customer: {
email: q.customer_email, email: q.customer_email,
displayName: q.customer_display_name, displayName: q.customer_display_name,
@@ -223,6 +225,8 @@ function mapPayloadToService(body) {
introText: 'introText', outroText: 'outroText', introText: 'introText', outroText: 'outroText',
internalNotes: 'internalNotes', ccPdfEmail: 'ccPdfEmail', internalNotes: 'internalNotes', ccPdfEmail: 'ccPdfEmail',
businessBankAccountId: 'businessBankAccountId', businessBankAccountId: 'businessBankAccountId',
// Migration 121 — optional Project Overview link.
projectId: 'projectId',
}; };
for (const [api, svc] of Object.entries(map)) { for (const [api, svc] of Object.entries(map)) {
if (Object.prototype.hasOwnProperty.call(body, api)) out[svc] = body[api]; if (Object.prototype.hasOwnProperty.call(body, api)) out[svc] = body[api];
+8
View File
@@ -803,6 +803,10 @@ async function createContract(payload, adminId) {
row.event_time_start = payload.eventTimeStart || null; row.event_time_start = payload.eventTimeStart || null;
row.event_time_end = payload.eventTimeEnd || 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 inserted = await trx('contracts').insert(row).returning('id');
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; 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)) { for (const [api, col] of Object.entries(map)) {
if (api in payload) updates[col] = payload[api] || null; 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); await trx('contracts').where({ id }).update(updates);
// Replace inclusions only when the caller sent an explicit list. // Replace inclusions only when the caller sent an explicit list.
+38 -9
View File
@@ -15,6 +15,7 @@
const { db } = require('../database/db'); const { db } = require('../database/db');
const { AppError } = require('../utils/errors'); const { AppError } = require('../utils/errors');
const { hasColumnCached } = require('../utils/schemaCache');
function transformProject(p) { function transformProject(p) {
if (!p) return null; if (!p) return null;
@@ -94,6 +95,24 @@ async function assignEvent(projectId, eventId) {
return { 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, * Full overview aggregation for the cockpit. Returns the project, its events,
* and the rolled-up emails / quotes / contracts / invoices / hours + a * and the rolled-up emails / quotes / contracts / invoices / hours + a
@@ -133,20 +152,28 @@ async function getProjectOverview(id, perms = {}) {
.orderBy('issue_date', 'desc'); .orderBy('issue_date', 'desc');
} }
// Quotes / contracts (by customer — no event_id on those tables). // Quotes / contracts. These tables carry no event_id (migration 107), so
if (project.customerAccountId) { // they're linked to the project explicitly via project_id (migration 121).
if (perms.quotes !== false) { // Where that column doesn't exist yet (pre-121 DB) we fall back to the
out.quotes = await db('quotes') // project's customer — the original, less precise scoping.
.where({ customer_account_id: project.customerAccountId }) 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') .select('id', 'quote_number', 'status', 'issue_date', 'valid_until', 'total_amount_minor', 'currency', 'deal_uuid')
.orderBy('issue_date', 'desc'); .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) { if (perms.contracts !== false && (contractsHaveProjectId || project.customerAccountId)) {
out.contracts = await db('contracts') let q = db('contracts')
.where({ customer_account_id: project.customerAccountId })
.select('id', 'contract_number', 'status', 'issue_date', 'signed_by_customer_at', 'deal_uuid') .select('id', 'contract_number', 'status', 'issue_date', 'signed_by_customer_at', 'deal_uuid')
.orderBy('issue_date', 'desc'); .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. // Hours (by project_id) — individual entries + total.
@@ -244,6 +271,8 @@ module.exports = {
createProject, createProject,
updateProject, updateProject,
assignEvent, assignEvent,
assignQuote,
assignContract,
getProjectOverview, getProjectOverview,
getEmailPreview, getEmailPreview,
resendEmail, resendEmail,
+8
View File
@@ -554,6 +554,10 @@ async function createQuote(payload, adminId) {
created_at: new Date(), created_at: new Date(),
updated_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 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];
@@ -667,6 +671,10 @@ async function updateQuote(id, payload, adminId) {
? JSON.stringify(payload.installments) ? JSON.stringify(payload.installments)
: null; : 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); await trx('quotes').where({ id }).update(updates);
// Delete + reinsert keeps the editor flow simple: the frontend // Delete + reinsert keeps the editor flow simple: the frontend