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).
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.
Mirror the onboarding fix on the customer profile (Rechnungsadresse): replace
the free-text 2-char Country input with the CountrySelect dropdown and move it
below State/region. Grid reflowed: Postal+City row, then State+Country row.
The accept-invite (onboarding) address form used a free-text 2-char Country
input sitting above State/region. Replace it with the CountrySelect dropdown
(same component as the admin customer + business-profile forms) and move it
below State/region. Grid reflowed: Postal+City row, then State+Country row.
The test-email, save-config, and flush mutations all showed the generic
'Failed to save changes' toast on error, hiding the actual backend reason —
so a failing test email looked like a save failure and gave no diagnosis.
Show response.data.error / .details (SMTP auth/connection failure, masked
password, private-host rejection, …) with the generic string as fallback.
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 customer detail + business profile forms showed both a Country picker
(stores the ISO code) and a free-text 'Country (full name)' override
(migration 107). Now that the picker offers the full ISO list and the PDF
renderer derives the localized full name from the code (pdfService.countryName,
used as 'country_name || derive' for both issuer and recipient), the free-text
field is redundant. Remove the input from both forms. The DB column + the
fallback stay, so any legacy override still renders.
index.html hardcoded <link rel=icon href=/favicon-32x32.png>. When the HTML
declares a favicon link, the browser uses it and NEVER requests /favicon.ico
— so Safari showed the bundled default and our dynamic backend route was
never hit (direct /favicon.ico was correct, but the tab wasn't). DynamicFavicon's
JS swap is exactly what Safari ignores.
Point the link at /favicon.ico (backend dynamic route) + add apple-touch-icon,
no type/sizes so the response content-type wins. Now the configured favicon
shows from first paint in every browser, Safari included.
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').
The historical-invoice import (and any form whose date field has a non-empty
default like today) lost a typed date: the value was only pushed to the parent
on blur, so submitting while the field was focused — or before React
re-rendered after the blur-time setState — sent the stale default. Issued/
event dates came out as 'today' instead of the entered date.
Now commit as soon as a complete, valid date is entered (toIso returns '' for
partial input, so intermediate keystrokes emit nothing); blur still normalises
display + handles clearing. Applies to every LocalizedDateInput consumer.
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.
Extends the dark-logo fix to the customer login, customer accept-invite,
customer reset-password, and gallery client-access pages — they all rendered
only the light logo on the themed (possibly dark) surface.
Also makes the login-page pick frame-aware: a framed login logo sits on a
fixed cream plate, so the light (dark-ink) logo always reads there; only the
frameless logo sits on the themed page background and uses the dark variant.
This corrects the admin login too (was unconditionally swapping when dark).
Customer/gallery pages read isDark from usePublicDarkMode (branding_force_
color_mode + OS fallback), matching CustomerLayout.
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 login page only ever rendered branding_logo_url (the light logo), so a
dark-text logo sat on the dark background in dark mode. Pick the dark
variant via useAdminDarkMode (honouring branding_force_color_mode too),
mirroring AdminHeader/AdminSidebar, with a fallback to whichever exists.
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.
The link was an <a target="_blank"> doing a hard SPA boot in a new tab,
which tripped the error boundary. Switch to a react-router <Link> so it
opens Settings -> CRM the same way the sidebar nav does (known-good path).
The country dropdown was a curated 22-entry European subset; expand it to
the complete ISO 3166-1 alpha-2 set so customers from any country can be
selected. Labels are still derived from Intl.DisplayNames and sorted by
localized name at render time, so no translation map is needed.
GalleryPreview used only branding.logo_url, so the Live Preview kept the
light logo when previewing a dark theme. Pick the logo by theme.colorMode
(symmetric fallback) and pass logo_url_dark through from BrandingPage.
The sidebar brand row (logo_position=sidepanel) + collapsed rail used
the light logo unconditionally. Make it theme-aware via useAdminDarkMode
with the same symmetric fallback as the header (dark uses dark||light,
light uses light||dark). This was the missing admin surface — the header
already switched.
The whole contracts.detail.* namespace was English-fallback-only, so the
contract detail page rendered English in German. Add all 64 keys to en +
de (native German), covering actions, signing/counter-sign, audit trail,
convert, and PDF flows. Also dedupe a duplicate events.createInvoice key
(identical value).
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.
After 'Create draft invoice' mints the single scheduled invoice from a
per-event customer's unbilled hours, navigate to the bill editor so the
admin can add other line items before it ships (invoice is already
status='scheduled' + editable). Updated the per-event hint copy.
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.
DynamicFavicon hardcoded link.type='image/png', so an SVG/.ico favicon
was declared as PNG and browsers ignored it. Derive the type from the
file extension instead. (Sidebar icon already uses <img> which renders
SVG fine.)
Pick the logo by the active color mode with a symmetric fallback: a
single uploaded logo serves both modes (dark uses dark||light, light
uses light||dark). Apply to admin header, customer gallery, and the
customer portal (follows branding_force_color_mode). PDFs already use
the light branding logo with the business-profile PDF logo as override
(resolveLogoFile) — unchanged.
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 'Configure defaults in Settings' link (opens Settings → CRM in a
new tab) under the payment-conditions section of the quote and invoice
editors, so the admin can jump to the payment-term / Skonto / numbering
defaults without hunting for the settings page.
Add a 'Preview PDF' button on draft contracts that renders a fresh PDF
via the existing no-write /preview endpoint, so the admin can check
layout + signature blocks before sending (no audit trail created).
LocalizedDateInput.toDisplay only matched a bare yyyy-MM-dd, but Postgres
serializes DATE columns as a full ISO datetime, so the field printed the
raw "2026-…T…Z" string (SQLite returned a bare date, hiding it). Match the
leading yyyy-MM-dd of any ISO value and slice the hidden native picker's
value to 10 chars. Fixes ISO-form dates on customer/event/passive-create.
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.
Native <input type="time"> ignores general_time_format (browser-locale
controlled; lang hint failed in Safari and Ralf's Chrome for both en-GB
and de-DE). Replace with a custom TimeField text input that displays per
general_time_format (24h "13:00" / 12h "01:00 PM") and stores canonical
HH:MM, parsing tolerant free-text on blur. Keeps the fixed-width
alignment fix.
Business-hours time pickers used lang="en-GB", which didn't render 24h in
Chrome. Switch to lang={timeFormat==='12h'?'en-US':'de-DE'} — the same hint
HoursSection/Quote/Bill/Contract editors use — so the picker shows 24h in
Chrome/Edge. Keep the fixed-width plain <input> for column alignment.
The shared Input wraps fields in a w-full div, so two per flex row split
the width and the trailing +/trash buttons knocked columns out of
alignment. Use a plain fixed-width <input> for the start/end time fields.
Route 10 surfaces that hardcoded 12-hour date-fns patterns
('h:mm a', 'PPp', 'p', toLocaleTimeString) through useLocalizedDate's
formatDateTime/formatTime so they respect general_date_format +
general_time_format: backup/restore, archives, photo viewer, feedback,
event details, gallery timeline, public quote page, CMS save indicator.
Also pin a lang hint on the business-hours native time inputs (Chrome/Edge
render 24h). Drops now-unused date-fns imports.
Native <input type="time"> renders AM/PM from the browser locale, ignoring
general_time_format. Pin a lang hint (en-GB for 24h, en-US for 12h) so
Chrome/Edge render the admin's chosen format. Display-only; the stored
value was already 24h HH:MM.
The t() calls on the Features tab, CRM settings tab, contract editor,
and settings nav carried English fallbacks but the keys were absent from
both locale files, so DE rendered English. Add ~67 keys to en.json +
de.json (DE native): the full contracts.editor.* subtree (whole page was
English), settings.features.{contracts,crmDevelopment,hoursLogging}.*,
crmSettings contracts/dashboard-overview/ToS labels, and two settings
nav titles. Also drop a dead duplicate bills.field.sourceQuote key in de.
No code changes — additive translations only.
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 Anlass field rendered inv.eventDate verbatim (raw ISO from pg
date-as-Date serialization) while every other date on the card went
through useLocalizedDate. Route it through fmtDate so it honors the
general_date_format setting. (The event_id → /admin/events linkify was
already in place.)
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.
The business-profile country field — the seed source for the customer-create
country default — was still a free-text input placeholdered "FL", which could
reintroduce the non-ISO "FL" code that migration 110 normalized to "LI" and
re-open the create/edit CH-vs-FL default mismatch. Swap it for the shared
CountrySelect so every surface stores ISO alpha-2. The free-text countryName
verbatim-PDF override (migration 107) is unchanged.
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.
The Anlass / event name on the invoice detail page and the bills list
now links through to /admin/events/:id when the invoice references a
real event row. The list link stops propagation so it doesn't trigger
the row's invoice navigation. Falls back to plain text when the invoice
carries only a free-text event snapshot. Customer portal unchanged
(no admin route access).
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.
Admin date inputs were inconsistent: raw <input type="date"> on event
creation and the bill editor rendered in the browser locale (en-US users
saw MM/DD/YYYY regardless of Settings -> General), while the historical-
invoice import modal used a private LocalizedDateField that displayed the
configured format but showed a text box plus a tiny native date stub
side-by-side ("two date fields, looks corrupted").
Extract a single shared LocalizedDateInput that displays/parses in the
configured general_date_format on every browser and opens the native
picker via a calendar icon button (showPicker on a visually-hidden native
input), so there is one date field, not two. Wire it into event creation,
the bill editor (event/issue/due dates), the import modal, and the tax-
report range filters (dropping the Chromium-only lang={dateInputLang}
workaround there).
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.
upstream/beta independently shipped 108_seed_sl_email_template_translations.js
(Slovenian email template translations) using the migration number
this branch had already claimed for 108_add_backup_paths.js. Knex's
filename-based ordering would have caused both to attempt the slot
at merge time.
Renamed via `git mv` so file history is preserved. All five
references updated in lockstep:
- backend/src/services/_backupPathsBoot.js (require + comments)
- backend/src/services/backupService.js (LEGACY_BACKUP_PATHS comment)
- 3 integration test files (require + "migration 108" prose)
- migration's own header comment, with a paragraph explaining the
rename so reviewers don't wonder why the number jumped
**No data-migration impact for installs that already ran the
108-named version** (Ralf's beta, primarily): the migration's body
is idempotent — createTable is guarded by `hasTable`, and the seed
uses `onConflict('path').ignore()`. So when 109 runs against an
install whose backup_paths table is already populated, both the
schema step and the seed step no-op cleanly. The orphaned
`108_add_backup_paths.js` row in the `migrations` tracking table
sits harmlessly alongside the new `109_add_backup_paths.js` row.
No data lost, no double-insert, no schema drift. Mechanical rename
ahead of the PR opening.
The previous split (separate docs/install-from-backup.md + separate
README link for "Disaster Recovery") fragmented what's conceptually
one workflow: backup → restore. DR is a specific scenario of restore
(the destination is wiped), not a separate feature.
This merge:
- Folds install-from-backup content into docs/backup-restore.md
as a "Disaster recovery (install from a backup)" section with
its own table-of-contents anchor.
- Adds an explicit ToC at the top so admins land on what they
need in one click.
- Frames the two restore paths up front: "live install" → wizard,
"fresh / wiped install" → trigger file. Admins encountering DR
in panic mode don't need to know to look under a separate link.
- Drops the duplicate "Disaster Recovery" README bullet. The
"Backup & Restore" blurb now mentions DR explicitly so it's
still findable via Ctrl+F on the README.
- Removes docs/install-from-backup.md (its content is now in
backup-restore.md's DR section).
Single source of truth = less risk of one doc going stale relative
to the other when the feature evolves. Maintainer-facing surface
on docs.picpeak.app shrinks back to one /guides/backup-restore page.
The four backup admin panes (BackupHistory, BackupDashboard,
BackupCoverageCard, BackupIntegrityCard) used raw date-fns
`format()` with hard-coded tokens like 'p' (12-hour AM/PM), 'PP',
'PPP', 'PPp', and 'yyyy-MM-dd HH:mm:ss' — ignoring the admin's
configured `general_date_format` and `general_time_format`
settings.
Net effect on a 24h-configured install: backup History row showed
"11:25 PM" instead of "23:25", and the Coverage tab's "Last dump"
+ "Coverage generated" timestamps were stuck on
yyyy-MM-dd HH:mm:ss regardless of the admin's date-format choice.
All four panes now route through `useLocalizedDate()` which honors
both settings + the active i18n locale (per the existing
[[feedback_respect_general_format_settings]] pattern).
Tokens replaced:
format(date, 'p') → formatTime(date)
format(date, 'PP') → format(date)
format(date, 'PPP') → format(date)
format(date, 'PPp') → formatDateTime(date)
format(date, 'yyyy-MM-dd HH:mm:ss') → formatDateTime(date)
format(date, 'yyyy-MM-dd HH:mm') → formatDateTime(date)
No backend changes — settings already shipped via /admin/settings;
this just makes the consumers actually read them.
Closes the six-step DR dance ("onboard throwaway admin → restore via
wizard → log out → log back in with originals") by letting admins
recover an install with zero clicks past `docker compose up`.
Convention: drop a file named `RESTORE_ON_INSTALL` (no extension OR
.txt) into the existing `/backup` bind mount. On next container
start, the new boot hook detects it, runs the restore, and starts
the server with the restored state. Admin opens the browser, login
works first try.
Payload variants:
- empty file → auto-picks newest backup-manifest-*.json from
/backup/manifests/. Useful for "restore the latest".
- path inside the file → uses that specific manifest. Useful for
"I want this older backup, not the most recent".
Safety gates (three layers):
1. Trigger file must exist — no auto-magic, admin signals intent
2. DB must be empty (no events, ≤1 admin) — refuses to clobber
production data
3. Restore failure leaves the trigger file in place for retry on
next container start. Success deletes it so subsequent boots
don't redo the work.
Override hook: INSTALL_FROM_BACKUP_FORCE=true skips guard #2 for the
"I know what I'm doing" edge case (dev env rebuilds, etc).
No docker-compose changes required — uses the bind mount picpeak
already has, env vars are optional. The minimal admin workflow now
matches the bare-minimum mental model: "copy my backup files,
restart the container, log in with original credentials."
Tests: 7 scenarios covering trigger detection, payload variants,
safety gates, success/failure trigger-file lifecycle.
Closes the last gap from tonight's backup-hardening: backup_runs.
statistics now carries a `per_path` map keyed by backup_paths.path
(e.g. `events/active`, `business-docs`), with per-bucket count + size.
Backend (backupService.js):
- new `computePerPathStats(backedUpFiles, allFiles)` helper that
bucket-sorts each backed-up file into its owning backup_paths row
by longest-prefix match. Reuses the same backup_paths source the
walker reads, so toggling include_in_default off propagates
correctly. Falls back to LEGACY_BACKUP_PATHS if the table is
missing.
- runBackupInternal calls it after the destination implementation
reports back, includes the result in statistics under both
snake_case (`per_path`) and camelCase (`perPath`) keys for the
same alias treatment the existing fields get.
Frontend (BackupHistory.jsx):
- Backup History detail pane now renders one row per per_path entry
when present, with path label + count + formatted size.
- Falls back to the legacy Photos / Archives / "Other" rendering
when the field is absent (backups taken before this commit). No
breaking change for stored history.
Tests: new backupService.perPathStats.test.js — 2 scenarios pinning
attribution behaviour (single-path, nested-paths-don't-collide).
Plus a NOTE comment about overlapping-path walker behaviour (out of
scope; canonical seed doesn't hit it).
Closes the chicken-and-egg where `restore_allow_force` (and its
auto-upgrade tracking flag) got overwritten on every restore by
whatever value happened to be in the backup. Net effect:
1. Admin enables Force Restore (via tonight's default-ON migration
edit, or hand-SQL on older installs).
2. Restore runs successfully.
3. Restored DB has `restore_allow_force = <backup's old value>`.
4. Next restore attempt: "Force restore is not allowed by system
settings" — admin needs the SQL workaround AGAIN.
Cure: snapshot a small list of operator-meta keys BEFORE the DROP
DATABASE (while we still have a working pool against the OLD DB),
then UPSERT them back AFTER the psql restore + migrate.latest.
The preserved set is intentionally narrow — currently just
`restore_allow_force` and `restore_allow_force_auto_upgraded`. These
are about how the operator wants the install to behave, not user-
facing state. Adding more keys is a one-line addition to the
PRESERVED_META_KEYS constant.
Survives both:
- backup is OLDER than the operator's most recent setting change
- backup is NEWER but had a different operator policy
Either way, the post-restore install reflects the LIVE operator
policy, not the backup's snapshot of it.
Closes the loop on the original 2026-05-29 data-loss class: Ralf had
four "Run Backup Now" manifests sitting on disk with
database.backup_file = null because Stage A wasn't yet in place.
The restore wizard would have happily restored any of those four,
bringing back files (photos, PDFs) but leaving the database empty —
silently re-creating the exact data loss the rest of this branch
prevents going forward.
The /restore/list-backups endpoint now returns `database_included`
per row (parsed from the manifest at discovery time). The wizard
uses it to:
- Per-row badge: red "No DB" pill next to any backup where
database_included === false. Tooltip explains the consequence
in plain English: "restoring this will NOT recover the database".
- Selected-card callout: full red banner under the chosen row
when database_included is false, restating the warning + giving
the admin a clear path: "pick a different backup if you have
one with a database dump, or proceed only if files-only is
what you want."
The wizard does NOT block the restore — the admin may genuinely want
a files-only restore (e.g. recovering a deleted photo while keeping
current DB state). The warnings make sure that choice is informed.
The dashboard widget used `lastBackup.created_at` for the "Last
successful backup: X ago" text — but lastBackup is the most recent
row of any status. So a crashed restore (status=running, never
updated) or a recent failure showed up labeled as the last
successful backup. Same "silent failure not surfaced" class the
restore wizard had.
Backend now returns:
lastSuccessfulBackup — most recent backup_runs with status='completed'
zombieRuns — running rows older than 30 min (likely crashed mid-flight)
lastBackup — unchanged (most recent any status)
Frontend renders:
- "Last successful backup: X ago" — always from lastSuccessfulBackup
- "Last attempt: Y ago · failed/running" — when lastBackup differs
from lastSuccessful. failed shows the first line of error_message
in red; running stays neutral.
- Zombie callout — "N backup(s) running >30min — may have crashed"
in amber, so admin sees stuck rows at a glance.
- Health score downgrades from "excellent" to "warning" if the
latest attempt failed, even when older successes keep the age
fresh — surfaces regressions without erasing the green history.
The progress step used a binary `isRunning ? "in progress" : "completed"`
check. So when the backend rejected the restore (pre-flight validator
threw, path error, etc.) the wizard cheerfully rendered "Restore
completed" with 0% progress and no error context — the admin had to
SSH into the server and inspect `restore_runs.error_message` to find
out what happened.
Now reads the most recent row from `restoreStatus.history[0]` and
renders one of three states:
- running → blue text, progress bar updates
- succeeded → green tick + post-restore actions (existing behaviour)
- failed → red banner with the first line of error_message, and
a callout if was_rollback_attempted is true so the
admin knows the destination is safe to retry on top of.
Net: the wizard now tells the truth about what just happened.
`db.destroy()` during restore tore down the in-process connection
pool to release PG sessions so DROP DATABASE could succeed. After
CREATE DATABASE + psql restore, the old code did
`require('../database/db')` expecting a fresh instance — but Node
caches require results, so it got the SAME destroyed instance back.
Every subsequent query in the process failed with "Unable to acquire
a connection" until the container was manually restarted, even
though the restore technically succeeded.
Net effect for admins: login showed "An error occurred", customer /
invoice / quote pages were blank, no surface hinted at the dead pool.
Cure: db.js now wraps the live knex instance in a Proxy that forwards
to a mutable internal reference, with a `reinitPool()` function that
destroys the old instance + builds a fresh one + probes with `SELECT 1`
so any reconnect failure surfaces immediately. The thousands of
existing `const { db } = require(...)` imports work unchanged — they
capture the Proxy once, and every call goes through to the current pool.
restoreService calls reinitPool() after CREATE DATABASE and before
migrate.latest(), so the rest of the request + every subsequent admin
action runs against the fresh pool. Container restart no longer
needed after restore.
Root cause of the persistent "Force restore is not allowed by system
settings" error even on fresh installs after `docker compose down -v`:
migrations/core/032_add_restore_runs_table.js seeded the row with
`JSON.stringify(false)` = the literal string 'false'.
So every install (fresh OR upgraded) wrote restore_allow_force=false
at migration time. The boot self-heal added earlier today saw the row
and respected "admin policy" per its safety design — never noticing
that the row was the deprecated migration default, not an explicit
admin choice.
Cure follows [[feedback_migration_no_compensation]] +
[[feedback_self_heal_pattern]]:
1. Edit migration 032 IN PLACE — flip seed value from false to
true. Fresh installs forward get the correct default at install
time, no boot helper needed.
2. One-time auto-upgrade in _restoreSettingsBoot.js for installs
that already ran the OLD migration. Bumps restore_allow_force
to 'true' iff the current value is the deprecated literal
'false' AND the new tracking key
`restore_allow_force_auto_upgraded` doesn't yet exist. The
tracking flag is always written after the first boot pass, so
subsequent admin choices (e.g. deliberately disabling force)
are preserved on every boot after.
3. Defensive: adminRestore.js getRestoreSettings() now normalizes
'true'/'false'/'"true"'/'"false"' string shapes to JS booleans,
not just '1'/'0'. Belt-and-suspenders so any future seeder that
uses a different boolean serialization doesn't silently break
the !settings.restore_allow_force gate.
Net effect: any picpeak install pulling this image — fresh or
existing — gets restore_allow_force=true on first boot after the
upgrade. The catch-22 that forced every disaster-recovery admin to
hand-write SQL before their FIRST restore is closed.
Fresh installs of picpeak had `restore_allow_force` defaulting to
false (or missing entirely). Combined with the "1 active admin
user" pre-restore warning that the fresh-install admin auto-creates,
this meant the very first restore on every new install hit:
Force restore is not allowed by system settings
Admins then had to hand-craft SQL to flip the setting before they
could recover their data — at the worst possible moment, when they
were already mid-disaster.
This isn't security: the admin who can SQL the setting on can also
flip it via the UI. It's just a sharp edge that bites every new
install once.
Cure: boot-time self-heal that seeds restore_allow_force=true only
when the row doesn't exist. Existing installs that explicitly set
the row (true OR false) are NOT touched — admin policy wins.
Pattern mirrors _backupPathsBoot.js and _emailTemplateBoot.js.
Default-ON rationale matches Stage A's principle: the cost of
forgetting (= can't recover from a disaster) outweighs the friction
saved (= adversarial admins can't run forced restores). Audit
logging keeps the accountability story intact.
The "Content Backed Up" panel in Backup History only counted two
categories (Photos + Archives), so a 3-file backup that landed all
3 in business-docs (Ralf's case after the storage truncation +
restore tonight) showed:
Photos (0 of 0)
Archives (0)
→ total: 3 files
The discrepancy made admins wonder where the 3 files actually went.
Adds two rows:
- "Business documents & other" = files_processed - photos - archives
- "Total files" = files_processed
So the math adds up regardless of which Stage B path the files came
from. Properly per-path-category breakdown requires backend-side
per-path counters (separate follow-up); this commit closes the
visible-discrepancy gap without that schema change.
i18n: en + de added; other locales fall back to en until reviewed.
pg_dump emits setval() statements for SERIAL/IDENTITY columns, but
they don't always land cleanly: --clean ordering, knex pool sequence
caching, rows inserted mid-restore (the pre-restore safety backup
writes a database_backup_runs row before DROP), etc. Net result on
Ralf's install after a successful restore:
- "A record with this value already exists" on every CRUD action
- duplicate key value violates unique constraint
"database_backup_runs_pkey" on the next Run Backup Now
Same root cause: every SERIAL column's sequence was pointing at or
below MAX(id), so the next INSERT collided.
Fix: append a DO block after the psql restore that walks pg_class +
pg_attribute and setval()s every public-schema sequence to
GREATEST(MAX(<col>), 1). Cheap (a few ms even on large schemas),
safe (read-only on row data), idempotent — re-running it just
re-asserts the same values.
Seventh latent PG-restore bug discovered on Ralf's install tonight.
Manual hand-fix worked; this commit makes the fix automatic for
every future restore.
PostgreSQL refuses DROP DATABASE while any session is connected:
ERROR: database "picpeak_prod" is being accessed by other users
DETAIL: There are 6 other sessions using the database.
The backend's own knex pool holds 5-25 active connections to the
target DB. So even after closing the request that initiated the
restore, the pool keeps the DB busy and the DROP statement fails.
Three-layered cure, all in the restore service's PG branch:
1. Call `db.destroy()` first to close the in-process knex pool so
we don't fight ourselves. Knex will lazily re-open on the next
query via db.js's retry logic, so this is safe to do mid-restore.
2. SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE
datname=<target> AND pid<>pg_backend_pid() — evicts any sessions
from other processes (other server replicas, leftover idle
transactions, things our own pool destroy missed).
3. DROP DATABASE IF EXISTS "<target>" WITH (FORCE) — PG13+ kills
remaining connections atomically with the DROP. Falls back to
plain DROP on older Postgres where WITH (FORCE) is a syntax error.
Surfaced as the FIFTH latent bug in the restore path tonight: the
DROP DATABASE statement always assumed a quiescent destination, but
the live backend keeps the destination busy at all times. Every
previous PG install of picpeak that ever tried Restore would have
hit this — meaning the disaster-recovery feature has shipped broken
for a long time without anyone exercising it end-to-end.
`psql` with no -d connects to a database whose name matches the
connecting user. On installs where the user's home DB doesn't exist
(common pattern: DB_USER=picpeak, DB_NAME=picpeak_prod, no `picpeak`
DB), the restore's DROP DATABASE / CREATE DATABASE statements failed
with:
FATAL: database "picpeak" does not exist
even though the target DB (picpeak_prod) was alive and connectable.
And of course you can't connect to the target DB itself for DROP —
PostgreSQL refuses while a connection is open to it.
Fix: explicitly connect to `postgres` (the maintenance DB every PG
cluster ships with) for the DROP/CREATE statements. Override via
DB_CHECK_DB env var if the `postgres` DB is restricted to superusers
on the cluster — matches the pattern wait-for-db.sh already exposes.
Also quote the database name in the SQL so installs whose DB has
unusual characters (numbers, hyphens) don't break the statement.
Surfaced during Ralf's end-to-end restore validation — yet another
"never been tested on a real PG install" latent bug exposed by the
Stage A inline-dump path actually being able to produce a restorable
manifest for the first time on his install.
Two changes that close the disaster-recovery loop the Stage A-B-C
backup-hardening plan opened:
1. Resolve 'local' source to backup_destination_path
The wizard passes options.source = 'local' (the SOURCE TYPE
string). The old code assigned that verbatim to localBackupPath
and every downstream path.join() ended up with junk like
'local/database/<file>.sql.gz'. Fixed by looking up
backup_destination_path from app_settings when source='local',
plus a layered candidate fallback in performDatabaseRestore so
absolute paths in manifests are honoured first.
2. Auto-rollback on ANY failure during restore
Previously rollback only fired when post-restore VERIFICATION
failed (inside the try block). Anything that threw earlier —
path bugs, pg_restore failure, file copy errors — left the
destination half-clobbered with no automatic recovery. Now the
catch block always invokes attemptRollback if a pre-restore
backup exists, and persists rollback status in
was_rollback_attempted + an enriched error_message so the admin
can tell at a glance whether the destination is safe to retry
on top of or needs manual inspection first.
Surfaced during Ralf's validation of the end-to-end backup +
restore cycle (`docker compose down -v` then restore from disk).
Every prior failed attempt left stray PDFs behind that the next
attempt had to navigate around — exactly the "every failure makes
the next worse" pattern this fix kills.
Two stacked bugs in the disaster-recovery path:
1. The wizard passes `options.source = 'local'` (the source TYPE
string) and the service assigned it verbatim to `localBackupPath`.
Every downstream `path.join(localBackupPath, ...)` ended up with
junk like `local/database/<file>.sql.gz` and `local/events/...`.
2. performDatabaseRestore reconstructed the dump path from the
manifest by basename-only:
path.join(backupPath, 'database', path.basename(dbBackupFile))
discarding the absolute path the manifest actually recorded.
Cure:
- At the entry point, if `options.source === 'local'`, look up
`backup_destination_path` from app_settings and use that as the
local root. Honour s3:// downloads via the existing branch.
- In performDatabaseRestore, try the manifest's absolute path
first, then `localRoot + manifest_value`, then the legacy
`localRoot + 'database' + basename` reconstruct as a final
fallback. First hit wins; error message lists every candidate
so future failures are diagnosable.
Surfaced during Ralf's end-to-end validation of the Stage A-B-C
backup-hardening plan — restored fresh after `down -v`, the wizard
failed silently with `Database backup file not found: local/database/...`
even though the dump existed at the path the manifest recorded.
With this fix, the same destruction-and-recovery sequence completes.
The Restore wizard's "Choose Backup to Restore" list was driven only
by the backup_runs table. After `docker compose down -v` (the disaster
this whole hardening effort is designed to recover from), the DB is
empty and the wizard shows "No backups found in selected source" —
exactly when it's needed most. The manifest JSONs are still on disk;
the wizard just can't see them.
Adds disk-first discovery:
- Walks backup_destination_path AND backup_manifest_path (manifests
can live in a sibling directory under the canonical
<root>/manifests/backup-manifest-<id>.json layout). Depth-limited
recursion (3 levels) so the scan doesn't enumerate the photo tree.
- Matches backup-manifest-*.json|yaml AND legacy bare manifest.json.
- Parses each manifest for real metadata (timestamp, size, file
count, database.backup_file presence) instead of showing the
admin opaque filenames.
- Layers in surviving backup_runs rows, deduping by manifest_id.
Applied to both GET /available-backups (legacy) and POST /list-backups
(the one the frontend actually calls). Same helper, two call sites.
Side benefit: each returned row now carries `databaseIncluded` — so a
future Restore UI iteration can show a "this backup has no DB dump"
warning before the admin picks a files-only backup. Exactly the
surface that would have caught Ralf's original four files-only
manifests if it had existed.
`BackupHistory.jsx` opened `/admin/backup/download/<id>` via window.open,
which goes to the React SPA's router — no matching route, so it
rendered the "Page Not Found" screen.
The actual download endpoint lives at `/api/admin/backup/download/:id`
on the backend (adminBackup.js:685). Cookie-based admin auth already
supports the implicit cookie sent by window.open, so the URL prefix
was the only thing missing.
Predates today's backup-hardening work — the bug has existed since
this download button shipped. Surfaced now because Ralf finally has a
completed backup to try downloading after the Stage A inline-dump
guard started working.
pg_dump rejects `--single-transaction` — it's a pg_restore / psql flag,
never a pg_dump one. Triggered as soon as the inline-dump path landed
on Ralf's install:
pg_dump: unrecognized option: single-transaction
pg_dump: hint: Try "pg_dump --help" for more information.
pg_dump already wraps the entire export in a single REPEATABLE READ
snapshot automatically (since Postgres 9.x), so the original intent —
consistent snapshot of the live DB — is preserved by removing the
flag. Same "latent until Stage A wired it in" pattern as the three
prior bugs this rollout has surfaced (PG insert destructure → bind-
mount EACCES → Node 22 stdio strict mode → this).
spawnToFile and spawnFromFile passed an unopened WriteStream/ReadStream
directly as a stdio entry to child_process.spawn. Older Node versions
auto-extracted .fd; Node 22 throws synchronously:
The argument 'stdio' is invalid.
Received WriteStream { fd: null, path: '/backup/database/...sql', ... }
Bug bit Ralf's install once today's `bugfix/crm-backup` image landed —
Node 22 came with that image, and Stage A's inline-dump path is the
first caller of spawnToFile on this install. Latent on the previous
image (Node 20); fatal on this one. restoreService's pre-restore
safety snapshot uses the same helper and would have hit it next time
a restore ran.
Cure: stdio: ['ignore', 'pipe', 'pipe'] (and ['pipe', 'pipe', 'pipe']
for spawnFromFile) + manual pipe of child.stdout/stdin through the
file stream. Works on every Node version. Also wires the WriteStream's
'error' event to the promise via settleReject so a future EACCES /
ENOSPC reaches the caller's try/catch instead of becoming a process-
fatal unhandled error event — closing the same "Stage A guard
bypassed" hole noted in the spawned follow-up task.
Side benefit: outStream.end() now awaits flush before resolving, so
fast pg_dump runs can no longer produce a truncated dump.
databaseBackupService.backup() did `const [runId] = await db(...).insert({...})`
without a .returning() — works on SQLite (knex returns [lastInsertId]) but
throws "(intermediate value) is not iterable" on Postgres (knex returns
a non-iterable shape).
Bug was latent until Stage A of the backup-hardening plan wired this
method into the "Run Backup Now" inline-dump path. Before Stage A only
the scheduled cron + the dedicated admin-DB-backup page called it, and
Ralf's install had never exercised either — so the inline-dump default
landing in production was the first time the destructure ran on his PG.
Cure: same explicit .returning('id') + dual-shape coalesce pattern that
backupService.js uses for its own backup_runs insert (line 949).
Two more sibling files have the same anti-pattern (userManagementService,
customerAccountsService — invitation flows) and will bite under the
same conditions; spawned a follow-up task to fix them in a separate PR.
Stage C of the three-stage backup-hardening plan (Stage A: inline
DB dump + fail-loud landed in 7fdf01a; Stage B: config-driven walker
in 302fc6b). Answers the "what would I lose if I clicked Run Backup
Now right now?" question that Stage B made possible to answer.
Backend:
- new backupCoverageService.js: per-path coverage classification,
drift detection (top-level subdirs not in backup_paths and not
in the backups/tmp allow-list), DB-dump mode + staleness block
- new GET /api/admin/system-health/backup-coverage route, same
auth + settings.view permission as /backup-integrity
- 7 integration scenarios pinning the classifier behaviour
Frontend:
- new BackupCoverageCard with auto-fetch (cheap; no recursion)
- new Coverage tab on BackupManagement next to Integrity
- en + de i18n; other locales fall back to en keys until a native
speaker reviews
Verification:
- 26/26 backup integration tests pass (Stage A 5 + Stage B 7 +
Stage C 7 + adminBackupIntegrity 4 + businessDocs 3)
- frontend build clean
- 4 pre-existing integration failures confirmed unrelated
Stage B of the three-stage backup-hardening plan (Stage A:
inline-DB-dump + fail-loud guard already landed). The file-backup
walker used to hard-code its subdirectory list inside
`getFilesToBackupInternal`, which is the same footgun that hid the
`business-docs` gap for ~6 months — a new feature drops artefacts
under STORAGE_PATH and the maintainer has to remember to edit the
walker.
Now driven by a `backup_paths` table:
- Migration 108 creates the table and seeds the 7 canonical
defaults (events/active, events/archived, thumbnails, previews,
heroes, uploads, business-docs). Seed data lives on the
migration as `DEFAULT_PATHS` so the boot self-heal can re-use it.
- `_backupPathsBoot.js` mirrors `_emailTemplateBoot.js`: on every
boot it diffs the canonical list against the current rows and
`INSERT ... ON CONFLICT DO NOTHING`s the missing ones. Keeps
admin edits intact, picks up new defaults shipped after the
install (Knex won't re-run migration 108). Wired into server.js
just before `startBackupService()`.
- Walker now calls `resolveBackupPaths(config)` which:
* reads `backup_paths WHERE include_in_default=true ORDER BY
display_order`
* falls back to a hard-coded `LEGACY_BACKUP_PATHS` if the
table is missing OR empty (defense in depth — never silently
scans nothing)
* gates each row by its `feature_flag` column (matches how
`backup_include_archived` already worked; data-driven now)
- Backward compatible: `getFilesToBackup(true|false)` still works
for legacy callers and the existing businessDocs test. New
callers should pass the full config object so feature gates
other than `backup_include_archived` evaluate correctly.
Tests:
- new: `backupService.configurableWalker.test.js` — 7 cases
covering canonical seed, toggling include_in_default, runtime
INSERT picked up without restart, feature_flag gating both on
and off, empty-table → LEGACY fallback, boolean backward compat
- all 15 backup-walker integration tests pass
(configurableWalker 7 + inlineDbDump 5 + businessDocs 3)
- frontend build clean
- 4 pre-existing integration failures (webhookDelivery, storage
backend, adminPhotos.reference, imageProcessor.storage) confirmed
unrelated via `git stash` baseline run
Stage C (CRM feature coverage audit + diagnostic UI) follows
in a separate commit.
The previous file-backup workflow only LOOKED UP an existing
database dump via getDatabaseBackupInfo() and silently shipped a
files-only manifest when none was found. Admins clicking "Run
Backup Now" (or relying on the schedule) got an apparent success
that omitted every customer / quote / invoice / contract / payment-
log row. The data-loss footgun was discovered 2026-05-29 when an
admin who'd been "backing up" for weeks via the UI lost the entire
CRM after a routine docker compose down -v — every produced
manifest had database: { backup_file: null, size: 0, tables: {} }.
New helper `ensureDatabaseDumpForBackup(config)` encapsulates:
1. Inline pg_dump (or SQLite copy) before the file scan, via
databaseBackupService.backup(). Result lands in
database_backup_runs and is picked up by the existing
getDatabaseBackupInfo lookup that writes the manifest.
2. Fail-loud guard: if no usable dump file is reachable (path
missing, 0 bytes, or never existed), throw — the existing
catch in runBackupInternal marks the backup_runs row failed
with the error_message and emails the admin if configured.
No more silent files-only manifests.
3. Opt-out: `backup_database_inline_dump = false` skips the
inline dump for admins who already run their own scheduled
`backup_database_schedule`. The fail-loud guard still
applies, so an opted-out install with no recent dump still
aborts loudly instead of producing a partial backup. Default
ON is encoded as "skip only when explicitly false" — undefined
(existing installs upgrading) falls through to the safe-
default ON branch.
The helper returns the verified `databaseInfo` so the manifest-build
step at runBackupInternal:917 reuses it instead of calling
getDatabaseBackupInfo a second time. S3/future destinations that
override `result.databaseInfo` are still respected (the existing
`result.databaseInfo ||` fallback shape stays put).
Test suite covers: default-on happy path, dump-throws-aborts-run,
opt-out + recent dump + proceeds, opt-out + no-dump + fail-loud,
opt-out + 0-byte dump + fail-loud. Mocks
databaseBackupService.backup so the tests don't depend on pg_dump
or sqlite3 CLI binaries being installed.
Stage A of three-stage backup hardening plan. Stage B (config-
driven walker) and Stage C (audit + diagnostic UI) follow in
separate commits.
The previous file-backup workflow only LOOKED UP an existing database
dump via getDatabaseBackupInfo() and silently shipped a files-only
manifest when none was found. Admins clicking "Run Backup Now" (or
relying on the schedule) got an apparent success that omitted every
customer / quote / invoice / contract / payment-log row. The
data-loss footgun was discovered 2026-05-29 when an admin who'd been
"backing up" for weeks via the UI lost the entire CRM after a routine
docker compose down -v — every produced manifest had database:
{ backup_file: null, size: 0, tables: {} }.
Changes to runBackupInternal:
1. Inline pg_dump (or SQLite copy) before the file scan, via
databaseBackupService.backup(). Result lands in
database_backup_runs and is picked up by the existing
getDatabaseBackupInfo lookup that writes the manifest.
2. Fail-loud guard after the dump step: if no usable dump file is
reachable (path missing, 0 bytes, or never existed), throw —
the existing catch block marks the backup_runs row failed with
the error_message and emails the admin if configured. No more
silent files-only manifests.
3. Opt-out: `backup_database_inline_dump = false` skips the inline
dump for admins who already run their own scheduled
`backup_database_schedule`. The fail-loud guard still applies,
so an opted-out install with no recent dump still aborts loudly
instead of producing a partial backup. Default ON is encoded
as "skip only when explicitly false" — undefined (existing
installs upgrading) falls through to the safe-default ON path.
Test suite covers: default-on happy path, dump-throws-aborts-run,
opt-out + recent dump + proceeds, opt-out + no-dump + fail-loud,
opt-out + 0-byte dump + fail-loud. Mocks
databaseBackupService.backup so the tests don't depend on pg_dump
or sqlite3 CLI binaries being installed.
Stage A of three-stage backup hardening plan. Stage B (config-driven
walker) and Stage C (audit + diagnostic UI) follow in separate
commits.
The fix shipped in 3ab3756 added /backup to the boot-time chown list.
That broke installs that don't bind-mount ./backup:/backup — the
single greedy `chown -R /a /b /c /backup` returned non-zero on any
individual failure, exiting the script and putting the backend into
a restart loop.
Reverting to the upstream-stable version. The original EACCES at
backup time is better fixed by admins pointing the backup destination
at a writable path via the admin UI (e.g. /app/storage/backups,
which the script already chowns) rather than baking a /backup
assumption into every install's boot path.
The docker-compose `./backup:/backup` mount was the only bind mount
not included in wait-for-db.sh's startup chown step. On a fresh
install (or any time the mount point is recreated), it stays
owned by root, and the nodejs (UID 1001) process running the
backup service gets EACCES when trying to mkdir under /backup.
Added /backup to both the chown list (root branch) and the
writable-check list (compose `user:` override branch), each guarded
by `[ -d /backup ]` so installs that don't use the bind mount —
native deployments, k8s with a different backup destination, etc. —
still boot cleanly.
Existing installs hit by this need a one-time host-side
sudo chown -R 1001:1001 <host-mount-for-/backup>
because the on-disk ownership won't fix itself; the script only
chowns at startup, and the directory was already created with
the wrong ownership by Docker's mount-point auto-creation. From
this commit onward, fresh installs are correct from the first
boot.