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,
|
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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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');
|
||||||
.select('id', 'quote_number', 'status', 'issue_date', 'valid_until', 'total_amount_minor', 'currency', 'deal_uuid')
|
const contractsHaveProjectId = await hasColumnCached('contracts', 'project_id');
|
||||||
.orderBy('issue_date', 'desc');
|
|
||||||
}
|
if (perms.quotes !== false && (quotesHaveProjectId || project.customerAccountId)) {
|
||||||
if (perms.contracts !== false) {
|
let q = db('quotes')
|
||||||
out.contracts = await db('contracts')
|
.select('id', 'quote_number', 'status', 'issue_date', 'valid_until', 'total_amount_minor', 'currency', 'deal_uuid')
|
||||||
.where({ customer_account_id: project.customerAccountId })
|
.orderBy('issue_date', 'desc');
|
||||||
.select('id', 'contract_number', 'status', 'issue_date', 'signed_by_customer_at', 'deal_uuid')
|
if (quotesHaveProjectId) q = q.where({ project_id: id });
|
||||||
.orderBy('issue_date', 'desc');
|
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.
|
// 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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user