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:
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user