From a2b2d3fb313f347bac505b5d1846dce2b679ce55 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:42:08 +0200 Subject: [PATCH 01/29] fix(crm): PR #603 review follow-ups + Outlook-proof email design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the maintainer's non-blocking review items + the Outlook email bug: - invoice create: verify the chosen event belongs to the customer (only when the event has assignments; legacy unassigned events pass through). - mark-paid + import: bound paidAt to [2000-01-01, now+30d] so a typo'd year can't silently drop a payment out of every cash-basis revenue window. - customer routes: country_code now {min:2,max:2}+isAlpha+uppercase-normalize (was isString/max:2 — allowed '', '1', '!@'), matching the business-profile route. - email transporter: close the previous instance before re-init (leak guard for a future pooled transport). - scheduled-email tz: warn loudly when business_hours is set but the profile timezone is blank (was silently using the server/UTC tz). - wrapEmailHtml: rebuild the chrome as inline-styled tables + bgcolor and inline the themed CTA button, so the design survives Outlook/Apple Mail stripping the head - -
-
- - - -
-
+ + + + + + `; } @@ -886,7 +916,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 }); diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 91a92e68..4f708097 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -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 From ea09a86d0505b08f38e70be0c8717df414144a75 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:42:08 +0200 Subject: [PATCH 02/29] fix(crm): recent-activity email placeholder + customer-dashboard locale dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recent Activity rendered literal {{email}} — the per-row t() call didn't pass the email interpolation var. Source it like formatActivityMessage (metadata.email ?? actorName). - Customer 'Deine Galerien' dates rendered en-US ('May','Jun') under a German UI because they used raw date-fns format(parseISO(iso),'PP') with no locale. Route through useLocalizedDate().format → honours general_date_format + the active language. --- frontend/src/pages/admin/AdminDashboard.tsx | 5 +++++ frontend/src/pages/customer/CustomerDashboardPage.tsx | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index 6f6c749f..4fd0ee0f 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -276,6 +276,11 @@ export const AdminDashboard: React.FC = () => { const getActivityMessage = (): string => { const params: Record = { eventName: activity.eventName || t('common.unknown'), + // Customer/account activity keys (customer_login, + // customer_invitation_*, customer_updated, …) interpolate + // {{email}}; without it the literal placeholder rendered. + // Sourced the same way formatActivityMessage does. + email: activity.metadata?.email || activity.actorName || '', count: activity.metadata?.count || 0, template: activity.metadata?.template_key || '', categoryName: activity.metadata?.category_name || '' diff --git a/frontend/src/pages/customer/CustomerDashboardPage.tsx b/frontend/src/pages/customer/CustomerDashboardPage.tsx index e9e5c152..b94711f3 100644 --- a/frontend/src/pages/customer/CustomerDashboardPage.tsx +++ b/frontend/src/pages/customer/CustomerDashboardPage.tsx @@ -25,7 +25,7 @@ import { useNavigate } from 'react-router-dom'; import { Calendar, Clock, Download, ExternalLink, ImageIcon, AlertCircle } from 'lucide-react'; import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; -import { format, parseISO } from 'date-fns'; +import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useQuery } from '@tanstack/react-query'; import { Button, Loading } from '../../components/common'; @@ -51,6 +51,10 @@ const DEFAULT_SORT: SortKey = 'newest'; export const CustomerDashboardPage: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); + // Localized date formatting — respects general_date_format + the active UI + // language. Previously used raw date-fns `format(parseISO(iso),'PP')` with + // no locale, so dates rendered en-US ("May", "Jun") under a German UI. + const { format: fmtLocalized } = useLocalizedDate(); const { data: events, isLoading, error } = useQuery({ queryKey: ['customer-events'], @@ -125,7 +129,7 @@ export const CustomerDashboardPage: React.FC = () => { const formatDate = (iso: string | null) => { if (!iso) return null; - try { return format(parseISO(iso), 'PP'); } catch { return null; } + try { return fmtLocalized(iso); } catch { return null; } }; return ( From 43b10f91c05818ad244f64284aef6b83b0319214 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:22:46 +0200 Subject: [PATCH 03/29] fix(crm): localize scheduled-send + installment date + timezone picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From dev testing: - BillEditor 'Geplanter Versand' was a native → rendered US date + 12h regardless of settings. Split into LocalizedDateInput + TimeField (honour general_date_format + general_time_format), recombined into the YYYY-MM-DDTHH:MM the payload/scheduler expect. - InstallmentsPanel 'Send on' native (browser-locale via a lang hint, wrong in Safari/Firefox) → LocalizedDateInput, consistent in every browser. (Luca approved converting it.) - Business-profile Timezone was a free-text input → dropdown of the full IANA list (Intl.supportedValuesOf, CH/LI fallback), blank = system default. --- .../components/admin/InstallmentsPanel.tsx | 11 ++--- .../src/pages/admin/bills/BillEditorPage.tsx | 27 +++++++++++- .../settings/SettingsBusinessProfilePage.tsx | 43 ++++++++++++++----- 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/admin/InstallmentsPanel.tsx b/frontend/src/components/admin/InstallmentsPanel.tsx index 39c2c8af..e735274c 100644 --- a/frontend/src/components/admin/InstallmentsPanel.tsx +++ b/frontend/src/components/admin/InstallmentsPanel.tsx @@ -26,10 +26,9 @@ import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Trash2, Plus } from 'lucide-react'; -import { Button, Input } from '../common'; +import { Button, Input, LocalizedDateInput } from '../common'; import type { PaymentTermInstallment } from '../../services/quotes.service'; import { useInstallmentDefaults } from '../../hooks/useInstallmentDefaults'; -import { useLocalizedDate } from '../../hooks/useLocalizedDate'; export type InstallmentPlan = PaymentTermInstallment[]; @@ -69,7 +68,6 @@ export const InstallmentsPanel: React.FC = ({ value, onChange, onValidityChange, eventDate, disabled, }) => { const { t } = useTranslation(); - const { dateInputLang } = useLocalizedDate(); const defaults = useInstallmentDefaults(); const [advanced, setAdvanced] = React.useState(false); @@ -230,12 +228,9 @@ export const InstallmentsPanel: React.FC = ({ 'On delivery — admin releases manually. Switch to advanced to change.')} ) : ( - { - const next = e.target.value; + onChange={(next) => { if (!next) return; const offset = daysBetween(todayIso(), next); update(idx, { trigger: 'fixed_date', offset_days: offset }); diff --git a/frontend/src/pages/admin/bills/BillEditorPage.tsx b/frontend/src/pages/admin/bills/BillEditorPage.tsx index 81cfd8d3..228f5281 100644 --- a/frontend/src/pages/admin/bills/BillEditorPage.tsx +++ b/frontend/src/pages/admin/bills/BillEditorPage.tsx @@ -566,8 +566,31 @@ export const BillEditorPage: React.FC = () => { : t('bills.field.dueDateOverrideOff', 'Auto from send date + payment term — tick to set manually')} - setScheduledSendAt(e.target.value)} /> +
+ + {/* Localized date + time (honours general_date_format + + general_time_format) instead of a native datetime-local, which + renders in the browser locale (US date + 12h). Recombined into + the "YYYY-MM-DDTHH:MM" the payload + scheduler expect. */} +
+ { + if (!iso) { setScheduledSendAt(''); return; } + const time = scheduledSendAt.length >= 16 ? scheduledSendAt.slice(11, 16) : '09:00'; + setScheduledSendAt(`${iso}T${time}`); + }} + /> + = 16 ? scheduledSendAt.slice(11, 16) : ''} + onChange={(hhmm) => { + const date = scheduledSendAt ? scheduledSendAt.slice(0, 10) : ''; + if (!date) return; + setScheduledSendAt(`${date}T${hhmm || '09:00'}`); + }} + /> +
+
setProfile({ ...profile, defaultLocale: e.target.value })} /> - {/* Migration 137 — IANA timezone string for the admin calendar. - Free-text; backend caps at 64 chars. When blank the calendar - UI falls back to the browser's `Intl.DateTimeFormat() - .resolvedOptions().timeZone`. */} - setProfile({ ...profile, timezone: e.target.value || null })} - /> + {/* Migration 137 — IANA timezone for the admin calendar + the + scheduled-email business-hours snapping. Dropdown of the full + IANA list; blank = fall back to the server/browser tz. */} +
+ + +
setProfile({ ...profile, vatLabel: e.target.value })} /> Date: Sat, 6 Jun 2026 03:06:21 +0200 Subject: [PATCH 04/29] =?UTF-8?q?feat(crm):=20Project=20Overview=20phase?= =?UTF-8?q?=201=20=E2=80=94=20projects=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data model for the admin-only Project Overview cockpit (Model A — projects group events; money docs stay per-event and roll up). - migration 117: projects table (name, customer_account_id nullable, status) + events.project_id FK; backfill one auto-project per existing event (1:1 default, customer = the event's single assignment when unambiguous), admins relink freely afterward. 1 project : N events. - migration 118: customer_hour_entries.project_id (book hours to a project). - migration 119: email_queue.rendered_html (store actual sent HTML for the cockpit's email preview). All idempotent (hasTable/hasColumn guards), reversible downs. Verified: full migration boot + backfill on a temp DB. --- backend/migrations/core/117_add_projects.js | 85 +++++++++++++++++++ .../118_add_project_id_to_hour_entries.js | 30 +++++++ .../119_add_rendered_html_to_email_queue.js | 31 +++++++ 3 files changed, 146 insertions(+) create mode 100644 backend/migrations/core/117_add_projects.js create mode 100644 backend/migrations/core/118_add_project_id_to_hour_entries.js create mode 100644 backend/migrations/core/119_add_rendered_html_to_email_queue.js diff --git a/backend/migrations/core/117_add_projects.js b/backend/migrations/core/117_add_projects.js new file mode 100644 index 00000000..98417e27 --- /dev/null +++ b/backend/migrations/core/117_add_projects.js @@ -0,0 +1,85 @@ +/** + * Migration: Projects — an admin-only grouping layer ABOVE events + * (Project Overview cockpit, Model A). + * + * A project groups one OR MORE events of (usually) one customer; all the + * money documents (quotes/contracts/invoices) stay attached to their EVENT + * and the project simply rolls them up. Customers never see projects. + * + * projects id, name, customer_account_id (nullable), status, + * timestamps. + * events.project_id FK → projects (nullable, SET NULL on project delete). + * + * Backfill: every existing event gets its OWN auto-created project (the + * 1:1 default) so nothing is unassigned; admins then relink freely (group + * several events under one project, move events between projects). The + * auto-project's customer = the event's single assigned customer when there + * is exactly one, else NULL (admin sets it later). Cardinality is 1:N — the + * per-event auto-project is only the starting point, never a hard rule. + * + * Idempotent: table + column guarded; backfill touches only events whose + * project_id is still NULL, so a re-run is a no-op. + */ + +exports.up = async function (knex) { + // 1. projects table + if (!(await knex.schema.hasTable('projects'))) { + await knex.schema.createTable('projects', (table) => { + table.increments('id').primary(); + table.string('name', 255).notNullable(); + // Nullable: a multi-customer or not-yet-assigned project has no single + // customer. SET NULL so erasing a customer doesn't delete the project. + table.integer('customer_account_id').unsigned() + .references('id').inTable('customer_accounts').onDelete('SET NULL'); + table.string('status', 24).notNullable().defaultTo('active'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.index(['customer_account_id']); + }); + } + + // 2. events.project_id + if ((await knex.schema.hasTable('events')) && !(await knex.schema.hasColumn('events', 'project_id'))) { + await knex.schema.alterTable('events', (table) => { + table.integer('project_id').unsigned() + .references('id').inTable('projects').onDelete('SET NULL'); + table.index(['project_id']); + }); + } + + // 3. Backfill one auto-project per still-unassigned event. + if ((await knex.schema.hasTable('events')) && (await knex.schema.hasColumn('events', 'project_id'))) { + const events = await knex('events').whereNull('project_id').select('id', 'event_name'); + const hasAssignments = await knex.schema.hasTable('event_customer_assignments'); + for (const ev of events) { + let customerId = null; + if (hasAssignments) { + const rows = await knex('event_customer_assignments') + .where({ event_id: ev.id }) + .select('customer_account_id'); + if (rows.length === 1) customerId = rows[0].customer_account_id; + } + const name = (ev.event_name && String(ev.event_name).trim()) || `Event ${ev.id}`; + const inserted = await knex('projects').insert({ + name, + customer_account_id: customerId, + status: 'active', + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }).returning('id'); + const projectId = (inserted[0] && typeof inserted[0] === 'object') ? inserted[0].id : inserted[0]; + await knex('events').where({ id: ev.id }).update({ project_id: projectId }); + } + } +}; + +exports.down = async function (knex) { + if ((await knex.schema.hasTable('events')) && (await knex.schema.hasColumn('events', 'project_id'))) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('project_id'); + }); + } + if (await knex.schema.hasTable('projects')) { + await knex.schema.dropTable('projects'); + } +}; diff --git a/backend/migrations/core/118_add_project_id_to_hour_entries.js b/backend/migrations/core/118_add_project_id_to_hour_entries.js new file mode 100644 index 00000000..457faea8 --- /dev/null +++ b/backend/migrations/core/118_add_project_id_to_hour_entries.js @@ -0,0 +1,30 @@ +/** + * Migration: book logged hours to a project. + * + * Adds customer_hour_entries.project_id (nullable FK → projects, SET NULL). + * Hours stay primarily customer-scoped; the optional project link powers the + * "book to project" checkbox + the Project Overview hours roll-up. Null = + * not booked to a project (existing behaviour preserved). + * + * Idempotent: column guarded by hasColumn. + */ + +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('customer_hour_entries'))) return; + if (!(await knex.schema.hasColumn('customer_hour_entries', 'project_id'))) { + await knex.schema.alterTable('customer_hour_entries', (table) => { + table.integer('project_id').unsigned() + .references('id').inTable('projects').onDelete('SET NULL'); + table.index(['project_id']); + }); + } +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('customer_hour_entries'))) return; + if (await knex.schema.hasColumn('customer_hour_entries', 'project_id')) { + await knex.schema.alterTable('customer_hour_entries', (table) => { + table.dropColumn('project_id'); + }); + } +}; diff --git a/backend/migrations/core/119_add_rendered_html_to_email_queue.js b/backend/migrations/core/119_add_rendered_html_to_email_queue.js new file mode 100644 index 00000000..73309d16 --- /dev/null +++ b/backend/migrations/core/119_add_rendered_html_to_email_queue.js @@ -0,0 +1,31 @@ +/** + * Migration: store the rendered email HTML at send time. + * + * The Project Overview cockpit previews the ACTUAL email that was sent (not a + * re-render from the current template, which may have changed). email_queue + * only stored the template variables (email_data), so add a rendered_html + * column the sender populates with the final wrapped HTML on dispatch. + * + * Nullable: rows queued/sent before this column existed have no stored HTML — + * the cockpit reconstructs those from email_data with a "reconstructed" note. + * + * Idempotent: column guarded by hasColumn. + */ + +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('email_queue'))) return; + if (!(await knex.schema.hasColumn('email_queue', 'rendered_html'))) { + await knex.schema.alterTable('email_queue', (table) => { + table.text('rendered_html'); + }); + } +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('email_queue'))) return; + if (await knex.schema.hasColumn('email_queue', 'rendered_html')) { + await knex.schema.alterTable('email_queue', (table) => { + table.dropColumn('rendered_html'); + }); + } +}; From eb263137b98935754155824de2a03848121304b6 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 03:50:55 +0200 Subject: [PATCH 05/29] =?UTF-8?q?feat(crm):=20Project=20Overview=20phase?= =?UTF-8?q?=202=20=E2=80=94=20project=20service=20+=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/server.js | 1 + backend/src/routes/adminProjects.js | 102 ++++++++++++ backend/src/services/projectService.js | 207 +++++++++++++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 backend/src/routes/adminProjects.js create mode 100644 backend/src/services/projectService.js diff --git a/backend/server.js b/backend/server.js index bc1427fc..2b19f3bb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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')); diff --git a/backend/src/routes/adminProjects.js b/backend/src/routes/adminProjects.js new file mode 100644 index 00000000..ec19d326 --- /dev/null +++ b/backend/src/routes/adminProjects.js @@ -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; diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js new file mode 100644 index 00000000..54db20d7 --- /dev/null +++ b/backend/src/services/projectService.js @@ -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, +}; From 874c91f944d8397edaf9f48091bab3bf5cfc30e1 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 04:00:16 +0200 Subject: [PATCH 06/29] =?UTF-8?q?feat(crm):=20Project=20Overview=20phase?= =?UTF-8?q?=203=20=E2=80=94=20persist=20sent=20email=20HTML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processEmailQueue now stores the actual rendered HTML in email_queue .rendered_html on a successful send (sendTemplateEmail returns it). Guarded by hasColumnCached so installs without migration 119 just skip it; never blocks the send. Powers the cockpit's exact-sent email preview. --- backend/src/services/emailProcessor.js | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index 58462bfa..2539dcc5 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -763,7 +763,9 @@ 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; @@ -839,19 +841,25 @@ 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 { + const { hasColumnCached } = require('../utils/schemaCache'); + 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`); From 1bf0b34ea5e280770e0262660586f345860b0325 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:59:57 +0200 Subject: [PATCH 07/29] feat(projects): gate Project Overview behind a projects feature flag + cockpit email actions - Migration 120 seeds the projects flag (default OFF), idempotent. - Backend feature-flags whitelist + DEFAULT_FLAGS + clients derivation. - adminProjects routes 403 PROJECTS_DISABLED when the flag is off. - projectService email actions (resend/cancel/retry/send-now) + routes. - Frontend flag type, DEFAULT_FLAGS, Features tab card (en+de). --- .../core/120_seed_projects_feature_flag.js | 24 ++++++++++ backend/src/routes/adminFeatureFlags.js | 7 +++ backend/src/routes/adminProjects.js | 25 ++++++++++ backend/src/services/projectService.js | 46 +++++++++++++++++++ frontend/src/contexts/FeatureFlagsContext.tsx | 4 ++ .../features/settings/tabs/FeaturesTab.tsx | 15 ++++++ frontend/src/i18n/locales/de.json | 5 ++ frontend/src/i18n/locales/en.json | 5 ++ frontend/src/services/featureFlags.service.ts | 6 ++- 9 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/core/120_seed_projects_feature_flag.js diff --git a/backend/migrations/core/120_seed_projects_feature_flag.js b/backend/migrations/core/120_seed_projects_feature_flag.js new file mode 100644 index 00000000..2673ddd9 --- /dev/null +++ b/backend/migrations/core/120_seed_projects_feature_flag.js @@ -0,0 +1,24 @@ +/** + * Migration: seed the `projects` feature flag (default OFF). + * + * Gates the admin-only Project Overview cockpit + the "book to project" hours + * control. Off by default so existing installs don't suddenly surface a new + * top-level CRM area — the admin opts in under Settings → Features, exactly + * like bills/quotes/contracts/hours. + * + * Idempotent: inserts only when the row is missing (migration 088 already + * seeded the original flag set on fresh installs and won't re-run). + */ + +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('feature_flags'))) return; + const existing = await knex('feature_flags').where({ key: 'projects' }).first(); + if (!existing) { + await knex('feature_flags').insert({ key: 'projects', value: false }); + } +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('feature_flags'))) return; + await knex('feature_flags').where({ key: 'projects' }).del(); +}; diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js index f76b2ceb..04575e1d 100644 --- a/backend/src/routes/adminFeatureFlags.js +++ b/backend/src/routes/adminFeatureFlags.js @@ -62,6 +62,10 @@ const KNOWN_FLAGS = [ // upload). Seeded block bodies are EXAMPLES ONLY; admins must have a // lawyer review before sending. See docs/crm-disclaimers.md. 'contracts', + // 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 @@ -84,6 +88,7 @@ const DEFAULT_FLAGS = { taxReport: false, hoursLogging: false, contracts: false, + projects: false, }; async function readAllFlags() { @@ -123,6 +128,8 @@ function applyDependencyRules(flags) { || out.taxReport || out.hoursLogging || out.contracts + // 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.) diff --git a/backend/src/routes/adminProjects.js b/backend/src/routes/adminProjects.js index ec19d326..180566b4 100644 --- a/backend/src/routes/adminProjects.js +++ b/backend/src/routes/adminProjects.js @@ -14,10 +14,23 @@ 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) => { const projects = await projectService.listProjects({ @@ -99,4 +112,16 @@ router.get('/email/:emailId/preview', requirePermission('events.view'), [param(' 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)); + 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; diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js index 54db20d7..9755e22b 100644 --- a/backend/src/services/projectService.js +++ b/backend/src/services/projectService.js @@ -196,6 +196,48 @@ async function getEmailPreview(emailId) { }; } +// ── Email actions (from the cockpit feed) ─────────────────────────────── + +async function resendEmail(emailId) { + const row = await db('email_queue').where({ id: emailId }).first(); + if (!row) throw new AppError('Email not found', 404); + const insert = await db('email_queue').insert({ + recipient_email: row.recipient_email, + email_type: row.email_type, + email_data: row.email_data, + 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]; + return { id, status: 'pending' }; +} + +async function cancelEmail(emailId) { + 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' }); + return { id: emailId, status: 'cancelled' }; +} + +async function retryEmail(emailId) { + 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 }); + return { id: emailId, status: 'pending' }; +} + +async function sendEmailNow(emailId) { + 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 }); + const { processEmailQueue } = require('./emailProcessor'); + return processEmailQueue({ ignoreSchedule: true, limit: 50 }); +} + module.exports = { listProjects, getProjectById, @@ -204,4 +246,8 @@ module.exports = { assignEvent, getProjectOverview, getEmailPreview, + resendEmail, + cancelEmail, + retryEmail, + sendEmailNow, }; diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index b0da55dc..36b56fd7 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -45,6 +45,10 @@ export const DEFAULT_FLAGS: FeatureFlags = { // Settings → Features once they've reviewed the seeded block // library with their lawyer. contracts: false, + // Projects (migration 120). Admin-only grouping layer above events + + // the Project Overview cockpit. Off by default — admin opts in under + // Settings → Features once they want the CRM → Overview area. + projects: false, }; export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const; diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx index b5cefdc7..0d4fd6bb 100644 --- a/frontend/src/features/settings/tabs/FeaturesTab.tsx +++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx @@ -16,6 +16,7 @@ import { Briefcase, Wrench, Calculator, + FolderKanban, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button, Card } from '../../../components/common'; @@ -291,6 +292,20 @@ export const FeaturesTab: React.FC = () => { enabled={staged.hoursLogging} onToggle={(next) => setFlag('hoursLogging', next)} /> + + setFlag('projects', next)} + /> {/* Insights & Access */} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index a33d2dbd..c955df08 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1666,6 +1666,11 @@ "title": "Stundenerfassung", "description": "Zeiterfassung pro Kunde. Admin erfasst Datum + Start-/Endzeit + optionalen Satz-Override + Notiz. Kunden im Monatsmodus akkumulieren Stunden automatisch in den laufenden Monatsentwurf; Kunden pro Anlass sehen eine Schaltfläche „Entwurfsrechnung erstellen“, die eine eigenständige Entwurfsrechnung mit einer Zeile pro Eintrag erzeugt. Unabhängig von Rechnungen — Stunden erfassen, noch bevor die volle Abrechnungsoberfläche aktiviert ist.", "sidebar": "Stunden" + }, + "projects": { + "title": "Projekte", + "description": "Nur-Admin-Gruppierungsebene über Events. Bündle mehrere Events unter einem Projekt und öffne ein 360°-Projektübersichts-Cockpit — Meilenstein-Zeitleiste plus ein datierter Verlauf aller E-Mails (mit der tatsächlich gesendeten Vorschau + Erneut-senden/Abbrechen/Wiederholen-Aktionen), Angebote, Verträge, Rechnungen, Galerien und erfassten Stunden. Fügt beim Erfassen von Stunden eine „Auf Projekt buchen“-Option hinzu. Kunden sehen Projekte nie.", + "sidebar": "Übersicht" } }, "customerSurface": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 757c2d55..7e8c5a03 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1224,6 +1224,11 @@ "title": "Hours logging", "description": "Per-customer time tracking. Admin logs date + start/end times + optional rate override + note. Monthly-mode customers auto-accumulate hours into the running monthly draft; per-event customers see a \"Create draft invoice\" button that mints a standalone draft invoice with one line per entry. Independent of Bills — log hours even before turning the full billing surface on.", "sidebar": "Hours" + }, + "projects": { + "title": "Projects", + "description": "Admin-only grouping layer above events. Bundle several events under one project and open a 360° Project Overview cockpit — milestone timeline plus a dated feed of every email (with the actual sent preview + resend/cancel/retry actions), quote, contract, invoice, gallery and logged hour. Adds a \"book to project\" control when logging hours. Customers never see projects.", + "sidebar": "Overview" } }, "customerSurface": { diff --git a/frontend/src/services/featureFlags.service.ts b/frontend/src/services/featureFlags.service.ts index c5015e8f..ad3c95e1 100644 --- a/frontend/src/services/featureFlags.service.ts +++ b/frontend/src/services/featureFlags.service.ts @@ -41,7 +41,11 @@ export type FeatureKey = // upload. Independent of quotes / bills — contracts can be sent on // their own. Seeded block bodies are examples only; admins must // have their lawyer review before sending. See docs/crm-disclaimers.md. - | 'contracts'; + | 'contracts' + // Projects (migration 120). Admin-only grouping layer above events with the + // 360° Project Overview cockpit + the "book to project" hours control. Off + // by default; gates the CRM → Overview area entirely. + | 'projects'; export type FeatureFlags = Record; From 6420047e7cda046057d27539b85334874081b51d Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:05:36 +0200 Subject: [PATCH 08/29] 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. --- .../121_add_project_id_to_quotes_contracts.js | 65 +++++++++++++++++++ backend/src/routes/adminContracts.js | 2 + backend/src/routes/adminProjects.js | 22 +++++++ backend/src/routes/adminQuotes.js | 4 ++ backend/src/services/contractService.js | 8 +++ backend/src/services/projectService.js | 57 ++++++++++++---- backend/src/services/quoteService.js | 8 +++ 7 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 backend/migrations/core/121_add_project_id_to_quotes_contracts.js diff --git a/backend/migrations/core/121_add_project_id_to_quotes_contracts.js b/backend/migrations/core/121_add_project_id_to_quotes_contracts.js new file mode 100644 index 00000000..867dd703 --- /dev/null +++ b/backend/migrations/core/121_add_project_id_to_quotes_contracts.js @@ -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'); + }); + } + } +}; diff --git a/backend/src/routes/adminContracts.js b/backend/src/routes/adminContracts.js index b97137e9..d6c3564e 100644 --- a/backend/src/routes/adminContracts.js +++ b/backend/src/routes/adminContracts.js @@ -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, diff --git a/backend/src/routes/adminProjects.js b/backend/src/routes/adminProjects.js index 180566b4..e8d3ddbc 100644 --- a/backend/src/routes/adminProjects.js +++ b/backend/src/routes/adminProjects.js @@ -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); diff --git a/backend/src/routes/adminQuotes.js b/backend/src/routes/adminQuotes.js index fd8664b4..a130f4c1 100644 --- a/backend/src/routes/adminQuotes.js +++ b/backend/src/routes/adminQuotes.js @@ -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]; diff --git a/backend/src/services/contractService.js b/backend/src/services/contractService.js index 46cc448d..0d0dbd5c 100644 --- a/backend/src/services/contractService.js +++ b/backend/src/services/contractService.js @@ -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. diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js index 9755e22b..eca06f49 100644 --- a/backend/src/services/projectService.js +++ b/backend/src/services/projectService.js @@ -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, diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index c6304309..81db3d10 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -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 From 0175007abc675ecb18a30e05241cac62bee833f8 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:20:59 +0200 Subject: [PATCH 09/29] feat(projects): gated project pickers on quote/contract/hours editors - ProjectSelect: a reusable picker that renders nothing when the projects flag is off (satisfies 'book to project hidden unless projects enabled'). - projects.service.ts: full frontend API client (list/get/create/update, overview, assign event/quote/contract, email preview + 4 actions). - Quote + contract editors carry an optional projectId (state, prefill, payload); service payload/detail types updated. - HoursSection gains a 'book to project' control; backend createEntry persists project_id (migration 118, hasColumnCached guarded). --- backend/src/routes/adminCustomers.js | 1 + backend/src/services/customerHoursService.js | 4 + .../src/components/admin/HoursSection.tsx | 12 ++ .../src/components/admin/ProjectSelect.tsx | 74 +++++++ .../admin/contracts/ContractEditorPage.tsx | 15 ++ .../pages/admin/quotes/QuoteEditorPage.tsx | 16 ++ frontend/src/services/contracts.service.ts | 6 + .../src/services/customerAdmin.service.ts | 2 + frontend/src/services/projects.service.ts | 180 ++++++++++++++++++ frontend/src/services/quotes.service.ts | 5 + 10 files changed, 315 insertions(+) create mode 100644 frontend/src/components/admin/ProjectSelect.tsx create mode 100644 frontend/src/services/projects.service.ts diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js index cc03115c..95c43ec5 100644 --- a/backend/src/routes/adminCustomers.js +++ b/backend/src/routes/adminCustomers.js @@ -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( diff --git a/backend/src/services/customerHoursService.js b/backend/src/services/customerHoursService.js index 2ce2e262..ab24efd6 100644 --- a/backend/src/services/customerHoursService.js +++ b/backend/src/services/customerHoursService.js @@ -233,6 +233,10 @@ async function createEntry(customerId, payload, adminId) { created_at: new Date(), updated_at: new Date(), }; + // Migration 118 — optional "book to project" link. + if (payload.projectId !== undefined && await hasColumnCached('customer_hour_entries', 'project_id')) { + row.project_id = payload.projectId || null; + } const inserted = await trx('customer_hour_entries').insert(row).returning('id'); const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; diff --git a/frontend/src/components/admin/HoursSection.tsx b/frontend/src/components/admin/HoursSection.tsx index 6f4abd41..279a644f 100644 --- a/frontend/src/components/admin/HoursSection.tsx +++ b/frontend/src/components/admin/HoursSection.tsx @@ -24,6 +24,7 @@ import { parseLocaleDecimal, parseDuration } from '../../utils/parsers'; import { customerAdminService } from '../../services/customerAdmin.service'; import { businessProfileService } from '../../services/businessProfile.service'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { ProjectSelect } from './ProjectSelect'; export interface HoursSectionProps { customerId: number; @@ -54,6 +55,8 @@ export const HoursSection: React.FC = ({ const [duration, setDuration] = useState(''); const [rateOverride, setRateOverride] = useState(''); const [description, setDescription] = useState(''); + // Migration 118 — optional "book to project" link (gated component). + const [projectId, setProjectId] = useState(null); // Duration shortcut — admin types "1.5", "1,5", "1:30" or "1h" and // the end-time jumps to start + duration. Pure convenience; the End @@ -114,6 +117,7 @@ export const HoursSection: React.FC = ({ return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null; })(), description: description || null, + projectId: projectId ?? null, }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] }); @@ -123,6 +127,7 @@ export const HoursSection: React.FC = ({ setDuration(''); setRateOverride(''); setDescription(''); + setProjectId(null); toast.success(t('customers.hours.toast.created', 'Entry logged')); }, onError: (err: any) => { @@ -348,6 +353,13 @@ export const HoursSection: React.FC = ({ placeholder={t('customers.hours.form.notePlaceholder', 'What was worked on?') as string} />
+ {/* Book to project — renders only when the projects feature is on. */} +
{noRateConfigured && !overrideTyped && ( diff --git a/frontend/src/components/admin/ProjectSelect.tsx b/frontend/src/components/admin/ProjectSelect.tsx new file mode 100644 index 00000000..84703076 --- /dev/null +++ b/frontend/src/components/admin/ProjectSelect.tsx @@ -0,0 +1,74 @@ +/** + * ProjectSelect — a gated project picker reused by the quote / contract / + * hours / event editors to link a document to a Project Overview project. + * + * Renders nothing when the `projects` feature flag is off, so every call + * site stays a one-liner that simply vanishes when the feature is disabled + * (the maintainer's "book to project must not show unless projects is + * enabled" requirement). Customers never see this — admin surfaces only. + */ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; +import { projectsService } from '../../services/projects.service'; + +interface ProjectSelectProps { + value: number | null; + onChange: (projectId: number | null) => void; + /** Optional label above the select. When omitted the select renders bare. */ + label?: string; + /** Restrict the list to a single customer's projects when set. */ + customerAccountId?: number | null; + disabled?: boolean; + className?: string; +} + +export const ProjectSelect: React.FC = ({ + value, + onChange, + label, + customerAccountId, + disabled, + className, +}) => { + const { t } = useTranslation(); + const { flags } = useFeatureFlags(); + + const { data: projects, isLoading } = useQuery({ + queryKey: ['projects', 'select'], + queryFn: () => projectsService.list(), + enabled: !!flags.projects, + staleTime: 60_000, + }); + + // Hard gate: hidden entirely when the feature is off. + if (!flags.projects) return null; + + const options = (projects || []).filter( + (p) => customerAccountId == null || p.customerAccountId == null || p.customerAccountId === customerAccountId, + ); + + return ( +
+ {label && ( + + )} + +
+ ); +}; diff --git a/frontend/src/pages/admin/contracts/ContractEditorPage.tsx b/frontend/src/pages/admin/contracts/ContractEditorPage.tsx index c06ed00a..b11ec9f0 100644 --- a/frontend/src/pages/admin/contracts/ContractEditorPage.tsx +++ b/frontend/src/pages/admin/contracts/ContractEditorPage.tsx @@ -24,6 +24,7 @@ import { CONTRACT_SECTIONS, } from '../../../services/contracts.service'; import { CustomerPicker } from '../../../components/admin/CustomerPicker'; +import { ProjectSelect } from '../../../components/admin/ProjectSelect'; interface BlockRow { blockId: number; @@ -63,6 +64,7 @@ export const ContractEditorPage: React.FC = () => { const [language, setLanguage] = useState('de'); const [issueDate, setIssueDate] = useState(() => new Date().toISOString().slice(0, 10)); const [validUntil, setValidUntil] = useState(''); + const [projectId, setProjectId] = useState(null); const [blocks, setBlocks] = useState([]); // Load existing contract on edit. @@ -110,6 +112,7 @@ export const ContractEditorPage: React.FC = () => { setLanguage(c.language || 'de'); setIssueDate(c.issueDate); setValidUntil(c.validUntil || ''); + setProjectId(c.projectId ?? null); setBlocks((c.inclusions || []).map((inc) => ({ blockId: inc.blockId, section: inc.section, @@ -188,6 +191,7 @@ export const ContractEditorPage: React.FC = () => { outroText: outroText || null, issueDate, validUntil: validUntil || undefined, + projectId: projectId ?? null, }); // Apply block toggles + ordering as an update right after create. await contractsService.update(created.contract.id, { @@ -220,6 +224,7 @@ export const ContractEditorPage: React.FC = () => { language, issueDate, validUntil: validUntil || undefined, + projectId: projectId ?? null, blocks: blocks.map((b) => ({ blockId: b.blockId, included: b.included, position: b.position, })), @@ -348,6 +353,16 @@ export const ContractEditorPage: React.FC = () => {
)} + {/* Project link (renders only when the projects feature is on). */} +
+ +
+