New Settings -> Accounting tab (gated by the accounting flag) to edit the km
rate, per-diem rate and the "require proof for expense" toggle (reads GET /
writes PUT /admin/settings/accounting). Rates are CHF, stored as integer minor
units; carries the "verify with your Treuhaender" disclaimer. Wired into
SettingsPage (TabType, keys, flag-gated nav item, render) + the features barrel.
i18n: settings.accounting.* (EN + DE, DE native).
Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
Matches the backend split. Incoming invoices and Expenses are now distinct
surfaces with no shared rows.
Incoming invoices (AccountingInboxPage): triage sets the disposition + booking
(event or company) ON the document; "Mark paid" / "Paid" toggle records
supplier payment HERE with the outstanding total shown; re-bill via the
customer picker + markup. PDF preview still rasterised (last page = QR-bill).
Expenses (ExpensesLedgerPage): internal own-costs only. Add form has a Type
dropdown (amount / mileage(km) / per-diem); km/per-diem switch the input to a
quantity + rate (default from accounting settings, per-entry override) with a
live computed amount; optional proof upload (required when the setting says so);
localized category; booked to an event or the company. Proof viewable per row.
Service: reworked to the new endpoints/shapes; categoryLabel() localizes seed
categories (custom stay free-text). i18n: accounting.booking / incoming /
expense / expenseKind / category (EN + DE, DE native).
Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
Implements the split decided in review:
Incoming invoices (external) - the inbound_documents row IS the payable:
- categorizeInbound now UPDATES the document (disposition + tax_treatment +
booking event_id (null=company) + category), no derived expense row, so a
supplier invoice appears only in the incoming-invoices surface.
- rebillInbound mints the client invoice from the document (base = invoice
total + markup) and links it on the doc.
- markInboundSupplierPayment records supplier payment ON the incoming invoice
(mark-paid lives here now).
Expenses (internal) - own costs only:
- createExpense: kind = amount|mileage|per_diem; amount = quantity x rate
(rate from accounting settings, per-entry override; snapshotted); optional
proof file; booked to an event or the company; require-proof enforced from
settings. No supplier payment, always own-cost.
- listExpenses returns internal rows only (inbound_document_id IS NULL).
Routes: per-flag gating (incomingInvoices vs expenses; categories on the
accounting master); supplier-payment + re-bill moved under /inbound/:id/*;
POST/PATCH expenses accept a multipart proof upload; GET /:id/proof streams it
(PDF download-only, image inline). getAccountingSettings reads app_settings.
Verified: node -c, require-graph, 12 unit tests (markup + expense amount/build).
Frontend rework (service + the two UIs + settings tab + category i18n) follows.
Foundation for separating external supplier invoices from internal expenses,
per design review. This stage is additive + buildable; the service/route/UI
data rework follows in stage 2.
- Migration 126: incoming invoices own their payable on inbound_documents
(supplier_paid/at/method/ref + disposition + tax_treatment + booking event_id
+ category_id + re-bill markup/linkage); expenses gain kind (amount/mileage/
per_diem) + quantity + snapshotted rate_minor. Additive, hasColumn-guarded.
- Migration 127: seed `expenses` feature flag (default off) + accounting
app_settings (accounting_km_rate_minor=70, accounting_per_diem_rate_minor=0,
accounting_require_proof=false).
- Backend: `expenses` added to feature-flag known/defaults/dependency (forced
off when the accounting master is off); new PUT /admin/settings/accounting
(read via the generic GET /:type).
- Frontend: `expenses` flag (type + context + dependency); Features tab gets an
Expenses sub-card; the Expenses sub-nav + route now gate on `expenses` (not
incomingInvoices); AccountingIndex prefers inbox -> expenses -> tax.
- i18n: settings.features.expenses.* (EN + DE).
Verified: node -c; migration 124->126->127 harness (new columns, flag, settings
+ idempotency); en/de JSON valid; npm run build green.
Covers the silently-regressable money + classification bits of the re-bill
flow (the maintainer's "thin CRM test coverage" concern). Pure functions via a
new expenseService._internal export — no DB, no date-harness pitfalls:
- computeMarkupMinor: percent rounding, flat, none/null.
- resolveMarkup precedence: override > expense clause > none.
- buildExpenseInsert: bad-disposition guard, tax_treatment/status defaults,
declined -> status+reason, markup field matches type, parked -> status.
11 tests, all green (npx jest expenseService.markup).
Adds an "Add expense" action to the expenses ledger for costs with no inbound
document — mileage, per-diem, a cash receipt, etc.
- accounting.service: createExpense() -> POST /admin/expenses
(createManualExpense); CategorizePayload gains `description`.
- ExpensesLedgerPage: AddExpenseModal with supplier / description / amount /
currency / disposition (company expense / pass-through / re-bill — no
duplicate, there's no document to dedupe). Company-expense picks a category;
re-bill uses the customer picker + markup and chains createExpense -> rebill
into an editable scheduled invoice, same as inbox triage. "Add expense"
button in the filter row.
- i18n: accounting.ledger.{addExpense,addTitle,description,descriptionHint,
createdToast} (EN + DE); shared field labels reuse accounting.inbox.field.*.
Verified: en/de JSON valid; npm run build green.
Security hardening for inbound supplier-invoice previews. The admin UI no
longer renders raw PDFs — a malicious inbound PDF could otherwise run embedded
JS or phone home in the admin's session. Instead PDFs are rasterised to flat
PNGs server-side and only those images are shown.
- backend: new rasterizeService shells out to poppler `pdftoppm` (added to the
Docker image via apk poppler-utils — an OS package, NOT a Node PDF lib, so it
respects the pdfkit+pdf-lib "no third PDF lib" rule). pdftoppm executes no JS
and fetches no remote resources, so it doubles as the SSRF/phone-home guard.
Rendered pages cached under storage/business-docs/inbound/rendered/<id>/.
- GET /inbound/:id/page/:n streams the rasterised PNG (CSP default-src 'none'
+ nosniff). GET /inbound/:id/file now serves PDFs as a DOWNLOAD only
(Content-Disposition: attachment) — never inline; images still inline.
- frontend: triage preview switched from a raw-PDF <iframe> to rasterised page
images (getInboundPageBlob), defaulting to the LAST page (QR-bill) with
prev/next nav for multi-page PDFs; images stream as before.
- i18n: previewError / prevPage / nextPage / pageOf (EN + DE).
REQUIRES A BACKEND IMAGE REBUILD (Dockerfile adds poppler-utils) — a plain
`docker compose pull` of a stale image won't have pdftoppm; the route then
returns 503 RASTERIZER_UNAVAILABLE and the UI shows "preview unavailable".
Verified: node -c, a pdfkit->pdftoppm rasterise smoke test (renders + caches),
en/de JSON valid, npm run build green.
Adds Accounting → Expenses, the view of everything triaged out of the inbox:
- ExpensesLedgerPage: filter by status / disposition; each row shows the
disposition + status badge, CHF amount, created date, and a link to the
client invoice for re-billed items. Supplier-payment toggle ("Mark paid" ->
method + date + reference modal; "Paid" -> click to revert) wired to
/:id/supplier-payment. Payment status is decoupled from categorisation, per
the locked design; declined/duplicate rows skip the toggle.
- AccountingLayout: "Expenses" sub-nav item (gated by incomingInvoices).
- App.tsx: /admin/accounting/expenses route.
- i18n: accounting.subnav.expenses, accounting.ledger/expenseStatus/
paymentMethod (EN + DE, DE authored natively).
Verified: en/de JSON valid; npm run build green.
Instead of OCR, let the admin read the payment slip directly: the triage modal
now embeds the captured document and, for PDFs, opens at the LAST page scrolled
to the Swiss QR-bill area so IBAN/amount/reference are visible while typing.
- backend: capture PDF page count at upload via pdf-lib (new
inbound_documents.page_count, added to in-flight migration 124); new
GET /api/admin/expenses/inbound/:id/file streams the stored file inline
(safePath-guarded, nosniff). Raw-serve is acceptable here (admin views own
uploads); the hardened rasterise-in-isolated-worker path stays a follow-up.
- frontend: getInboundFileBlob fetches the file with Bearer auth as a blob;
the triage modal renders it (iframe for PDF with #page=<last>&view=FitH,300,
<img> for camera photos) in a two-column layout next to the form.
- i18n: accounting.inbox.previewLoading / qrHint (EN + DE).
Verified: node -c, require-graph, migration-124 harness (page_count), npm run
build green.
Adds the Accounting → Incoming invoices frontend on top of the existing
/api/admin/expenses backend:
- accounting.service.ts: typed client (inbound upload/list/get/update/
categorize, expense list, re-bill, supplier-payment, categories).
- AccountingInboxPage: capture a supplier invoice via the device CAMERA
(<input accept="image/*" capture="environment">) or a PDF/image upload;
inbox list with status badges + parsed summary; a triage modal to confirm
fields and pick a disposition (re-bill / pass-through / company expense /
duplicate / declined). Re-bill uses the customer picker and mints an
editable scheduled invoice (chains categorize -> rebill).
- AccountingLayout: "Incoming invoices" sub-nav item + AccountingIndex that
redirects /admin/accounting to the first enabled sub-feature.
- App.tsx: /admin/accounting/inbox route (gated by incomingInvoices).
- i18n: accounting.inbox/disposition/markup + subnav.incomingInvoices +
common.saving (EN + DE, DE authored natively).
Camera capture needs no native app — the mobile web input drives the device
camera straight into the upload endpoint. OCR/QR auto-extraction is still a
backend follow-up (extractionService is a no-op), so fields are confirmed
manually in the triage modal for now.
Verified: npm run build green; en/de JSON valid.
Replaces the earlier peer-`accounting` flag (which only *conditionally*
relocated Tax) with a cleaner top-level master + sub-toggle model, per design
discussion:
- `accounting` = explicit top-level MASTER (Settings -> Features). Off hides
the whole Accounting section.
- Sub-toggles, gated under the master:
- `taxReport` ("Tax export") moves PERMANENTLY out of CRM. Removed from the
Clients sub-nav and from the derived `clients` flag. Now INDEPENDENT of
Bills (per decision). Old /admin/clients/tax-report -> redirect to
/admin/accounting/tax-report.
- `incomingInvoices` (new) gates the supplier-invoice capture / expenses /
re-bill feature; the /api/admin/expenses router now checks it.
- Dependency rules (backend + frontend): accounting off forces taxReport +
incomingInvoices off; taxReport dropped from the clients derivation; the
bills->taxReport rule removed.
- Preserve visuals: migration 122 rewritten to auto-enable `accounting` on
installs that already had Tax on (so the tab doesn't vanish), and to seed
`incomingInvoices` off. Verified with a SQLite harness (taxReport on ->
accounting on; off -> off).
- Settings -> Features: new "Accounting" section with the master card + Tax
export + Incoming invoices sub-cards (disabled until the master is on).
- i18n: navigation.accounting, accounting.*, settings.features.{accounting,
incomingInvoices,taxReport.requiresAccounting}, sections.accounting (EN + DE,
DE authored natively); Tax report relabelled "Tax export"/"Steuerexport".
Verified: node -c, migration-122 harness, en/de JSON valid, npm run build green.
Adds the `accounting` feature flag to the frontend (type, context default) and
a Settings -> Features toggle card. When enabled:
- A new top-level "Accounting" sidebar entry appears (gated by `accounting` +
accounting.view), with an AccountingLayout sub-nav mirroring ClientsLayout.
- The Tax report relocates: it is HIDDEN from the CRM (Clients) sub-nav and
shown under Accounting instead, at /admin/accounting/tax-report. When
accounting is OFF, Tax stays under CRM exactly as before.
Tax visibility still depends on `taxReport` (which depends on `bills`), so the
relocation only changes WHERE the menu item lives, not whether it exists.
Files: featureFlags.service.ts (+'accounting'), FeatureFlagsContext default,
AdminSidebar entry, new AccountingLayout, ClientsLayout filter, App.tsx route,
FeaturesTab card, en/de i18n (navigation.accounting, accounting.*,
settings.features.accounting; DE authored natively).
Verified: `npm run build` green; en/de JSON valid.
New top-level Accounting area (gated by an `accounting` feature flag, default
OFF, + accounting.view/manage permissions), separate from CRM. Lets an admin
capture a received supplier invoice (upload OR phone/tablet camera), give it a
disposition, and re-bill the cost to a client onto the relevant event's
invoice with a contract-driven markup. Mirrors the billable-hours model.
Backend foundation only — frontend pages (inbox / expenses UI + camera widget)
and the heavy extractors (Tesseract OCR / Swiss-QR decode / isolated rasterise
worker) are follow-ups; extractionService is scaffolded so the upload path is
already wired.
Migrations 122-125 (numbered above the in-flight feat/crm 117-121):
- 122 seed `accounting` flag (default OFF, idempotent)
- 123 seed accounting.view/manage permissions + grant super_admin/admin
- 124 inbound_documents + expenses + expense_categories (+ seed categories)
- 125 contracts Spesen-Zuschlag clause (expense_markup_type/_percent/_flat_minor)
API: /api/admin/expenses — inbound capture/list/confirm/categorize, expense
CRUD, /:id/rebill (event-scoped; markup = expense override -> contract clause
-> 0%; mints an editable scheduled invoice), /:id/supplier-payment, categories.
adminFeatureFlags KNOWN_FLAGS/DEFAULT_FLAGS gain `accounting`.
Conventions: idempotent hasTable/hasColumn-guarded migrations; money in integer
*_minor; QR amount stored separately + untrusted; requirePermission guards;
camelCase API <-> snake_case columns; multer + 15MB cap for PDF/JPEG/PNG.
VAT/tax handling is v1 capture-only — verify with a Treuhaender before relying.
Verified: node -c all files, require-graph smoke test, and a SQLite migration
harness (schema + seeds + idempotency + defaults assert green).
Zszywany reported on v3.44.0 (Ubuntu, Postgres 15): with Settings →
General → "Max Files per Upload" set to 10, an event configured with
allow_user_uploads + a guest selecting 16 files in the gallery's
"Upload Photos" modal succeeded silently — admin uploads to the same
event correctly refused with "Upload limit reached". On top of that,
the gallery modal's "fileRequirements" hint literally rendered
`{{limit}}` instead of the configured number.
Two separate misses for the guest path, both fixed here:
1. **Backend enforcement** — `backend/src/routes/gallery.js:1641` had
`limits: { fileSize: 50MB, files: 10 }` and `.array('photos', 10)`
hardcoded. The admin path at adminPhotos.js:131 has always resolved
files-per-batch via `getMaxFilesPerUpload()` (cached 60s read of
`general_max_files_per_upload`); guest path just never used it.
Mirror the admin: `const maxFilesPerUpload = await getMaxFilesPerUpload()`
and feed multer both `limits.files` AND the `.array(...)` cap. The
50MB per-file size is a separate concern from this issue and stays
as-is for now.
2. **i18n interpolation missing on the guest modal** —
`UserPhotoUpload.tsx:203` called `t('upload.fileRequirements')` with
no arguments. The translation string at `en.json:160` is
"JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)"
— `{{limit}}` is unbound, so i18next emits it literally. The admin
variant `PhotoUpload.tsx:414` correctly passes
`{ limit: maxFilesPerUpload }`.
Also wired up the same client-side count guard the admin component
uses: addFiles refuses additions past the limit (`upload.limitReached`)
and warns on partial-truncate (`upload.someFilesSkipped`). Backend
enforces too, but the client guard saves a 4MB+ multipart POST when
the user is clearly over.
To surface the setting on the guest side, `general_max_files_per_upload`
joins the public-settings whitelist + projection (publicSettings.js)
and the `PublicSettings` TS interface gets the new field. Default
fallback (500, matching `uploadSettings.js` DEFAULT_MAX_FILES_PER_UPLOAD)
in both backend projection and frontend reader so an install that's
never set the value renders a sensible number rather than "undefined".
jodrmx reported on v3.44.0 (Pi Lite, Docker compose): admin-UI event
delete removes the DB row but leaves `storage/events/active/<event>/`
intact on disk.
Root cause: `deleteEventCascade` in adminEvents.js read
`event.folder_path` and gated the `fs.rm` on it. That column is NEVER
WRITTEN anywhere in the codebase — grep confirms two reads in this one
function, zero writes elsewhere. So `event.folder_path` was always
undefined, `if (event.folder_path)` always false, and the per-folder
cleanup silently no-op'd for every delete. The DB-cascade transaction
ran fine, so the symptom was always "row gone, files stay" — exactly
what jodrmx hit.
The actual on-disk location is `events/active/{slug}` everywhere else
in the codebase:
- adminPhotos.js:260 — `path.posix.join('events/active', event.slug)`
- adminEvents.js:610, events.js:155, adminThumbnails.js:153 — read
from `events/active/{slug}`
- adminArchives.js:171 — reads from same root
- photoResolver.js:14-15 — documents the layout
The delete cascade was the only path looking at the non-existent column.
Cure: drop the `if (event.folder_path)` guard, read `event.slug`
instead, and remove from both `events/active/{slug}` (active gallery
folder) and `events/archived/{slug}` (the post-archive copy that
survives the archive flow). `event.slug` is NOT NULL and slugify-
sanitized (lower-case ASCII + dashes only via utils/slug.js), so the
path is well-formed and path-traversal-safe. Best-effort `fs.rm`
semantics + try/catch unchanged — failures still log a warning rather
than unwinding the DB transaction, since orphan files are recoverable
noise compared to a half-deleted DB row.
Forward fix only — does not retroactively clean up the orphans that
have accumulated on existing installs. Admins can `rm -rf
storage/events/active/<old-slug>` manually for those; not worth a
migration script for a one-time deploy ritual.
patchingfailed reported on v3.44 stable: a gallery named `Ägypten` with
photo `Ägypten_individual_0050.jpg` downloads as `gypten_...` —
the leading umlaut is dropped entirely. Their hypothesis was a
Content-Disposition encoding issue, but the actual root cause sits
one layer earlier: at UPLOAD time when `generatePhotoFilename` calls
`sanitizeFilename`.
`sanitizeFilename` did:
String(str).trim()
.replace(/\s+/g, '_')
.replace(/[^a-zA-Z0-9_\-\.]/g, '') // ← drops `Ä` outright
.replace(/[_\-]{2,}/g, '_')
.replace(/^[_\-]+|[_\-]+$/g, ''); // ← would strip a leading _ too
For `Ägypten`: alphanumeric-strip → `gypten` (Ä gone, no underscore
left behind because the regex used '' as the replacement, not '_'). The
result is stored in `photos.filename` and that's what downloads serve.
By that point `buildContentDisposition` is doing the right thing
(emits both `filename="..."` ASCII fallback AND RFC 5987
`filename*=UTF-8''…` — Chrome correctly picks the UTF-8 form), but the
string it's encoding has already lost the umlaut at the DB layer.
Cure: NFD-normalize + strip combining marks BEFORE the alphanumeric
strip. Same pipeline `utils/slug.js` (#525) already uses for URL
slugs:
sanitized = sanitized
.normalize('NFD')
.replace(/[̀-ͯ]/g, '');
Now `Ägypten` → NFD-decomposed `A` + combining diaeresis → strip
combining mark → `Agypten` survives the alphanumeric pass. Filename
and URL slug stay in sync (the URL was already `Agypten`, per
patchingfailed's report — the filename now matches).
Test surface: new `filenameSanitizer.test.js` pins:
- the headline #607 contract for German / Portuguese / French / Spanish
accented inputs (with a counter-example using the pre-fix pipeline so
a future edit can't quietly regress it)
- ASCII-input parity — pre-#607 byte-identical output for every
pre-existing ASCII case
- `generatePhotoFilename` composed round-trip
- `sanitizeForContentDisposition` + `buildContentDisposition` RFC 6266
dual-form output (since the helper sits next to this function and is
the next thing to break if a refactor goes sideways)
- `sanitizeForZipEntry` path-traversal blocking
31 cases total, all pass.
Bundled into PR #609 since it's a small targeted fix and that PR is
already an admin-UI polish branch with low review weight.
Two issues Rekoo-PS hit immediately after upgrading to v3.60.3-beta.0:
1. **Broken logo URL rendered the browser's broken-image icon + alt
text.** Their `<img src={resolvedLogoUrl}>` had no `onError` handler,
so a 404 / slow logo URL produced the default broken-image rendering
— which uses the `alt` attribute (`companyName`) as text. Visually it
looked like the wordmark span had unexpectedly re-appeared on phone,
even though the actual `<span>` was correctly hidden by the existing
`wordmarkVisibilityClass` logic.
Fix:
- `useState` tracks `logoLoadError` (first failure) and
`fallbackLoadError` (second failure). On a configured-URL miss the
`<img>` swaps to the bundled `/picpeak-kamera-transparent.png`; on
a second miss the `<img>` is removed from the DOM entirely.
- `useEffect([resolvedLogoUrl])` resets both flags when the URL
changes, so a dark-mode toggle that flips `lightLogo ↔ darkLogo`
gets a fresh attempt instead of being permanently sad.
- `wordmarkVisibilityClass` now derives from `logoEffectivelyVisible`
(showLogo && !fallbackLoadError) — when both the configured URL
AND the bundled fallback have failed, the wordmark un-hides on <sm
so the phone header isn't completely empty.
2. **Sidebar VersionInfo + StorageInfo vanished during the
permission-hydration window.** The bottom block was gated on
`hasPermission('settings.view')` directly, which returns `false`
while `PermissionsContext.isLoading` is still resolving (a few
hundred ms right after a deploy when the auth context bootstraps).
Net effect: the whole "Version / Storage" block was absent on first
paint, then re-appeared once permissions hydrated — Rekoo-PS read
that flash as "backend version + storage missing".
Fix: gate on `permissionsLoading || hasPermission('settings.view')`.
Optimistic render during hydration; permitted users see the widgets
immediately (with each widget's own internal loading state), denied
users still see nothing once the permission state lands as `false`.
Side benefit: the `<img>` fallback chain also covers the broader "logo
hosted on a flaky CDN" case for self-hosters, not just the one-time
post-upgrade asset-cache hiccup. Pure resilience polish — no behaviour
change when everything works.
linkDealToProject(dealUuid, projectId): links every quote + contract sharing
the deal_uuid, re-points the events the deal converted into (so their
invoices/emails/gallery roll up), and adopts the deal's customer onto an
empty project. Invoked from the assign endpoints AND the quote/contract
editors' project picker (create + update). Drop a quote on an empty project
and its linked contract, event and invoices populate the cockpit automatically.
Verified on a booted DB: assignQuote on an empty project propagates project_id
to the contract + event, adopts the customer, and the overview rolls up all
four document types.
A project has at most one customer, so the hours picker shouldn't offer
other customers' projects. HoursSection now passes customerAccountId to
ProjectSelect (shows this customer's projects + still-unassigned ones).
Backend createEntry rejects a projectId owned by a different customer
(422 PROJECT_CUSTOMER_MISMATCH) as defence-in-depth behind the picker.
A long unbreakable token (e.g. the gallery link) overflowed the email
container and forced the admin to side-scroll. The preview HTML prep now
also injects overflow-wrap:break-word so long words/URLs wrap within the
container. break-word only triggers on overflow, so table layout is
unaffected. (Renamed neutralizeLinks → preparePreviewHtml.)
The project create/update/assign routes required 'events.manage', which is
not a real permission (the event perms are view/create/edit/delete/archive).
Since it's absent from the permissions table, even super_admin's all-perms
set excluded it, so every write 403'd with 'Insufficient permissions'.
Switched the write routes to the existing 'events.edit'. (Reads keep
events.view; cockpit doc gating + email actions already use real keys.)
Inside the preview iframe the Accept button navigated but other links didn't
— inconsistent, and worse, clicking Accept/Decline would hit the live action
URLs and change the quote state. Sandbox the iframe (no popups/scripts/forms)
and force all anchors to target=_blank so every link is inert. Now nothing in
the preview is clickable (consistent + safe); scrolling and brand colors are
unaffected.
The email wrapper already sets body/container/text backgrounds from the
brand email-theme settings (email_body_bg_color etc.), so a dark preview is
the configured design — forcing it light was wrong. Render the email as-is;
set the iframe color-scheme to 'normal' only so the admin's dark app theme
doesn't leak into the iframe's UA defaults. The brand's light/dark choice is
respected.
Document rows navigate on click, but email rows only had clickable action
buttons — the row itself was dead, which read as inconsistent. The whole
email row now opens the preview; the action buttons stopPropagation so
Resend/Cancel/Retry/Send-now still fire without also opening the preview.
Every actionable feed row is now uniformly clickable.
The cockpit surfaces quotes/invoices/contracts by PERMISSION, but their
detail routes are gated by feature FLAG (RequireFeature). With those flags
off, clicking a row navigated to a route that redirects to /admin/dashboard
— so links 'did nothing' while the email action buttons (plain API calls)
worked. hrefFor now returns null when the destination flag is off, so the
row renders as non-clickable text instead of a dead link. Galleries/events
are never flag-gated, so they always link.
Each email in the rollup now carries a 'stored' flag (rendered_html present).
Emails without an exact stored copy show an amber '≈ re-rendered' tag next to
Preview, so it's visible at a glance — not just inside the modal. en + de.
- The customer address often doubles as the admin notification target, so
matching emails purely by recipient swept in system alerts (backup_failed,
restore_failed, …). The recipient match is now restricted to CRM document
types (quote_/contract_/invoice_/storno_); event-scoped mails still match
by event_id.
- getEmailPreview now falls back to renderQueuedEmail() — re-rendering from
the current template + the row's stored email_data — for emails sent before
rendered_html capture, flagged exact:false with an amber 're-rendered' note.
Only a missing template / no variables falls through to 'nothing stored'.
Known limitation: a customer with multiple projects sees their event_id=null
CRM mails under each (email_queue has no project_id).
- Milestones + feed rows now link to the document (quote/contract/bill
detail, event for galleries); hours have no page so stay non-clickable.
- Email rollup also matches the project customer's address — quote/invoice/
contract mails are queued with event_id=null, so the by-event scope alone
showed none (hence 'no email preview'). Now they appear with preview.
- Feed amounts coerce total_amount_minor with Number(): Postgres returns
bigint as a string, which formatMoneyMinor's Number.isFinite check
rejected and rendered as CHF 0.00. (computeValuation already coerced.)
- computeValuation helper: per deal_uuid, the invoice total (installments
summed, storno netted) wins over the quote; contracts carry no total so
never contribute. Summed across the project's events, split by currency.
- Value column on the Project Overview list + a value/paid block in the
cockpit header. Both gated by bills.view/quotes.view so no figure leaks.
- listProjects computes all values in two bulk queries (not per-project).
- en + de i18n; six unit assertions cover the rule's edge cases.
Search any event by name and attach it to the project (re-points
events.project_id via assignEvent). Lists the project's current events
above the search. en + de i18n. Completes event grouping UX — admins
can now regroup the auto-created per-event projects however they like.
- 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).
- 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.
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 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.
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.
From dev testing:
- BillEditor 'Geplanter Versand' was a native <input type=datetime-local> →
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 <input type=date> (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.
- 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.
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 <style> (kept the <style> as progressive enhancement).