- 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.
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).
The /favicon.ico + /apple-touch-icon routes stream the file directly,
bypassing the secureStatic middleware that locks down served SVGs. An
admin-uploaded SVG favicon with <script> would then run at the top-level
origin (stored XSS). Re-apply the same CSP (default-src 'none') + nosniff
for .svg here, mirroring secureStatic.js. Reported in the #603 review.
Two gaps left emails stuck 'pending' after (re)configuring SMTP:
1. Saving the email config never re-initialised the transporter. The queue
processor only re-inits when its cached transporter is null, so a changed
SMTP account had no effect until a backend restart. Now call
initializeTransporter(true) after save (it self-catches; invalid config
just leaves it null, surfaced via the Test-email button).
2. The manual 'send now' flush (ignoreSchedule) still enforced retry_count<3,
so emails that failed 3× while SMTP was broken could never be retried from
the UI. Move the retry-cap (and schedule gate) to automatic runs only;
a manual flush forces a retry of every pending email.
The /favicon.ico route 302-redirected to the uploaded file. Firefox/Chrome
follow that, but Safari does NOT reliably follow a redirect for favicon
requests — it falls back to the HTML <link>, i.e. the bundled picpeak
default. Stream the file bytes directly for local /uploads favicons (with a
path-containment guard); only external URLs and the missing-favicon fallback
still redirect. sendFile sets the content-type from the extension.
Per decision: keep dashboard revenue windows on pure cash basis (recognise
by paid_at for ALL invoices) and give the admin control over paid_at.
- adminDashboard: revert the imported-vs-native split; winSum is paid_at >=
cutoff for every paid invoice again (clean cash basis).
- BillDetailPage mark-paid dialog: add an optional 'Payment date' field
(LocalizedDateInput, defaults to today) so a payment can be backdated to
when it actually arrived. Backend already accepted paidAt end-to-end
(route validator + markPaid service + payment-log) — only the UI was
missing. EN/DE 'bills.payment.date' added.
This fixes the collapsed 30=90=365 windows (they were collapsing because
many invoices were marked paid in one session, all stamped 'now').
Safari requests /favicon.ico and /apple-touch-icon*.png at the site root and
is unreliable about honouring JS-injected <link rel=icon>, so an admin-set
favicon never showed there (index.html only ships /favicon-32x32.png; a bare
/favicon.ico 404'd).
- Backend: GET /favicon.ico + /apple-touch-icon(.png|-precomposed.png) resolve
the configured branding_favicon_url (redirect to its /uploads path or the
absolute URL), falling back to the bundled /favicon-32x32.png.
- nginx: exact-match (=) locations proxy those paths to the backend, winning
over the static-asset regex that previously served them from the build dir.
- DynamicFavicon also emits an apple-touch-icon link (belt-and-braces).
Requires a frontend image REBUILD (nginx.conf change) in addition to backend.
The dashboard revenue windows (30/90/365 days) keyed purely on paid_at.
Imported historical invoices therefore landed in the recent window whenever
their paid_at sat there — notably legacy rows imported before commit c6b8cc9
began anchoring an import's paid_at to its issue_date, which still carry an
import-time paid_at. Recognise imported invoices (imported_pdf_path NOT NULL)
on their issue_date instead; native invoices keep cash-basis paid_at. No data
migration needed — fixes already-imported year-old invoices too.
The public quote, contract-signing, and payment-check pages baked a single
light logo (the contract page showed none), so the dark page rendered a
dark-text logo on a dark background.
- usePublicDarkMode now returns { isDark } (reactive) alongside applying
the .dark class, so pages can pick a theme-aware asset.
- The three public routes now surface both branding logo URLs (logoUrl +
logoUrlDark) in the issuer block; the contract issuer gains a logo too.
- QuoteResponsePage, ContractResponsePage, and the payment-check
BrandingHeader pick the dark variant when isDark, falling back to
whichever exists. Covers the accept/accepted states of each page.
The SVG->PNG cache was keyed only by source path + mtime + size, so an
override logo rasterised once WITHOUT fonts (text -> tofu) stayed cached
after the font fix - the source SVG was unchanged, so the stale tofu PNG
kept being served. Add a RASTER_VERSION component to the cache key; bumping
it (v2-fonts) invalidates every prior rasterisation without clearing the
cache dir by hand.
The favicon upload allowed only PNG/ICO, so an SVG favicon was rejected.
Accept image/svg+xml (.svg) too - DynamicFavicon already emits the right
MIME type and served SVGs are CSP-locked (render-only) by secureStatic.
Update the EN/DE help text accordingly.
The runtime image (node:22-alpine) shipped without any fonts, so when
sharp/librsvg rasterised an SVG logo containing live <text> for the CRM
PDFs, the vector artwork drew but the text rendered as tofu boxes - a
'corrupted' logo on invoices/quotes.
- Add fontconfig + DejaVu/Liberation (broad Unicode fallback) and refresh
the font cache.
- Register picpeak's own bundled brand fonts (assets/fonts/<Family>/*.ttf -
the same files PDFKit and the web UI already use) with fontconfig via a
conf.d <dir> entry + fc-cache, so the logo's text renders in its ACTUAL
brand typeface rather than a generic fallback.
The upload never enforced 32x32 - only the help text recommended it,
which misled admins. Update the EN/DE guidance to recommend a larger
square image (512x512) and raise the favicon upload cap 1MB -> 2MB so
high-resolution PNGs fit comfortably.
Serve uploaded SVGs (admin logos etc.) with a restrictive
Content-Security-Policy (default-src 'none'; style-src 'unsafe-inline';
img-src 'self' data:) + X-Content-Type-Options: nosniff in secureStatic.
The browser still renders the vector, but any embedded <script>/on*
handler can't execute if the SVG is opened directly — keeps real SVGs
(scalable) instead of rasterising them. Applies to all secureStatic
mounts (uploads/photos/thumbnails/fonts); only SVGs get the header.
New /admin/system-health page (sidebar entry, settings.view) that lists
emails the queue gave up on (status='failed' or pending+retry>=3) with
retry (re-queue) and dismiss (delete) actions. Backend adds /failures,
/failures/email/:id/retry and DELETE on adminSystemHealth. First source
is email failures (the original trigger — quote_sent template errors
left invoices unsent for 14h with no signal); more sources can be added.
Add an optional dark-mode logo (branding_logo_url_dark) alongside the
main logo. Upload/remove via the logo endpoint (?variant=dark) on the
Branding settings page. Admin header (admin dark mode) and the public
gallery (dark themes) pick the dark logo when active, falling back to
the light logo when unset. PDFs keep using the light logo.
Add a bills-gated button that opens the bill editor pre-filled with the
event (eventId FK + name/date snapshot) and the linked customer (when
exactly one). BillEditorPage gains eventId state + query-param prefill +
sends eventId on create; backend validates eventId (already forwarded +
persisted). Reuses the editor — no empty drafts. Does not auto-pull hours.
queueEmail gains options.respectBusinessHours: snaps the send time to the
next open business-hours block (from now), only deferring when it actually
falls outside hours. Applied to dunning reminders + gallery-expiry warnings;
transactional/admin-initiated mail stays immediate. No-op until business
hours are configured.
Add a paginated, filterable view of the email_queue (recipient, type,
status, queued/sent timestamps, error, event link) as a third tab in
Email config, beside SMTP + Templates. Filters: status, recipient/type
search, created-at range. email_data is never exposed. Pairs with the
"Send queued emails now" flush — flush, then watch what sent/failed.
Add a "Decline on behalf" action mirroring accept-on-behalf, for when a
customer says no by phone/email. Flips a draft/sent/expired quote to
declined, stamps declined_at, closes the public response window, and
invalidates outstanding accept/decline tokens so the emailed link can't
toggle it back. Optional free-text reason persisted to a new
quotes.decline_reason column (migration 115) and shown on the quote
detail page. Hard-delete intentionally not included.
Move the scheduled-email business-hours floor onto the business profile
as Google-style per-weekday opening blocks (multiple blocks/day for lunch
breaks). Migration 114 adds business_profile.business_hours (JSON) +
scheduled_email_floor_enabled; emailProcessor snaps a queued email to the
next open block, read in the profile timezone. Editor lives under
Settings → Business profile.
Add an admin "Send queued emails now" flush (POST /admin/email/flush-queue)
that drains the queue immediately, ignoring the business-hours floor — the
escape hatch before maintenance/updates. processEmailQueue now takes
{ignoreSchedule, limit} and returns send counts; the scheduled interval
run is unchanged.
When no customer is selected, list every customer with unbilled hour
entries — entry count, total hours, and open amount (resolved via the
override → customer-rate → install-default chain). Rows with no
resolvable rate are flagged "Rate not set" rather than undercounted.
Click a row to drill into the per-customer logging section.
Backend: getUnbilledSummaryByCustomer() + GET
/api/admin/customers/hour-entries/unbilled-summary (customers.view).
Hour-entry saves hard-failed with an English-only error when a customer
had no rate, and the standalone hours page showed a disabled rate field
that looked set. Add a global business_profile default_hourly_rate_minor
(migration 113) as the last link in the rate chain
(entry override → customer → install default), so saves succeed with the
global rate. When no rate resolves anywhere, replace the save-time error
with a read-only resolved-rate display + a CTA to set a customer or
install-wide rate, disable Add-entry until a rate/override exists, and
translate the backend HOURLY_RATE_REQUIRED toast (en+de).
Replace the sort <select> dropdowns on the invoice, quote and contract
list pages with clickable column headers that toggle asc/desc and show a
chevron indicator. Adds a shared SortableHeader component + useColumnSort
hook that maps clickable columns onto the server-side sort enum.
Make issue date (newest first) the standard sort on all three lists,
set at the frontend, route and service layers. Adds issue_asc/issue_desc
to invoices and an "Issued" column to the bills table so the default is
visible and toggleable. Extends sort coverage so every clickable column
has both directions (+customer_desc on all; +issue_asc/desc on
quotes/contracts). Storno rows remain listed.
The historical-invoice import form had no event field, so imported rows
landed with event_name = NULL even when the admin knew the occasion. Add
free-text Event name + Event date inputs to the import modal, thread them
through billsService.importHistorical and the POST /admin/invoices/import
validator, and store them in the event_name/event_date snapshot columns
(migration 107). event_id stays NULL — no FK, since the event may predate
picpeak. Autocomplete-to-event_id linking deferred as a future bonus.
Adds customer_accounts.skonto_disabled (migration 112) so a customer
that negotiated "no early-payment discount" can be flagged once instead
of ticking the per-invoice toggle on every invoice. resolveSkontoPercent
ForInvoice and the PDF render context both honour it, extending the
resolution chain to customer → invoice → snapshot → quote → global.
Checkbox added to the customer detail Billing card (en + de).
A scheduled invoice's issue_date was stamped at creation, so a long-
scheduled invoice printed a stale date by the time it shipped — the
relative Skonto window ("pay within N working days") and the net-days
due date were then counted from the authoring day, not the send day.
sendInvoice now stamps issue_date = send date on the first send and
re-derives the due date from it, preserving a manual due-date override.
Adds resolveNetDaysForRow to read net days from the persisted snapshot.
Deselecting Skonto before the scheduled send already propagates (the
scheduler re-reads the row fresh and the render context honours
skonto_disabled); no change needed there.
Due date now derives from (scheduled send date else issue date) plus the
selected Net-days template, both in the editor and on save. The bill
editor renders it read-only with an Override toggle for manual entry;
existing invoices preserve their stored due date. Backend adds a single
resolveNetDays resolver that honors the split payment-net-days template
(previously only the legacy FK was read) and the
crm_payment_default_net_days setting, used by createInvoice and the
installment-spawn path alike.
The invoice-import endpoint stamped sent_at and paid_at with the moment
of import (new Date()) instead of the document's historical dates. The
CRM dashboard "Revenue · last 30 days" card keys on paid_at, so a
year-old paid invoice imported today wrongly counted toward the rolling
window. The dashboard windowing is correct (cash-basis "received in the
window") — the bug was the wrong paid_at on imported rows.
POST /admin/invoices/import now anchors sent_at to issue_date and
paid_at to issue_date (or an optional new paidAt param when the admin
knows the real payment date), never to import time.
Migration 111 backfills rows imported under the old behaviour: for every
invoice with imported_pdf_path set, sent_at/paid_at are reset to
issue_date. The old code never captured a real payment date, so
issue_date is the only sensible anchor. Idempotent and scoped strictly
to imported rows, so picpeak-issued invoices are untouched.
paid_at/sent_at are operational timestamps, not the invoice's immutable
legal content, so correcting the import-time error is safe under the
§14/§11 UStG immutability rule.
Replace the free-text 2-char country code field on the inline customer
create form and the customer detail page with a dropdown that shows
localized country names (Intl.DisplayNames, no hardcoded map) while
still storing the ISO 3166-1 alpha-2 code. The create form now seeds the
default country from the business profile instead of leaving it blank or
guessing CH/FL. The free-text countryName override is kept for the rare
case where an operator wants a custom display string.
Standardize Liechtenstein on the ISO code LI instead of the colloquial
plate code FL so it matches the PDF renderer's locale-aware lookup and
the new dropdown. Migration 110 normalizes existing FL rows to LI on
customer_accounts and business_profile (idempotent, case-insensitive).
Require at least one human-readable identifier (company name or a
contact name) at create time so the form can't produce a nameless row
that's impossible to recognise in lists later. Enforced on both the
frontend (isValid + toast) and the backend POST /admin/customers
validator so the API can't be bypassed.
i18n: en + de updated; other locales fall back to inline English
defaults and should get a native review before release.
Caught during the round-4 e2e validation on real PG: every
successful restore landed with `status='completed', was_successful=false`
because the success-branch update only wrote `status` but not
`was_successful` (column default is false). Visible side effect: the
BackupDashboard's "last successful restore" filter would skip the
row + any future audit query gating on was_successful would miss it.
One-line cure: include `was_successful: true` in the success-branch
update payload. Inline comment explains why and references the
review note so future edits keep the two fields together.
Source-inspection test in restoreService.pgBranch.test.js pins the
contract: after `performPostRestoreVerification(...)`, the
`status: 'completed'` update payload must also contain
`was_successful: true`. Future refactors of the success payload that
drop the flag fail the test before merge.
36/36 backup-related integration tests pass.
End-to-end DR cycle surfaced one more PG-only landmine — and it
turned out to be a side-effect of the round-1 replay placement, not
a new bug. Round 2 fixed the comparison logic; round 3 fixes the
ordering.
Symptom on real PG install:
[install-from-backup] FAILED — Post-restore verification failed:
Table app_settings row count mismatch: expected 190, got 191.
Trigger file left in place for retry.
Root cause: the operator-meta replay (introduced in round 1) ran
INSIDE performDatabaseRestore, lined up BEFORE the post-restore
verification step in the parent restore() method. So:
1. psql restores app_settings → 190 rows (matches backup)
2. Replay upserts `restore_allow_force_auto_upgraded` (which the
fresh-install seeded but the backup didn't have) → 191 rows
3. performPostRestoreVerification counts 191, manifest says 190,
verification fails the row-count check.
Replay is doing the right thing (preserving operator policy). The
verification is doing the right thing (counts must match). They
disagree because the replay landed in the wrong sequence relative
to verification.
Cure: move the replay out of performDatabaseRestore and into
restore() AFTER `performPostRestoreVerification` passes.
Verification now sees the as-restored DB (matches the backup
exactly), replay layers on top once verification has signed off.
Mechanism: snapshot stashed on `this.preservedMetaSnapshot`
(initialised in constructor, reset per run at the top of restore()).
performDatabaseRestore writes it in the PG branch before DROP;
restore() drains it after verification. SQLite leaves it empty,
both steps no-op there.
Tests:
- Updated `restoreService.pgBranch.test.js` to pin the new shape:
* `this.preservedMetaSnapshot` is initialised in the constructor
* No stray `let preservedMeta = []` local declarations anywhere
* Replay drain (`this.preservedMetaSnapshot.length > 0`) sits in
restore() AFTER `performPostRestoreVerification(...)` and is
lexically OUTSIDE `performDatabaseRestore`.
- The bigint-as-string contract from round 2 still holds.
34/34 backup-related integration tests pass.
pg-driver serialises `bigint` (which is what `COUNT(*)` returns) as a
JavaScript STRING to preserve precision for huge counts. The manifest
stores `expected.rowCount` as a JS number (parseInt'd at
databaseBackup.js:118). Strict `!==` in performPostRestoreVerification
flagged every match as a mismatch on PG:
Table activity_logs row count mismatch: expected 16, got 16
Table admin_users row count mismatch: expected 1, got 1
Table app_settings row count mismatch: expected 165, got 165
... (every table, all matching)
Symptom matched the preservedMeta scope leak from round 1: install-
from-backup logged FAILED, trigger file wasn't cleaned, data was
actually intact. Caught on PR #596 e2e re-run.
Cure: coerce both sides with `Number(...)` at the comparison AND in
the interpolated value so the warning text renders `16` not `"16"`.
Pre-emptive: lines 448 + 458-459 had the same string-vs-number issue
masked by `>` (JS coerces operands for `>`), but the warning text
printed `"5"` on PG vs `5` on SQLite, and a future patch changing
`>` to `=== 0` or `!== expectedCount` would silently break on PG.
Coerced at the read site into `eventCountN` / `activeUsersN` locals
+ added a comment block explaining the contract so future edits
don't drop the Number() calls without re-auditing.
New source-inspection test: pins the contract that every `.count`
result in restoreService.js MUST be wrapped in `Number(...)` when
used in a comparison (===/!==/>/</>=/<=). Same source-inspection
pattern as the preservedMeta test added round 1 — pragmatic until
the real-PG integration test follow-up lands.
The maintainer's audit of the rest of the backup/restore surface
(_installFromBackupBoot, _restoreSettingsBoot, _backupPathsBoot,
backupCoverageService, backupIntegrityService, backupService,
databaseBackup) confirmed no other bigint-as-string sites — the
class is now closed in the audited scope.
Two nice-to-haves from the PR #596 review.
1. Install-from-backup logging mirrors to stdout
The winston logger writes to /app/logs/combined.log and may not
tee to stdout. Operators tailing `docker logs picpeak-beta-backend`
after a `compose up` saw the migration sweep + npm notice and
nothing about the restore. Three key events now also fire through
`console.log` with a `[install-from-backup] ` prefix:
- "trigger file detected → <manifest>"
- "starting restore from <manifest>"
- "restore completed successfully" / "FAILED — <reason>"
Plus the "skipping — existing data" branch.
docker-logs surface now tells the restore story without requiring
an `exec into the container` step.
2. ADMIN_CREDENTIALS.txt flags stale creds when restore is queued
Migration 001 detects a pending `RESTORE_ON_INSTALL` file BEFORE
writing the fresh-install credentials file. If a trigger will fire
on the next boot, the file now opens with a clear warning:
⚠️ RESTORE_ON_INSTALL TRIGGER DETECTED ⚠️
These credentials are temporary. An install-from-backup run is
queued to fire on the next server start, which will REPLACE
this admin row with the one from the backup. After the restore
completes, log in with your ORIGINAL pre-disaster credentials
— not the ones below. If the restore fails for some reason,
the credentials below remain valid as a fallback recovery path.
Doesn't skip the file (so a failed restore still has the fallback
credentials), just annotates it. Closes the maintainer's "stale
junk credentials" observation.
`preservedMeta` was declared with `let` INSIDE the PostgreSQL else
branch of performDatabaseRestore (~L850), then read AFTER the else
block closed at the shared replay site (~L1030). On every real PG
restore, this threw:
ReferenceError: preservedMeta is not defined
after psql had already loaded the data successfully. Knock-on
effects per the maintainer's review:
- Loud `Install-from-backup: FAILED` line in combined.log even
though the data restored cleanly
- Trigger file in `_installFromBackupBoot.js` was left in place
because the success branch never ran — admin had to manually
rm it before the next boot
- The operator-meta replay (restore_allow_force,
restore_allow_force_auto_upgraded) silently dropped, exactly
the chicken-and-egg the snapshot was added to close.
`restore_allow_force` reverted to the backup's value on every
PG restore.
CI missed it because integration tests around `performFullRestore`
only exercise the SQLite branch (`this.dbType === 'sqlite'`). The PG
branch requires a real psql binary + cluster, which lives in the
"real-PG integration test in CI" follow-up.
Cure: hoist the `const PRESERVED_META_KEYS = [...]` + `let
preservedMeta = []` declarations above the SQLite/PG split. SQLite
leaves them empty; PG branch fills them; replay block at the bottom
reads them on both paths (no-op on SQLite).
New test: `restoreService.pgBranch.test.js` pins the scope contract
via source inspection. Two assertions:
1. Exactly one `let preservedMeta = []` declaration in the file,
positioned before the SQLite/PG branch split
2. The replay block `if (preservedMeta.length > 0)` sits outside
the else block (closing ` }` exists between the branch
opener and the replay site)
Source-inspection beats a runtime test here because (a) it doesn't
need a real PG cluster + psql binary, (b) it pins the EXACT property
that broke, more directly than a runtime test would.
Closes PR #596 review blocker.