Merge origin/beta into feat/accounting-inbound-invoices

Resolves the 7 feature-flag / i18n conflicts (accounting flags vs upstream's
Project Overview 'projects' flag, both registered in the same files) as
additive unions — accounting + incomingInvoices + expenses AND projects all
coexist. Migrations slot cleanly: projects 117-121, accounting 122-129, no
collisions. Frontend build + backend node --check pass.
This commit is contained in:
Luca
2026-06-15 16:37:23 +02:00
46 changed files with 2556 additions and 96 deletions
+8 -3
View File
@@ -64,10 +64,15 @@ async function checkMaintenanceMode() {
// Middleware to enforce maintenance mode
async function maintenanceMiddleware(req, res, next) {
// Skip maintenance check for certain paths
// Skip maintenance check for certain paths. Admin auth MUST work during
// maintenance — otherwise enabling it locks every admin out, including
// already-logged-in ones (their /auth/session check would 503 and read as
// logged-out). These are the REAL endpoints: the admin login + session
// routes live under /api/auth, NOT /api/admin (the old /api/admin/login
// entries here matched nothing, which is exactly why the lockout happened).
const skipPaths = [
'/api/admin/login',
'/api/admin/auth/login',
'/api/auth/admin/login',
'/api/auth/session',
'/api/public/settings',
'/health'
];
+2
View File
@@ -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,
+4 -3
View File
@@ -165,7 +165,7 @@ router.post('/invite', [
body('prefill.postal_code').optional({ nullable: true }).isString().isLength({ max: 20 }),
body('prefill.city').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.state').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
body('prefill.country_code').optional({ values: 'falsy' }).isLength({ min: 2, max: 2 }).isAlpha().withMessage('country_code must be a 2-letter ISO code').customSanitizer((v) => (v || '').toUpperCase()),
// Per-customer preferred language. Drives portal UI + quote/invoice
// PDF locale. Defaults at insert time to the business profile's
// default_locale when the admin doesn't supply one (see
@@ -246,7 +246,7 @@ router.post('/', [
body('prefill.postal_code').optional({ nullable: true }).isString().isLength({ max: 20 }),
body('prefill.city').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.state').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
body('prefill.country_code').optional({ values: 'falsy' }).isLength({ min: 2, max: 2 }).isAlpha().withMessage('country_code must be a 2-letter ISO code').customSanitizer((v) => (v || '').toUpperCase()),
body('prefill.country_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.preferred_language').optional({ nullable: true }).isString().isLength({ min: 2, max: 8 }),
// At least one human-readable identifier so the record isn't a
@@ -379,7 +379,7 @@ router.put('/:id', [
body('postal_code').optional({ nullable: true }).isString().isLength({ max: 20 }),
body('city').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('state').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
body('country_code').optional({ values: 'falsy' }).isLength({ min: 2, max: 2 }).isAlpha().withMessage('country_code must be a 2-letter ISO code').customSanitizer((v) => (v || '').toUpperCase()),
body('country_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('preferred_language').optional({ nullable: true }).isString().isLength({ max: 8 }),
body('notes').optional({ nullable: true }).isString(),
@@ -572,6 +572,7 @@ router.post('/:id/hour-entries', [
body('endTime').matches(/^([01]\d|2[0-3]):[0-5]\d$/),
body('hourlyRateMinorOverride').optional({ nullable: true }).isInt({ min: 0 }),
body('description').optional({ nullable: true }).isString().isLength({ max: 1000 }),
body('projectId').optional({ nullable: true }).isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const result = await customerHoursService.createEntry(
+7
View File
@@ -76,6 +76,10 @@ const KNOWN_FLAGS = [
// Expenses (migration 127) — internal expenses (mileage / per-diem / cash).
// Separate Accounting sub-feature; forced off when `accounting` is off.
'expenses',
// Projects (migration 120). Admin-only grouping layer above events +
// the Project Overview cockpit ("book to project" hours control, 360°
// rollup feed). Lights up the Clients section. Customers never see it.
'projects',
];
// Spec defaults for any flag missing from the DB (e.g. a row added by a
@@ -102,6 +106,7 @@ const DEFAULT_FLAGS = {
accounting: false,
incomingInvoices: false,
expenses: false,
projects: false,
};
async function readAllFlags() {
@@ -146,6 +151,8 @@ function applyDependencyRules(flags) {
|| out.contracts
// NOTE: taxReport intentionally removed — Tax export moved to the
// Accounting section (its own master), no longer a CRM sub-feature.
// Migration 120 — admin-only Project Overview cockpit lives under Clients.
|| out.projects
// Migration 137 — admin calendar lights up the Clients section.
// (calendarBooking is gated behind `calendar` so adding the parent
// is sufficient.)
+19 -2
View File
@@ -32,6 +32,23 @@ const { db } = require('../database/db');
const router = express.Router();
// PR #603 review follow-up #2 — bound payment dates. `isISO8601()` alone
// accepts year 1900/9999; cash-basis revenue keys on paid_at, so a typo
// (2026→2226) would silently push a payment out of every dashboard window
// forever. Reject anything before 2000-01-01 or more than 30 days in the
// future (small future window covers value-date lag without allowing fat-
// finger years). Use as `.custom(isReasonablePaidAt)` after `.isISO8601()`.
function isReasonablePaidAt(value) {
const d = new Date(value);
if (Number.isNaN(d.getTime())) throw new Error('Invalid payment date');
const min = new Date('2000-01-01T00:00:00Z');
const max = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
if (d < min || d > max) {
throw new Error('Payment date must be between 2000-01-01 and 30 days from now');
}
return true;
}
// Multer config for "import historical invoice" PDF uploads. Stored
// under storage/business-docs/invoice-imports/<year>/<filename> so
// imported files don't collide with the renderer's own output under
@@ -438,7 +455,7 @@ router.post(
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
body('status').optional({ values: 'falsy' }).isIn(['sent', 'paid', 'overdue']),
body('paidAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
body('paidAt').optional({ values: 'falsy' }).isISO8601(),
body('paidAt').optional({ values: 'falsy' }).isISO8601().custom(isReasonablePaidAt),
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
],
handleAsync(async (req, res) => {
@@ -745,7 +762,7 @@ router.post(
[
param('id').isInt({ min: 1 }),
body('amountMinor').isInt({ min: 1 }),
body('paidAt').optional({ values: 'falsy' }).isISO8601(),
body('paidAt').optional({ values: 'falsy' }).isISO8601().custom(isReasonablePaidAt),
body('paymentMethod').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
body('reference').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
body('notes').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
+160
View File
@@ -0,0 +1,160 @@
/**
* Admin → Projects routes (the admin-only Project Overview cockpit, Model A).
*
* Mounted at /api/admin/projects. Projects group events; the overview rolls
* up the per-event/per-customer documents. Read = `events.view`, write =
* `events.manage` (projects are fundamentally an events-grouping concept).
* The overview additionally gates each money-doc type on the admin's own
* bills/quotes/contracts view permission.
*/
const express = require('express');
const { body, param } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission, userHasAnyPermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const projectService = require('../services/projectService');
const { db } = require('../database/db');
const router = express.Router();
router.use(adminAuth);
// Projects is feature-flagged like bills/quotes — when off, the whole cockpit
// (and the "book to project" hours control) is hidden, and the API 403s.
async function requireProjectsFlag(req, res, next) {
try {
const row = await db('feature_flags').where({ key: 'projects' }).first();
const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
if (!enabled) return res.status(403).json({ error: 'Projects feature is disabled', code: 'PROJECTS_DISABLED' });
next();
} catch (err) { next(err); }
}
router.use(requireProjectsFlag);
// List
router.get('/', requirePermission('events.view'), handleAsync(async (req, res) => {
// Value rollup mirrors the cockpit's per-doc gating so the list never
// shows figures the admin lacks permission to see. Only invoices + quotes
// carry monetary totals; contracts contribute none, so (unlike the detail
// route, which gates the contracts *section*) the list needs no contracts
// permission.
const perms = {
bills: await userHasAnyPermission(req.admin.id, ['bills.view']),
quotes: await userHasAnyPermission(req.admin.id, ['quotes.view']),
};
const projects = await projectService.listProjects({
search: req.query.q || '',
status: req.query.status || null,
perms,
});
return successResponse(res, { projects });
}));
// Create
router.post('/',
requirePermission('events.edit'),
[body('name').isString().trim().isLength({ min: 1, max: 255 }), body('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const project = await projectService.createProject(
{ name: req.body.name, customerAccountId: req.body.customerAccountId || null },
req.admin.id,
);
return successResponse(res, { project }, 201, 'Project created');
}),
);
// Detail
router.get('/:id', requirePermission('events.view'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
validateRequest(req);
const project = await projectService.getProjectById(parseInt(req.params.id, 10));
if (!project) return res.status(404).json({ error: 'Project not found' });
return successResponse(res, { project });
}));
// Update
router.put('/:id',
requirePermission('events.edit'),
[
param('id').isInt({ min: 1 }),
body('name').optional().isString().trim().isLength({ min: 1, max: 255 }),
// nullable:true → accept JSON `null` (clear the customer); isInt otherwise.
body('customerAccountId').optional({ nullable: true }).isInt({ min: 1 }),
body('status').optional().isString().isLength({ max: 24 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const project = await projectService.updateProject(parseInt(req.params.id, 10), {
name: req.body.name,
customerAccountId: req.body.customerAccountId,
status: req.body.status,
});
return successResponse(res, { project }, 200, 'Project updated');
}),
);
// Attach an event to the project
router.post('/:id/events',
requirePermission('events.edit'),
[param('id').isInt({ min: 1 }), body('eventId').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await projectService.assignEvent(parseInt(req.params.id, 10), parseInt(req.body.eventId, 10));
return successResponse(res, result, 200, 'Event attached to project');
}),
);
// Attach a quote to the project (quotes carry no event_id — migration 121).
router.post('/:id/quotes',
requirePermission('events.edit'),
[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.edit'),
[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);
const perms = {
bills: await userHasAnyPermission(req.admin.id, ['bills.view']),
quotes: await userHasAnyPermission(req.admin.id, ['quotes.view']),
contracts: await userHasAnyPermission(req.admin.id, ['contracts.view']),
};
const overview = await projectService.getProjectOverview(parseInt(req.params.id, 10), perms);
return successResponse(res, overview);
}));
// Email preview — the ACTUAL sent HTML (or null for pre-rendered_html rows)
router.get('/email/:emailId/preview', requirePermission('events.view'), [param('emailId').isInt({ min: 1 })], handleAsync(async (req, res) => {
validateRequest(req);
const preview = await projectService.getEmailPreview(parseInt(req.params.emailId, 10));
return successResponse(res, preview);
}));
// Email actions from the feed (need email.send). Resend (sent→fresh copy),
// Cancel (pending→cancelled), Retry (failed→pending), Send now (flush this one).
const emailAction = (fn) => handleAsync(async (req, res) => {
validateRequest(req);
const result = await projectService[fn](parseInt(req.params.emailId, 10), req.admin.id);
return successResponse(res, result);
});
router.post('/email/:emailId/resend', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('resendEmail'));
router.post('/email/:emailId/cancel', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('cancelEmail'));
router.post('/email/:emailId/retry', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('retryEmail'));
router.post('/email/:emailId/send-now', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('sendEmailNow'));
module.exports = router;
+4
View File
@@ -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];
+17
View File
@@ -803,7 +803,14 @@ 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');
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];
// Seed with every active system block, toggled on. Per-section
@@ -890,8 +897,18 @@ 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);
// 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.
// (Editor's "save" sends every row; an inline "toggle" save could
// send a partial update — current frontend always sends full list.)
@@ -233,6 +233,21 @@ async function createEntry(customerId, payload, adminId) {
created_at: new Date(),
updated_at: new Date(),
};
// Migration 118 — optional "book to project" link. A project belongs to
// at most one customer, so reject booking hours onto a project owned by a
// DIFFERENT customer (defence-in-depth behind the customer-scoped picker).
// Unassigned projects (customer_account_id null) are allowed for anyone.
if (payload.projectId !== undefined && await hasColumnCached('customer_hour_entries', 'project_id')) {
const projectId = payload.projectId || null;
if (projectId && await trx.schema.hasTable('projects')) {
const project = await trx('projects').where({ id: projectId }).select('customer_account_id').first();
if (!project) throw new AppError('Project not found', 404, 'PROJECT_NOT_FOUND');
if (project.customer_account_id != null && project.customer_account_id !== customer.id) {
throw new AppError('That project belongs to a different customer', 422, 'PROJECT_CUSTOMER_MISMATCH');
}
}
row.project_id = projectId;
}
const inserted = await trx('customer_hour_entries').insert(row).returning('id');
const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
+98 -26
View File
@@ -38,6 +38,13 @@ async function initializeTransporter(forceReinit = false) {
// Configuration has changed or first initialization
logger.info('Initializing email transporter' + (lastConfigHash && currentConfigHash !== lastConfigHash ? ' (configuration changed)' : ''));
// PR #603 review follow-up #3 — release the previous transporter before
// swapping it. Harmless today (no connection pool), but prevents a
// socket/connection leak if `pool: true` is ever enabled on the transport.
if (transporter && typeof transporter.close === 'function') {
try { transporter.close(); } catch (_) { /* best-effort */ }
}
transporter = nodemailer.createTransport({
host: config.smtp_host,
port: config.smtp_port,
@@ -252,6 +259,19 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
const logoFullUrl = `${frontendUrl}${logoPath.startsWith('/') ? '' : '/'}${logoPath}`;
logger.debug('Email logo URL:', { frontendUrl, logoPath, logoFullUrl });
const year = new Date().getFullYear();
// PR review follow-up — Outlook (Word engine) and Apple Mail under some
// configs STRIP the <head><style>, so any element styled only by a class
// loses its design (the CTA rendered as a plain link, the header card +
// button vanished). Fix: inline the CTA button style (themed with the
// admin's primary colour) on every `class="button"` anchor, keeping the
// class so style-capable clients still get :hover. The wrapper chrome
// below is rebuilt as inline-styled tables with bgcolor attrs for the same
// reason. The <style> block stays as progressive enhancement.
const buttonInlineStyle = `background-color:${primaryColor};color:${buttonTextColor};display:inline-block;padding:12px 30px;text-decoration:none;border-radius:5px;font-weight:500;`;
const inlinedBody = (typeof htmlBody === 'string' ? htmlBody : '')
.replace(/class="button"/g, `class="button" style="${buttonInlineStyle}"`);
return `
<!DOCTYPE html>
<html lang="${language}">
@@ -367,22 +387,32 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
}
</style>
</head>
<body>
<div class="email-wrapper">
<div class="email-container">
<div class="email-header">
<img src="${logoFullUrl}" alt="${companyName}" class="logo">
</div>
<div class="email-content">
${htmlBody}
</div>
<div class="email-footer">
<img src="${logoFullUrl}" alt="${companyName}">
<p>${companyName}</p>
<p style="font-size: 12px; color: #999;">© ${new Date().getFullYear()} ${companyName}. All rights reserved.</p>
</div>
</div>
</div>
<body style="margin:0;padding:0;background-color:${bodyBgColor};color:${bodyTextColor};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="${bodyBgColor}" style="background-color:${bodyBgColor};" class="email-wrapper">
<tr>
<td align="center" style="padding:40px 20px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" class="email-container" style="width:100%;max-width:600px;background-color:${containerBgColor};border-radius:8px;overflow:hidden;">
<tr>
<td align="center" bgcolor="${primaryColor}" class="email-header" style="background-color:${primaryColor};padding:30px;text-align:center;">
<img src="${logoFullUrl}" alt="${companyName}" width="180" class="logo" style="max-width:180px;height:auto;display:inline-block;border:0;">
</td>
</tr>
<tr>
<td class="email-content" style="padding:40px 30px;">
${inlinedBody}
</td>
</tr>
<tr>
<td align="center" bgcolor="${secondaryColor}" class="email-footer" style="background-color:${secondaryColor};padding:30px;text-align:center;border-top:1px solid #eeeeee;">
<img src="${logoFullUrl}" alt="${companyName}" width="120" style="max-width:120px;height:auto;opacity:0.8;margin-bottom:15px;border:0;">
<p style="color:${mutedTextColor};font-size:14px;margin:5px 0;">${companyName}</p>
<p style="font-size:12px;color:#999999;margin:5px 0;">© ${year} ${companyName}. All rights reserved.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
@@ -733,13 +763,31 @@ async function sendTemplateEmail(to, templateKey, variables) {
});
logger.info(`Email sent successfully: ${info.messageId} (${language})`);
return { success: true, messageId: info.messageId, language };
// Return the rendered HTML so the queue processor can persist the ACTUAL
// sent body (email_queue.rendered_html) for the Project Overview preview.
return { success: true, messageId: info.messageId, language, html: htmlBody };
} catch (error) {
logger.error('Error sending template email:', error);
throw error;
}
}
/**
* Render a queued email's HTML WITHOUT sending it. Used by the Project
* Overview cockpit to preview emails that predate the rendered_html column
* (so nothing was stored at send time). The result is rendered from the
* CURRENT template + the row's stored variables, so it's a faithful
* approximation rather than the exact bytes that were sent — callers flag
* it as a re-render. Returns null when the template no longer exists.
*/
async function renderQueuedEmail(templateKey, variables = {}, to = '') {
const template = await db('email_templates').where('template_key', templateKey).first();
if (!template) return null;
const language = await getRecipientLanguage(to, variables.eventId || null);
const { subject, htmlBody } = await processTemplate(template, variables, language);
return { subject, html: htmlBody };
}
// Process email queue.
//
// Options:
@@ -750,9 +798,13 @@ async function sendTemplateEmail(to, templateKey, variables) {
// limit max emails per pass. The flush raises this to drain the
// whole queue in a single pass (no re-query, so a failing
// email isn't retried in a tight loop within one flush).
// onlyId when set, process EXACTLY this one queue row. Used by the
// cockpit "send now" so a forced send never sweeps up OTHER
// dead-lettered emails (those past the retry cap) just
// because ignoreSchedule also bypasses that cap.
//
// Returns { processed, sent, failed }.
async function processEmailQueue({ ignoreSchedule = false, limit = 10 } = {}) {
async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId = null } = {}) {
logger.info('Email queue processor: Checking for pending emails...');
const result = { processed: 0, sent: 0, failed: 0 };
@@ -775,6 +827,9 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10 } = {}) {
const now = new Date();
const query = db('email_queue')
.where('status', 'pending');
// Targeted single-email flush (cockpit "send now"): scope to that row
// only, so we never force-retry other dead-lettered emails.
if (onlyId != null) query.where('id', onlyId);
if (!ignoreSchedule) {
// Automatic runs: respect the retry cap (don't hammer a failing
// address) AND the schedule (business-hours floor / future send).
@@ -809,19 +864,24 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10 } = {}) {
? JSON.parse(email.email_data || '{}')
: email.email_data || {};
await sendTemplateEmail(
const sendResult = await sendTemplateEmail(
email.recipient_email,
email.email_type,
emailData
);
// Mark as sent
// Mark as sent, persisting the actual rendered HTML for the Project
// Overview email preview (guarded — older installs without migration
// 119 just skip it).
const sentUpdate = { status: 'sent', sent_at: new Date() };
try {
if (sendResult && sendResult.html && await hasColumnCached('email_queue', 'rendered_html')) {
sentUpdate.rendered_html = sendResult.html;
}
} catch (_) { /* best-effort — never block the send on the preview */ }
await db('email_queue')
.where('id', email.id)
.update({
status: 'sent',
sent_at: new Date()
});
.update(sentUpdate);
result.sent += 1;
logger.info(`Email ${email.id} sent successfully`);
@@ -886,7 +946,18 @@ async function getScheduledEmailConfig() {
const schedule = normaliseSchedule(profile.business_hours);
let timezone = (profile.timezone || '').trim();
if (!timezone) timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
if (!timezone) {
// PR #603 review follow-up #4 — business hours are configured but the
// profile timezone is blank, so we fall back to the SERVER's tz (usually
// UTC on a Docker host). That silently shifts every business-hours
// calculation. Warn loudly so the admin sets business_profile.timezone.
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
logger.warn(
'Scheduled-email business hours are set but business_profile.timezone is blank — '
+ `falling back to the server timezone (${timezone}). Set the profile timezone `
+ 'so business-hours snapping uses your local time, not the server\'s.',
);
}
// Reject a bogus tz before it reaches Intl in the snap helper.
try {
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
@@ -1025,6 +1096,7 @@ module.exports = {
initializeTransporter,
startEmailQueueProcessor,
sendTemplateEmail,
renderQueuedEmail,
processEmailQueue,
queueEmail,
stopEmailQueueProcessor,
+16
View File
@@ -694,6 +694,22 @@ async function createInvoice(payload, adminId, trx = db) {
const customer = await trx('customer_accounts').where({ id: payload.customerAccountId }).first();
ensureCustomerCanBill(customer);
// PR #603 review follow-up #1 — when an invoice is attached to an event,
// make sure that event actually belongs to the chosen customer. Without
// this, a typo'd/copy-pasted eventId silently links the invoice to an
// unrelated event, producing misleading reporting links. Only enforced
// when the event HAS customer assignments (an event with none — e.g. a
// legacy import — is allowed through, since we can't prove a mismatch).
if (payload.eventId && await trx.schema.hasTable('event_customer_assignments')) {
const assignments = await trx('event_customer_assignments')
.where({ event_id: payload.eventId })
.select('customer_account_id');
if (assignments.length > 0 &&
!assignments.some(a => a.customer_account_id === payload.customerAccountId)) {
throw new AppError('The selected event is not assigned to this customer', 422, 'EVENT_CUSTOMER_MISMATCH');
}
}
// Accumulator intercept (migration 128). For customers in
// billing_cadence='monthly' OR 'manual' mode every createInvoice call
// APPENDS line items onto a single running draft instead of minting a
+653
View File
@@ -0,0 +1,653 @@
/**
* projectService — the admin-only "Project Overview" grouping layer (Model A).
*
* A project groups 1..N events. Money documents stay attached to their EVENT
* (or, for quotes/contracts which carry no event_id, to the CUSTOMER) and the
* project rolls them up for the cockpit. Customers never see projects.
*
* Rollup scoping (v1):
* - invoices / emails / galleries → by the project's EVENTS (event_id).
* - hours → by customer_hour_entries.project_id.
* - quotes / contracts → by the project's customer_account_id
* (they have no event_id; see migration 107). Empty when the project has
* no single customer set.
*/
const { db, logActivity } = require('../database/db');
const { AppError } = require('../utils/errors');
const { hasColumnCached } = require('../utils/schemaCache');
function transformProject(p) {
if (!p) return null;
return {
id: p.id,
name: p.name,
customerAccountId: p.customer_account_id || null,
customerEmail: p.customer_email || null,
status: p.status,
eventCount: p.event_count != null ? Number(p.event_count) : undefined,
createdAt: p.created_at,
updatedAt: p.updated_at,
};
}
/** List projects with customer email + event count + rolled-up value.
* `perms` gates which document types feed the value (matches the cockpit):
* invoices need bills.view, quotes need quotes.view. */
async function listProjects({ search = '', status = null, perms = {} } = {}) {
let q = db('projects')
.leftJoin('customer_accounts', 'customer_accounts.id', 'projects.customer_account_id')
.select(
'projects.*',
'customer_accounts.email as customer_email',
db('events').count('* as c').whereRaw('events.project_id = projects.id').as('event_count'),
)
.orderBy('projects.updated_at', 'desc');
if (status) q = q.where('projects.status', status);
if (search) {
q = q.where(function () {
this.where('projects.name', 'like', `%${search}%`)
.orWhere('customer_accounts.email', 'like', `%${search}%`);
});
}
const rows = await q;
const projects = rows.map(transformProject);
await attachValuations(projects, perms);
return projects;
}
/**
* Compute + attach `valuation` to each listed project in two bulk queries
* (not per-project), then run the shared newest-wins-per-deal helper. Mutates
* the passed array. Documents the admin can't see (per perms) are excluded,
* so the value never leaks figures the admin lacks permission for.
*/
async function attachValuations(projects, perms = {}) {
if (!projects.length) return;
const projectIds = projects.map((p) => p.id);
// Invoices roll up by event → project_id; quotes by quotes.project_id.
let invoices = [];
if (perms.bills !== false) {
invoices = await db('invoices as inv')
.join('events as e', 'e.id', 'inv.event_id')
.whereIn('e.project_id', projectIds)
.select('e.project_id as project_id', 'inv.id', 'inv.deal_uuid',
'inv.total_amount_minor', 'inv.paid_amount_minor', 'inv.currency');
}
let quotes = [];
if (perms.quotes !== false && await hasColumnCached('quotes', 'project_id')) {
quotes = await db('quotes')
.whereIn('project_id', projectIds)
.select('project_id', 'id', 'deal_uuid', 'total_amount_minor', 'currency', 'issue_date');
} else if (perms.quotes !== false && await hasColumnCached('quotes', 'customer_account_id')) {
// Pre-121 fallback: no quotes.project_id column yet. Scope quotes by the
// project's customer (mirrors the detail page) so the list isn't all-zero
// during the upgrade window. Imprecise when one customer owns several
// projects — each then shows the customer's full quote total — but never
// zero. Goes away the moment migration 121 lands.
const custToProjects = new Map();
for (const p of projects) {
if (p.customerAccountId == null) continue;
const list = custToProjects.get(p.customerAccountId) || [];
list.push(p.id); custToProjects.set(p.customerAccountId, list);
}
if (custToProjects.size) {
const rows = await db('quotes')
.whereIn('customer_account_id', Array.from(custToProjects.keys()))
.select('customer_account_id', 'id', 'deal_uuid', 'total_amount_minor', 'currency', 'issue_date');
for (const r of rows) {
for (const pid of (custToProjects.get(r.customer_account_id) || [])) {
quotes.push({ ...r, project_id: pid });
}
}
}
}
const invByProject = new Map();
for (const inv of invoices) {
const list = invByProject.get(inv.project_id) || [];
list.push(inv); invByProject.set(inv.project_id, list);
}
const quoteByProject = new Map();
for (const qt of quotes) {
const list = quoteByProject.get(qt.project_id) || [];
list.push(qt); quoteByProject.set(qt.project_id, list);
}
for (const p of projects) {
p.valuation = computeValuation(invByProject.get(p.id) || [], quoteByProject.get(p.id) || []);
}
}
async function getProjectById(id) {
const row = await db('projects')
.leftJoin('customer_accounts', 'customer_accounts.id', 'projects.customer_account_id')
.select('projects.*', 'customer_accounts.email as customer_email')
.where('projects.id', id)
.first();
return transformProject(row);
}
async function createProject({ name, customerAccountId = null }, adminId) {
if (!name || !String(name).trim()) throw new AppError('Project name is required', 400);
const inserted = await db('projects').insert({
name: String(name).trim(),
customer_account_id: customerAccountId || null,
status: 'active',
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = (inserted[0] && typeof inserted[0] === 'object') ? inserted[0].id : inserted[0];
return getProjectById(id);
}
/** Distinct customer_account_ids referenced by a project's linked content
* (its events, quotes and contracts). Used to keep a project single-customer:
* re-labelling it to a customer that conflicts with existing content is
* rejected. */
async function projectLinkedCustomerIds(id, conn = db) {
const ids = new Set();
if (await hasColumnCached('events', 'project_id') && await conn.schema.hasTable('event_customer_assignments')) {
const rows = await conn('event_customer_assignments as eca')
.join('events as e', 'e.id', 'eca.event_id')
.where('e.project_id', id)
.distinct('eca.customer_account_id as cid');
for (const r of rows) if (r.cid != null) ids.add(Number(r.cid));
}
for (const tbl of ['quotes', 'contracts']) {
if (await hasColumnCached(tbl, 'project_id') && await hasColumnCached(tbl, 'customer_account_id')) {
for (const r of await conn(tbl).where({ project_id: id }).whereNotNull('customer_account_id').distinct('customer_account_id as cid')) {
if (r.cid != null) ids.add(Number(r.cid));
}
}
}
return ids;
}
async function updateProject(id, { name, customerAccountId, status }) {
const existing = await db('projects').where({ id }).first();
if (!existing) throw new AppError('Project not found', 404);
const patch = { updated_at: new Date() };
if (name !== undefined) patch.name = String(name).trim();
if (customerAccountId !== undefined) {
const next = customerAccountId || null;
// Single-customer invariant: don't re-label a project to a customer that
// conflicts with documents/events it already holds. Clearing (null) is fine.
if (next != null) {
const linked = await projectLinkedCustomerIds(id);
for (const cid of linked) {
if (cid !== Number(next)) {
throw new AppError('This project already contains another customers content — clear or move it before reassigning the customer', 422, 'PROJECT_CUSTOMER_MISMATCH');
}
}
}
patch.customer_account_id = next;
}
if (status !== undefined) patch.status = status;
await db('projects').where({ id }).update(patch);
return getProjectById(id);
}
/** Distinct customer_account_ids an event is assigned to (event_customer_assignments
* is many-to-many, but a gallery normally belongs to exactly one account). */
async function eventCustomerIds(eventId, conn = db) {
if (!(await conn.schema.hasTable('event_customer_assignments'))) return [];
const rows = await conn('event_customer_assignments')
.where({ event_id: eventId })
.distinct('customer_account_id');
return rows.map((r) => r.customer_account_id).filter((x) => x != null).map(Number);
}
/** Attach an event to a project (re-points events.project_id).
* Projects are single-customer: an event may only join a project that shares
* its customer. When the project has no customer yet it ADOPTS the event's
* (single) customer — keeping the whole project tied to one customer. */
async function assignEvent(projectId, eventId) {
const project = await db('projects').where({ id: projectId }).first();
if (!project) throw new AppError('Project not found', 404);
const event = await db('events').where({ id: eventId }).first();
if (!event) throw new AppError('Event not found', 404);
const evCustomers = await eventCustomerIds(eventId);
if (project.customer_account_id != null) {
if (evCustomers.length && !evCustomers.includes(Number(project.customer_account_id))) {
throw new AppError('That event belongs to a different customer than this project', 422, 'PROJECT_CUSTOMER_MISMATCH');
}
} else if (evCustomers.length === 1) {
// Empty project adopts the event's single customer (first content wins).
await db('projects').where({ id: projectId }).update({ customer_account_id: evCustomers[0], updated_at: new Date() });
}
await db('events').where({ id: eventId }).update({ project_id: projectId });
return { projectId, eventId };
}
/**
* 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;
// Collect ALL the deal's customers across its quote/contract/invoice lineage
// AND every event it converted into — BEFORE mutating anything, so a link
// with no matching customer is rejected before we re-point data across tenants.
const eventIds = new Set();
const dealCustomerIds = new Set();
const quotesHaveDeal = await hasColumnCached('quotes', 'deal_uuid');
if (quotesHaveDeal) {
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 != null) dealCustomerIds.add(Number(q.customer_account_id));
}
}
const contractsHaveDeal = await hasColumnCached('contracts', 'deal_uuid');
if (contractsHaveDeal && 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 != null) dealCustomerIds.add(Number(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 != null) dealCustomerIds.add(Number(inv.customer_account_id));
}
}
// Single-customer projects, "one customer matches" rule: a customer-assigned
// project rejects the deal only when NONE of the deal's customers is the
// project's customer. An *unassigned* project (customer_account_id null)
// ADOPTS the deal's customer below — "drop the first deal on an empty project".
const project = await conn('projects').where({ id: projectId }).select('customer_account_id').first();
if (!project) throw new AppError('Project not found', 404, 'PROJECT_NOT_FOUND');
if (
project.customer_account_id != null &&
dealCustomerIds.size &&
!dealCustomerIds.has(Number(project.customer_account_id))
) {
throw new AppError('That belongs to a different customer than this project', 422, 'PROJECT_CUSTOMER_MISMATCH');
}
// Cleared to write: link the deal's quotes/contracts, re-point its events so
// invoices/emails/gallery roll up automatically.
if (quotesHaveDeal && await hasColumnCached('quotes', 'project_id')) {
await conn('quotes').where({ deal_uuid: dealUuid }).update({ project_id: projectId });
}
if (contractsHaveDeal && await hasColumnCached('contracts', 'project_id')) {
await conn('contracts').where({ deal_uuid: dealUuid }).update({ project_id: projectId });
}
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 (first deal wins).
if (dealCustomerIds.size && project.customer_account_id == null) {
const adopt = [...dealCustomerIds][0];
await conn('projects').where({ id: projectId }).update({ customer_account_id: adopt, 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) {
if (!(await hasColumnCached(table, 'project_id'))) {
throw new AppError('This instance has no project_id column yet — run migrations', 409);
}
let project = null;
if (projectId != null) {
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);
// Single-customer guard: a document carries exactly one customer, so it may
// only attach to a project that shares it. linkDealToProject re-checks the
// wider deal lineage ("one customer matches"); this is the boundary check
// that also covers the (unassigned-project) detach and standalone-doc cases.
if (
project &&
project.customer_account_id != null &&
doc.customer_account_id != null &&
project.customer_account_id !== doc.customer_account_id
) {
throw new AppError('That belongs to a different customer than this project', 422, 'PROJECT_CUSTOMER_MISMATCH');
}
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 };
}
const assignQuote = (projectId, quoteId) => assignDocument('quotes', projectId, quoteId);
const assignContract = (projectId, contractId) => assignDocument('contracts', projectId, contractId);
/**
* Project valuation — "newest stage wins per deal, cumulative across events".
*
* Each deal (deal_uuid lineage: quote → contract → invoice) contributes ONE
* figure: the invoice total when the deal has reached invoicing (installments
* summed; storno rows net out a cancelled invoice via their negative totals),
* otherwise the newest quote's total. Contracts carry no monetary total in
* picpeak, so they never contribute a number — the "newest" of the three is
* therefore always the invoice when present, else the quote. Documents with
* no deal_uuid each count as their own standalone deal. Totals are kept per
* currency so a mixed-currency project stays correct.
*
* @param {Array} invoices rows with deal_uuid, total_amount_minor, paid_amount_minor, currency
* @param {Array} quotes rows with deal_uuid, total_amount_minor, currency, issue_date
* @returns {{ byCurrency: Array<{currency:string,totalMinor:number,paidMinor:number}> }}
*/
function computeValuation(invoices = [], quotes = []) {
const deals = new Map();
const get = (key, currency) => {
let d = deals.get(key);
if (!d) { d = { currency, invoiceMinor: 0, paidMinor: 0, hasInvoice: false, quoteMinor: 0, quoteDate: null }; deals.set(key, d); }
return d;
};
for (const inv of invoices) {
const d = get(inv.deal_uuid || `i-${inv.id}`, inv.currency || 'CHF');
d.hasInvoice = true;
d.invoiceMinor += Number(inv.total_amount_minor || 0);
d.paidMinor += Number(inv.paid_amount_minor || 0);
d.currency = inv.currency || d.currency;
}
for (const q of quotes) {
const d = get(q.deal_uuid || `q-${q.id}`, q.currency || 'CHF');
const qd = q.issue_date ? new Date(q.issue_date).getTime() : 0;
if (d.quoteDate === null || qd >= d.quoteDate) {
d.quoteMinor = Number(q.total_amount_minor || 0);
d.quoteDate = qd;
if (!d.hasInvoice) d.currency = q.currency || d.currency;
}
}
const byCurrency = new Map();
for (const d of deals.values()) {
const value = d.hasInvoice ? d.invoiceMinor : d.quoteMinor;
const cur = d.currency || 'CHF';
const b = byCurrency.get(cur) || { totalMinor: 0, paidMinor: 0 };
b.totalMinor += value;
b.paidMinor += d.paidMinor;
byCurrency.set(cur, b);
}
return {
byCurrency: Array.from(byCurrency.entries()).map(([currency, v]) => ({
currency, totalMinor: v.totalMinor, paidMinor: v.paidMinor,
})),
};
}
/**
* Full overview aggregation for the cockpit. Returns the project, its events,
* and the rolled-up emails / quotes / contracts / invoices / hours + a
* timeline of milestones. `perms` gates which doc types are included.
*/
async function getProjectOverview(id, perms = {}) {
const project = await getProjectById(id);
if (!project) throw new AppError('Project not found', 404);
const events = await db('events')
.where({ project_id: id })
.select('id', 'event_name', 'event_date', 'slug', 'is_active', 'is_draft', 'expires_at', 'is_archived');
const eventIds = events.map((e) => e.id);
const out = { project, events, emails: [], quotes: [], contracts: [], invoices: [], hours: { entries: [], totalMinutes: 0 } };
// Invoices (by event) incl. storno.
if (eventIds.length && perms.bills !== false) {
out.invoices = await db('invoices')
.whereIn('event_id', eventIds)
.select('id', 'invoice_number', 'status', 'kind', 'issue_date', 'due_date',
'total_amount_minor', 'paid_amount_minor', 'paid_at', 'currency', 'event_id', '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;
}
// Emails — newest first. rendered_html presence flagged; body fetched lazily
// by the preview endpoint. Two precisely-scoped sources, never the recipient
// string alone (a shared family inbox must NOT leak another customer's mail):
// 1. Gallery/event mails — carry event_id, and the events belong to this
// project, so whereIn(eventIds) is already exact.
// 2. CRM document mails (quote_/contract_/invoice_/storno_) — queued with
// event_id=null + recipient=customer email. We use the recipient only as
// a cheap candidate filter, then KEEP a row only when its email_data
// document number matches one of THIS project's loaded documents. That
// both scopes to the right customer and excludes system/admin alerts
// (backup_failed, …) sent to the same inbox.
const selectCols = ['id', 'recipient_email', 'email_type', 'status', 'created_at', 'sent_at', 'error_message', 'event_id',
// Exact stored preview available? (CASE is cross-DB: SQLite→0/1, PG→int)
db.raw('CASE WHEN rendered_html IS NOT NULL THEN 1 ELSE 0 END as has_rendered')];
const mapEmail = (e) => ({
id: e.id, recipient: e.recipient_email, type: e.email_type, status: e.status,
queuedAt: e.created_at, sentAt: e.sent_at, error: e.error_message, eventId: e.event_id,
// false → the cockpit preview will re-render from the current template.
stored: !!Number(e.has_rendered),
});
const emailRows = [];
if (eventIds.length) {
const eventEmails = await db('email_queue')
.whereIn('event_id', eventIds)
.select(selectCols)
.orderBy('created_at', 'desc')
.limit(200);
emailRows.push(...eventEmails);
}
const customerEmail = project.customerEmail || null;
// The set of document numbers that belong to this project (across the doc
// types the admin may see). CRM emails carry their number in email_data.
const docNumbers = new Set();
for (const q of out.quotes) if (q.quote_number != null) docNumbers.add(String(q.quote_number));
for (const c of out.contracts) if (c.contract_number != null) docNumbers.add(String(c.contract_number));
for (const inv of out.invoices) if (inv.invoice_number != null) docNumbers.add(String(inv.invoice_number));
if (customerEmail && docNumbers.size) {
const crmCandidates = await db('email_queue')
.where('recipient_email', customerEmail)
.whereNull('event_id')
.andWhere(function () {
// LIKE '_' is a single-char wildcard matching the literal underscore in
// every CRM type; no escape needed and no real type collides with '%'.
for (const prefix of ['quote_%', 'contract_%', 'invoice_%', 'storno_%']) {
this.orWhere('email_type', 'like', prefix);
}
})
.select([...selectCols, 'email_data'])
.orderBy('created_at', 'desc')
.limit(200);
for (const r of crmCandidates) {
let data = r.email_data;
if (typeof data === 'string') { try { data = JSON.parse(data); } catch (_) { data = {}; } }
data = data || {};
// Match on any document-number key a CRM template carries (storno mails
// use storno_number / original_invoice_number, not invoice_number).
const candidates = [data.quote_number, data.contract_number, data.invoice_number,
data.storno_number, data.original_invoice_number];
if (candidates.some((n) => n != null && docNumbers.has(String(n)))) emailRows.push(r);
}
}
// Merge both sources, newest first, capped.
emailRows.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
out.emails = emailRows.slice(0, 200).map(mapEmail);
// Hours (by project_id) — individual entries + total.
const hours = await db('customer_hour_entries')
.where({ project_id: id })
.select('id', 'entry_date', 'duration_minutes', 'description', 'status', 'invoice_id')
.orderBy('entry_date', 'desc');
out.hours = {
entries: hours,
totalMinutes: hours.reduce((s, h) => s + Number(h.duration_minutes || 0), 0),
};
// Timeline milestones (latest of each kind that exists), each dated.
const milestones = [];
const firstQuote = out.quotes.at(-1);
if (firstQuote) milestones.push({ kind: 'quote', id: firstQuote.id, label: firstQuote.quote_number, date: firstQuote.issue_date });
const firstContract = out.contracts.at(-1);
if (firstContract) milestones.push({ kind: 'contract', id: firstContract.id, label: firstContract.contract_number, date: firstContract.issue_date });
const pubEvent = events.find((e) => e.is_active && !e.is_draft);
if (pubEvent) milestones.push({ kind: 'gallery', id: pubEvent.id, label: pubEvent.event_name, date: pubEvent.event_date });
const firstInvoice = out.invoices.at(-1);
if (firstInvoice) milestones.push({ kind: 'invoice', id: firstInvoice.id, label: firstInvoice.invoice_number, date: firstInvoice.issue_date });
out.milestones = milestones;
// Rolled-up project value (newest stage wins per deal, cumulative).
out.valuation = computeValuation(out.invoices, out.quotes);
return out;
}
/**
* HTML preview for an email_queue row (cockpit). Prefers the exact bytes
* stored at send time (rendered_html). Rows sent before that column existed
* have none — we then RE-RENDER from the current template + the row's stored
* variables (email_data) so the admin still sees the email, flagged `exact:
* false`. Only when even re-rendering fails (template gone / no variables)
* does `available:false` fall through to the "nothing stored" note.
*/
async function getEmailPreview(emailId) {
const row = await db('email_queue')
.where({ id: emailId })
.select('id', 'recipient_email', 'email_type', 'status', 'rendered_html', 'email_data')
.first();
if (!row) throw new AppError('Email not found', 404);
if (row.rendered_html) {
return { id: row.id, recipient: row.recipient_email, type: row.email_type, status: row.status, available: true, exact: true, html: row.rendered_html };
}
// Fallback: re-render from the current template + stored variables.
let html = null;
try {
let variables = row.email_data;
if (typeof variables === 'string') variables = JSON.parse(variables);
const { renderQueuedEmail } = require('./emailProcessor');
const rendered = await renderQueuedEmail(row.email_type, variables || {}, row.recipient_email);
html = rendered && rendered.html ? rendered.html : null;
} catch (_) { html = null; }
return {
id: row.id,
recipient: row.recipient_email,
type: row.email_type,
status: row.status,
available: !!html,
exact: false,
html,
};
}
// ── Email actions (from the cockpit feed) ───────────────────────────────
/** Audit every admin email action uniformly (mark-paid / cancel / reissue in
* the CRM services all log; these were the gap). Best-effort — never blocks. */
async function logEmailAction(activityType, emailId, row, adminId) {
try {
await logActivity(
activityType,
{ queueId: emailId, emailType: row && row.email_type, recipient: row && row.recipient_email },
(row && row.event_id) || null,
adminId ? { type: 'admin', id: adminId } : null,
);
} catch (_) { /* audit is best-effort */ }
}
async function resendEmail(emailId, adminId = null) {
const row = await db('email_queue').where({ id: emailId }).first();
if (!row) throw new AppError('Email not found', 404);
// Normalise email_data to match the canonical enqueue (emailProcessor.js
// stores JSON.stringify(...) in the json column). PG returns jsonb as a
// parsed object, SQLite as a string — re-stringify the object form so the
// resent row is never double-encoded.
let emailData = row.email_data;
if (emailData != null && typeof emailData !== 'string') emailData = JSON.stringify(emailData);
const insert = await db('email_queue').insert({
recipient_email: row.recipient_email,
email_type: row.email_type,
email_data: emailData,
event_id: row.event_id,
status: 'pending',
retry_count: 0,
created_at: new Date(),
}).returning('id');
const id = (insert[0] && typeof insert[0] === 'object') ? insert[0].id : insert[0];
await logEmailAction('project_email_resent', id, row, adminId);
return { id, status: 'pending' };
}
async function cancelEmail(emailId, adminId = null) {
const row = await db('email_queue').where({ id: emailId }).first();
if (!row) throw new AppError('Email not found', 404);
if (row.status !== 'pending') throw new AppError('Only pending emails can be cancelled', 409);
await db('email_queue').where({ id: emailId }).update({ status: 'cancelled' });
await logEmailAction('project_email_cancelled', emailId, row, adminId);
return { id: emailId, status: 'cancelled' };
}
async function retryEmail(emailId, adminId = null) {
const row = await db('email_queue').where({ id: emailId }).first();
if (!row) throw new AppError('Email not found', 404);
await db('email_queue').where({ id: emailId })
.update({ status: 'pending', retry_count: 0, error_message: null, scheduled_at: null });
await logEmailAction('project_email_retried', emailId, row, adminId);
return { id: emailId, status: 'pending' };
}
async function sendEmailNow(emailId, adminId = null) {
const row = await db('email_queue').where({ id: emailId }).first();
if (!row) throw new AppError('Email not found', 404);
await db('email_queue').where({ id: emailId }).update({ status: 'pending', scheduled_at: null });
// Flush ONLY this email — passing onlyId scopes processEmailQueue to a single
// row so a forced "send now" never force-retries OTHER dead-lettered emails
// (those that already exceeded the retry cap) just because we bypass it here.
const { processEmailQueue } = require('./emailProcessor');
const result = await processEmailQueue({ ignoreSchedule: true, onlyId: emailId });
await logEmailAction('project_email_sent_now', emailId, row, adminId);
return result;
}
module.exports = {
listProjects,
getProjectById,
createProject,
updateProject,
assignEvent,
assignQuote,
assignContract,
linkDealToProject,
computeValuation,
getProjectOverview,
getEmailPreview,
resendEmail,
cancelEmail,
retryEmail,
sendEmailNow,
};
+22
View File
@@ -554,9 +554,20 @@ 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];
// 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) {
// Normalise rows for the hierarchical-insert helper. We preserve
// the wire-only `parent_position` field here so the helper can
@@ -667,8 +678,19 @@ 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);
// 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
// sends the canonical line-item set on every save, we drop the
// old rows and rebuild from scratch. CASCADE on parent_line_item_id