feat(crm): Project Overview phase 2 — project service + routes

Backend API for the cockpit (admin-only, Model A):
- projectService: list/get/create/update, assignEvent (re-point events.project_id),
  getProjectOverview (rollup — invoices/emails/gallery by event, quotes/contracts
  by customer since they carry no event_id, hours by project_id, + a milestone
  timeline), getEmailPreview (actual sent HTML).
- adminProjects routes (/api/admin/projects): read=events.view, write=events.manage;
  the overview gates each money-doc type on the admin's own bills/quotes/contracts
  .view permission. Registered in server.js.
All aggregation queries verified against the real schema on a temp DB.
This commit is contained in:
Luca
2026-06-06 03:50:55 +02:00
parent efa47d697d
commit eb263137b9
3 changed files with 310 additions and 0 deletions
+1
View File
@@ -703,6 +703,7 @@ app.use('/api/admin/business-profile', require('./src/routes/adminBusinessProfil
app.use('/api/admin/quotes', require('./src/routes/adminQuotes'));
app.use('/api/admin/invoices', require('./src/routes/adminInvoices'));
app.use('/api/admin/contracts', require('./src/routes/adminContracts'));
app.use('/api/admin/projects', require('./src/routes/adminProjects'));
app.use('/api/admin/calendar', require('./src/routes/adminCalendar'));
app.use('/api/admin/deals', require('./src/routes/adminDeals'));
app.use('/api/admin/tax-report', require('./src/routes/adminTaxReport'));
+102
View File
@@ -0,0 +1,102 @@
/**
* 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 router = express.Router();
router.use(adminAuth);
// List
router.get('/', requirePermission('events.view'), handleAsync(async (req, res) => {
const projects = await projectService.listProjects({
search: req.query.q || '',
status: req.query.status || null,
});
return successResponse(res, { projects });
}));
// Create
router.post('/',
requirePermission('events.manage'),
[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.manage'),
[
param('id').isInt({ min: 1 }),
body('name').optional().isString().trim().isLength({ min: 1, max: 255 }),
body('customerAccountId').optional({ values: 'null' }).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.manage'),
[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');
}),
);
// 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);
}));
module.exports = router;
+207
View File
@@ -0,0 +1,207 @@
/**
* 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 } = require('../database/db');
const { AppError } = require('../utils/errors');
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. */
async function listProjects({ search = '', status = null } = {}) {
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;
return rows.map(transformProject);
}
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);
}
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) patch.customer_account_id = customerAccountId || null;
if (status !== undefined) patch.status = status;
await db('projects').where({ id }).update(patch);
return getProjectById(id);
}
/** Attach an event to a project (re-points events.project_id). */
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);
await db('events').where({ id: eventId }).update({ project_id: projectId });
return { projectId, eventId };
}
/**
* 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 } };
// Emails (by event) — newest first. rendered_html presence flagged, body
// itself fetched lazily by the preview endpoint.
if (eventIds.length) {
const emails = await db('email_queue')
.whereIn('event_id', eventIds)
.select('id', 'recipient_email', 'email_type', 'status', 'created_at', 'sent_at', 'error_message', 'event_id')
.orderBy('created_at', 'desc')
.limit(200);
out.emails = emails.map((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,
}));
}
// 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 (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');
}
}
// 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[out.quotes.length - 1];
if (firstQuote) milestones.push({ kind: 'quote', label: firstQuote.quote_number, date: firstQuote.issue_date });
const firstContract = out.contracts[out.contracts.length - 1];
if (firstContract) milestones.push({ kind: 'contract', 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', label: pubEvent.event_name, date: pubEvent.event_date });
const firstInvoice = out.invoices[out.invoices.length - 1];
if (firstInvoice) milestones.push({ kind: 'invoice', label: firstInvoice.invoice_number, date: firstInvoice.issue_date });
out.milestones = milestones;
return out;
}
/**
* The ACTUAL sent HTML for an email_queue row (cockpit preview). Rows sent
* before the rendered_html column existed have none → `available:false`, the
* frontend then shows a "preview not stored" note rather than a stale
* re-render.
*/
async function getEmailPreview(emailId) {
const row = await db('email_queue')
.where({ id: emailId })
.select('id', 'recipient_email', 'email_type', 'status', 'rendered_html')
.first();
if (!row) throw new AppError('Email not found', 404);
return {
id: row.id,
recipient: row.recipient_email,
type: row.email_type,
status: row.status,
available: !!row.rendered_html,
html: row.rendered_html || null,
};
}
module.exports = {
listProjects,
getProjectById,
createProject,
updateProject,
assignEvent,
getProjectOverview,
getEmailPreview,
};