Implements the three restore-hardening items deferred from the #811 Codex
review (all validated against a real Postgres, see __tests__/integration/
picpeakRestorePg.test.js). Backend-only; targets main (feature, not a backport).
1. Global session cutoff (utils/sessionCutoff.js). A restore reassigns admin/
customer/event ids, so ANY pre-restore JWT can rebind to a different restored
principal. Revoking just the importing token wasn't enough. importFromPicpeak
now stamps a unix-second cutoff in app_settings after the restore commits, and
adminAuth / galleryAuth / verifyGalleryAccess / customerAuth reject any token
whose iat predates it (cached 30s → one in-memory compare on the hot path).
The operator's forced re-login mints a token past the cutoff, so it passes.
2. Role preservation across an RBAC replace (captureOperatorRole /
preserveOperatorRole). The operator's role + granted permission NAMES are
captured before the wipe; after roles/role_permissions are replaced the role
is resolved by NAME against the restored data, and re-created with its grants
if the backup omits it — so a crafted or cross-instance backup can't silently
downgrade or lock out the operator. reinjectCurrentAdmin now returns the
operator's id so the row can be re-pointed at the resolved role.
3. Postgres identity-sequence resync (resyncSequences). batchInsert writes
explicit ids without advancing the sequences, so the next natural insert into
any restored table collided on the PK. Runs AFTER commit (setval isn't
transactional) and guards every table with a column-existence check —
pg_get_serial_sequence RAISES on id-less tables like role_permissions.
No-op on SQLite.
Tests: SQLite unit tests for the cutoff and role preservation; a gated Postgres
integration suite (npm run test:pg with PICPEAK_PG_TEST_URL) covering sequence
resync, the id-less-table guard, explicit-id reinject, role re-creation, and a
full cross-instance replaceAllTables run asserting operator preservation, role
re-establishment, FK integrity, and collision-free post-restore inserts.
Stacks on #811 (shares the reinject hardening); merge after it.
The req.admin.id fix activated reinjectCurrentAdmin(); hardening its preservation
logic (found across Codex review rounds of #811):
- MFA hijack: reinject wrote back only password_hash/is_active/
must_change_password, leaving a crafted backup's two_factor_* on the
operator's row — it could strip or replace their second factor. The email-
matched row is now updated with the operator's full AUTH set (login identity,
password, and all two_factor_* columns). Relationship/audit FKs (role_id,
created_by) are deliberately NOT forced from the snapshot: on a cross-instance
restore those pre-restore ids may be absent from the backup and would dangle
the FK (SQLite rolls back at commit); the restored row keeps its own valid
values.
- Cross-instance restore rollback / FK safety: reinject matched only by email,
so a backup shipping a different admin with the default `admin` username hit
UNIQUE(username) and rolled the whole restore back; email and username could
even collide on two different rows. Reconciliation is now non-destructive:
the email-matching row is updated in place (id preserved → restored FKs like
events.created_by stay valid); any different row holding the operator's
username is RENAMED, not deleted (deletion would fire ON DELETE actions /
dangle references); only when no row has the operator's email is a fresh row
inserted, with created_by nulled and an explicit max(id)+1 id (batchInsert
left the Postgres identity sequence unadvanced, so a sequence-based insert
could collide).
- Stale session after restore: admin_users ids shift on restore, but the
operator's live JWT is bound only to decoded.id (IP logged not enforced; the
backup controls password_changed_at). The route now revokes the token (result
checked and logged) and clears the admin cookie; the client redirects to a
fresh login via a sessionInvalidated flag. Cookie clear is the unconditional
guarantee.
Adds SQLite-backed reinject regression tests (in-place login/MFA restore with id
and FK columns preserved, username-only rename, email+username on different rows,
clean insert with created_by nulled) and the frontend redirect on
sessionInvalidated.
Deferred (design decisions / pre-existing, need a Postgres test env — see PR
discussion): global "invalidate all pre-restore sessions" cutoff; preserving the
operator's ROLE semantics across an RBAC-table replace; and resyncing Postgres
identity sequences after any restore (batchInsert leaves them behind max(id) —
pre-existing, affects every restored table).
The chunked video upload stored req.body.filename unmodified and later built
the merged path as path.join(tempDir, uploadMeta.filename). path.join does not
neutralise '../', so a filename like '../../uploads/logos/evil.svg' escaped the
temp dir on merge and overwrote arbitrary files. Requires admin with
photos.upload.
Fix: path.basename() the client filename in initializeUpload() and reject
names that collapse to nothing. Adds a regression test.
Two invoice-PDF changes from #794.
1. VAT / free-text note (Benedikt's request, placement A). A new
`crm_invoices_vat_note_text` setting (Settings → CRM → Invoices) prints a
free-text line directly under the MwSt. row on every invoice. Data-driven:
the admin types the exact wording (Austrian Kleinunternehmer § 6 Abs. 1 Z 27
UStG, German § 19, reverse-charge, …) — no jurisdiction hardcoded. The
totals-block reserve grows by the measured note height so a long note can't
push the grand total into the footer. Read in invoice/render.js, threaded
through normaliseContext, drawn in drawTotals. Empty → row omitted; quotes
unaffected.
2. Multi-page footer overlap. On a full continuation page the line-item table
filled to the bottom margin, but the "Seite X von Y" stamp was drawn at
marginBottom-12 — INSIDE that fill zone — so items overlapped the page
number. Move the stamp into the bottom margin (below the content edge),
zeroing that page's bottom margin during the write so it can't trigger
PDFKit's auto-page-break. Verified: on a full page the lowest item text is
at pdfkitY ~790 while the page number sits at ~816 — ~26pt clearance.
Tests: render the note on a single page (byte-delta proves it renders) and
paginate a long invoice with the note (2–3 pages, no stray blank page).
26 tests as a safety net ahead of decomposition — invoice create/list/
status transitions, adminEvents CRUD via Supertest+SQLite, backup config
parsing and manifest validation.
Repo transferred from the-luap/picpeak → PicPeak/picpeak. Docker images
publish to ghcr.io/picpeak/picpeak/{backend,frontend} (lowercase, per the
GHCR canonical form computed by docker-build.yml's `${GITHUB_REPOSITORY,,}`).
Sweep covers:
- docker-compose.production.yml + Dockerfiles → new image registry path
- README, CONTRIBUTING, SECURITY, SIMPLE_SETUP, scripts/picpeak-setup.sh
→ new GitHub URLs
- Update-check / release-notes services (updateCheckService,
environmentService, updateNotificationService, adminSystem,
UpdateNotification, githubReleaseUrl) → GitHub API + tag URLs use the
canonical PicPeak/picpeak path
- Issue templates + README-DOCKER + workflow README → updated package URLs
- One commit-context comment in migrations/090 + customerAccountsService
CHANGELOG.md is intentionally untouched (historical release entries are
immutable; GitHub auto-redirects the old URLs indefinitely).
CLAUDE.md keeps the bare `(the-luap)` reference — that's the maintainer's
personal handle, not a repo URL.
22 files, 48/48 line swaps (every change is a 1:1 URL replacement).
Implements the hybrid scope agreed on in #663: two native adapters
(Umami + Rybbit) for trackers we'd keep maintained, plus a Custom
script-paste mode for everyone else (Plausible, Matomo, Pirsch, GA4,
GoatCounter, Fathom, Cloudflare Web Analytics). Phase 2 (Plausible
native, deeper metrics) explicitly deferred until someone asks.
## Architecture
**Backend `services/trackers/`**:
- `TrackerAdapter` shape (single method): `fetchDeviceBreakdown` →
`{ desktop, mobile, tablet } | null`. Null = route falls back to
access_logs heuristic.
- `umamiAdapter.js` — extracted from the `services/umamiClient.js`
that landed in #662. Same 10 test contract preserved.
- `rybbitAdapter.js` — new. Hits `/api/site/{id}/breakdown?dimension=
device` with Bearer auth, accepts both bare-array and `{data:[...]}`
envelope variants, tolerates `sessions`/`visitors`/`value`/`count`
metric keys.
- `customScriptSanitiser.js` — sanitize-html with a tracker-tight
allowlist (`<script>` / `<noscript>` / `<link rel=preconnect|
dns-prefetch>` / `<meta>`). Strips event-handler attributes,
`javascript:` and `data:` URLs.
- `index.js` factory: `resolveAdapter()` reads
`analytics_tracker_provider` setting → dispatches. Back-compat:
when provider is unset, infers `umami` from the legacy
`analytics_umami_enabled` flag so #662 installs keep working
without an admin touching settings.
**Backend routes**:
- `adminDashboard.js /analytics`: now goes through `resolveAdapter()`.
Old `fetchUmamiDeviceBreakdown` direct import removed; both `umamiClient.js`
and its test file deleted (replaced by the adapter shape).
- `adminSettings.js PUT /analytics`: validates the new
`analytics_tracker_provider` enum, sanitises any incoming
`analytics_custom_head_html` on save via the sanitiser. Masks
the new `analytics_rybbit_api_key` on every GET — same pattern as
Umami's API key and recaptcha secret.
- `publicSettings.js`: emits `analytics_tracker_provider`,
`rybbit_url`/`rybbit_website_id` (only when provider=rybbit), and
the pre-sanitised `analytics_custom_head_html` (only when
provider=custom). Legacy `umami_*` fields stay for back-compat.
**Frontend**:
- `analytics.service.ts` reworked into a provider-aware shape.
`initialize({provider, ...config})` dispatches to Umami /
Rybbit / Custom / None. `track()` calls dispatch to
`window.umami.track` / `window.rybbit.event` / no-op based on
the loaded provider.
- `App.tsx` `AnalyticsBootstrap` reads `analytics_tracker_provider`
from public-settings and routes to the right `initialize` call.
Legacy `umami_enabled`-based path preserved as fallback when the
new field is missing.
- `AnalyticsTab.tsx` (Settings → Analytics) reworked with a
"Provider" dropdown switching between None / Umami / Rybbit /
Custom panels. Each panel renders its own config fields; Custom
panel surfaces an explicit CSP-reminder banner.
- `useSettingsState.ts` shape extended with `tracker_provider`,
`rybbit_url`/`rybbit_website_id`/`rybbit_api_key`,
`custom_head_html`. Save mutation keeps `umami_enabled` in sync
with `tracker_provider==='umami'` for back-compat with downstream
consumers (publicSettings shape, embedded iframe).
- `publicSettings.service.ts` type extended.
**i18n**: EN + DE for the provider heading + description + dropdown
options + Rybbit fields + Custom HTML field + CSP warning.
## Custom mode — script execution caveat
When the gallery `<head>` receives the custom HTML, simply assigning
innerHTML to a container element wouldn't execute the embedded
`<script>` tags (per the HTML spec, dynamically-inserted scripts via
innerHTML are non-running). `analytics.service.ts:120-130` re-creates
each `<script>` element manually so the browser actually evaluates
it. Non-script nodes (link, meta, noscript) move in directly.
## Tests
**Backend** (42 cases, all pass locally):
- `umamiAdapter.test.js` (10) — pinned from the original
`umamiClient.test.js`: missing-config / URL shape / encoding /
payload normalisation / `laptop`→`desktop` / unknown buckets /
empty / non-2xx / invalid JSON / network error.
- `rybbitAdapter.test.js` (9) — same shape adapted for Rybbit:
bare-array + envelope payload, `sessions`/`visitors`/`dimension`
key tolerance, encoding, failure modes.
- `trackerFactory.test.js` (6) — resolves null for `none`/`custom`,
correct adapter for `umami`/`rybbit`, back-compat path via
legacy `analytics_umami_enabled`, garbage-provider defensive null.
- `customScriptSanitiser.test.js` (12) — Plausible-style passthrough,
Umami-style passthrough, inline body passthrough, `<noscript>`
allowed, `<link rel="preconnect|dns-prefetch">` allowed,
`<link rel="stylesheet">` stripped, disallowed tags stripped,
`javascript:`/`data:` URLs stripped, `on*` event handlers
stripped, defensive on malformed input.
- `analyticsDateMerge.test.js` (5) — preserved from #662.
**Frontend**: full 84-case vitest suite green; tsc + eslint clean
on changed files. Adapter changes are narrow refactors of code
covered by backend tests; no new analytics-page unit test added.
## End-to-end smoke (dockerised backend + my changes mounted)
```
test 1 (back-compat: no provider, umami_enabled=true)
→ factory returns umami adapter, /analytics returns
devicesSource:access_logs (umami fetch to fake host fails
gracefully). ✓
test 2 (invalid provider value)
→ 400 "analytics_tracker_provider must be one of: none, umami,
rybbit, custom" ✓
test 3 (save custom HTML with XSS payload)
→ stored sanitised:
`<script>alert(1)</script>evil<script async defer
data-domain="x.com" src="https://plausible.io/js/script.js"></script>`
(<div> stripped; script tags survive but CSP `script-src 'self'`
still blocks inline + non-allowlisted external at runtime) ✓
test 4 (public-settings exposes the provider switch)
→ `analytics_tracker_provider: 'custom'`,
`analytics_custom_head_html: '<sanitised>'` ✓
```
## Out of scope (next discussions)
- **Plausible native** — covered via Custom mode for now; native is
Phase 2 if someone explicitly asks.
- **CSP "trusted domains" admin input** — Phase 1.5. For now operators
add their tracker domain to nginx/proxy CSP manually; the new
CSP-reminder banner in the Custom panel makes that clear.
- **Refactor `(window as any).umami.track(...)` direct calls** in
PhotoLightbox/PhotoGrid to go through `analyticsService.track()`
so events fire on the right tracker. Currently a no-op when Umami
isn't loaded; functional but not optimal.
Closes#663 Phase 1.
Two pre-existing HIGH bugs surfaced by the codebase audit (accounting surface):
- taxReportService: income totals excluded only `status='cancelled'`, never
`kind='storno'`. A Storno (status='sent', amounts stored negative) netted into
the totals on top of the already-excluded cancelled original → double-subtract,
so a cancel-and-reissue read as 0 income instead of the reissued amount.
Now exclude storno rows from grandTotal*/byRate (kept visible in the row list).
Regression test reproduces the real cancel→storno→reissue 3-row flow.
- customerHoursService.buildLineItemFromEntry: `String(entry.entry_date).slice(0,10)`
on a `date` column → Postgres returns a JS Date, baking "Wed Apr 06" into the
invoice line + PDF (SQLite returns the bare string, so SQLite-only tests pass).
Normalise via the Date branch like every other date read.
- #1 resolveTaxTreatment: an unconfigured (empty) reclaim-countries list no
longer auto-classifies every supplier — incl. the admin's own domestic one —
as foreign; defer auto-classification until the setting is set (+ test).
- #2 pending re-bills on customer erase: eraseCustomer now returns the
customer's not-yet-billed inbound docs to the inbox (null customer + unsorted)
so they aren't billable to an anonymized account. (NB: picpeak has no hard
customer delete — erase anonymizes in place — so the orphan/404 premise can't
occur; this is hardening.)
- #4 VatRateSelect: when >1 configured code shares the same rate, fall through
to the legacy "(not configured)" option instead of silently picking the first.
- #5 unwindBilledLine: delete the (mutable, never-issued) invoice when the
unwound re-bill was its only line, instead of leaving a net-zero survivor.
- #6 isInvoiceMutable: clarify in a comment that invoices have no 'draft' status
(the editable state is 'scheduled' w/o send-at) — no behaviour change.
- nit: collapse normalizeCurrency's tautological ternary.
- Fix VAT picker i18n: t('vat.legacyRate') → 'ledger.vat.legacyRate' (the key's
real home), so the legacy label localizes instead of always showing English.
- Remove dead i18n keys left by the settings refactor (businessProfile.field VAT
/hourly + profileFields.title/savedToast).
VAT supplier-country reclaim default:
- Migration 134 adds inbound_documents.supplier_country.
- categorizeInbound auto-derives tax_treatment via resolveTaxTreatment:
explicit treatment wins; else country in the reclaim list → domestic,
outside it → foreign_vat_non_reclaimable, unknown → domestic. Consumes the
previously-stored-but-unused accounting_vat_reclaim_countries.
- Triage modal gains a Supplier country dropdown (saved via updateInbound).
+5 unit tests for resolveTaxTreatment.
Configurable default output VAT code for new invoices:
- New accounting_default_output_vat_code setting (PUT wired; getSettings/type).
- Settings → Accounting dropdown to pick it.
- Invoice + quote editors seed their VAT picker (rate + code) from it on a
blank new document — skipping edits/conversions, never clobbering a touched
value. New docs no longer silently start at 0%.
i18n en + de.
Address three incoming-invoice issues:
1. Re-categorization: a categorized invoice can now be changed again (e.g.
passthrough → company expense). New "Re-categorize" button pre-fills the
triage modal from the existing disposition/customer/markup/note.
categorizeInbound is re-runnable — it unwinds any prior re-bill line
(removes the invoice line + recomputes totals) before applying the new
disposition, and refuses (INVOICE_LOCKED) when the re-bill is on an
already-issued invoice.
2. Note field: new `note` column (migration 132 — 126 is already on beta)
captured in triage and shown in the read-only view.
3. Re-bill like hours: rebill/passthrough now persist customer_account_id.
Per-event customers accumulate as PENDING items, surfaced in a new
"Pending re-bills" card and bundled into one invoice via "Bill these"
(mirrors unbilled-hours billing). Monthly/manual customers keep
auto-consolidating onto their running draft. Passthrough (durchlaufend)
can now also attach to a customer with optional markup.
Adds backend unit tests for buildInboundLineItem + isInvoiceMutable and
en/de translations (other locales fall back to English defaults).
The PhotoExportMenu's TXT format advertises "Simple text list for Lightroom
search" but emitted newline-separated filenames WITH `.jpg`. Lightroom's
filename search wants a comma-separated one-liner, and the gallery JPEGs may
correspond to RAW files in the catalog — so the search has to match on the
stem only.
The frontend now passes `separator: 'comma'` + `include_extension: false` for
the TXT format specifically. The backend gains an `include_extension` option
(defaulting to true so direct API consumers don't break), and the comma case
joins without a trailing space (the form Lightroom expects). Unit test pins
the Lightroom-mode output AND the backward-compatible default for any direct
API caller.
CSV / XMP / JSON exports are unchanged.
Closes the test gaps from the PR #622 work + the export-scope feature:
- export scope: scopeLedger/normalizeScope (exported via _internal) unit tests +
renderTaxReportCsv income/cost/all output assertions (income drops supplier
rows, cost drops invoice rows, filename gets the scope tag).
- isUniqueViolation: Postgres 23505 / SQLITE_CONSTRAINT / "UNIQUE constraint
failed" message, false for FK + nullish (the IMAP claim-first race detector).
- getRenderedPagePath: out-of-range pages reject with PAGE_OUT_OF_RANGE before
touching pdftoppm/disk (the per-file resource bound).
1. requireFeatureFlag now caches each flag for 10s (the accounting area is 10+
gated endpoints); PUT /admin/feature-flags invalidates the cache so toggles
still take effect immediately.
2. Customer routes (/quotes, /invoices, /contracts + their PDFs) now gate via
getEffectiveFeaturesForCustomer — the global MASTER flag AND the per-customer
override — instead of the per-customer column alone, via a shared
customerFeatureAllowed() helper. Admin disabling a feature globally is now
honoured for customers too.
4. Tax-report VAT-payable: when accounting_vat_registered is UNSET, stop guessing
from grandTotalVat>0 (a zero-output-VAT quarter silently flipped to "not
registered" and hid the reclaim). Treat null as "not configured":
vatPayableMinor=null + vatRegistrationConfigured=false; the UI renders "—" and
a "configure VAT registration" warning. Tests updated.
5. Shared upsertAppSetting() in utils/appSettings — the two adminSettings upsert
loops use it, so the app_settings created_at class can't be re-introduced.
6. PDF rasterise per-file bound: getRenderedPagePath refuses pages beyond
MAX_RENDERABLE_PAGES (200); page_count is capped to match at ingest, so a
hostile high-page PDF can't drive an unbounded pager.
7. (no code) original_filename is only rendered via auto-escaped JSX; the two
dangerouslySetInnerHTML sites are admin-authored content — paranoia pass clean.
Concerns 3 (foreign-VAT reclaim-country) and 8 (imap_pass plaintext) are PR-reply
/ doc items, addressed in the PR response, not code.
The report's vatPayable is now: 0 when not VAT-registered; otherwise output VAT
minus the RECLAIMABLE input VAT only (costs with tax_treatment
foreign_vat_non_reclaimable are excluded from the deduction). Registration reads
accounting_vat_registered; when unset it falls back to a behaviour-preserving
heuristic (charged output VAT this period ⇒ registered), so existing reports are
unchanged and non-VAT installs correctly show 0. loadCosts now tracks
reclaimableVat. Tests updated; 32 pass.
Real Banana Income & Expense files name the category column 'Category', not
'ContraAccount' (which the doc listed but is a double-entry concept) — so the
income/expense account never landed and Banana warned 'ContraAccount column not
found'. Use 'Category'. VatCode stays (it only warns on a non-VAT-enabled file;
amounts are gross). Test updated.
The Date column imported empty into Banana because dateOnly() did
String(d).slice(0,10) — on Postgres the date columns come back as JS Date
objects, so that yields "Thu Jan 15" instead of "2026-01-15", which Banana
rejects. (SQLite returns strings, so the tests never caught it — the
pg-date-serialisation trap.)
- ledgerService.dateOnly + taxReportService CSV now format Date objects to
yyyy-mm-dd via local calendar parts (DATE columns are local-midnight).
- Regression test added with a real Date object (the existing tests all used
string dates).
The Banana export assumed a double-entry file; a user importing into an Income
& Expense (Einnahmen-Ausgaben) file got "AccountDebit/AccountCredit/Amount/
VatCode column not found", since those columns only exist in double-entry.
Add a second Banana format alongside the double-entry one:
- ledgerService: new `banana_ie` format → Banana I&E columns Date, Doc,
Description, Income, Expenses, ContraAccount (the income/expense account),
VatCode (banana.ch doc 9946). Revenue → gross in Income + revenue account;
cost → gross in Expenses + expense account. Same tab-separated .txt shape.
- Frontend: ExportFormat + dropdown gain `banana_ie`; .txt extension covers
both Banana variants. Labels relabelled: "Banana — double-entry" and
"Banana — income & expense" (de equivalents). Hint de-"double-entry"-fied.
- Test added for the I&E format.
Pairs with the prior UTF-8 BOM fix (the "·" mojibake). Tests + build green.
Banana's "Text file with column headers" import (Actions → Import into
accounting) requires a TAB-separated .txt with unquoted values — picpeak was
emitting a comma-separated, quoted .csv, which won't even show in Banana's
*.txt file picker, let alone parse into columns.
- ledgerService.exportPostings: the `banana` format now serialises TAB-separated
with no quoting, .txt extension, text/plain content-type. generic + bexio stay
comma-CSV (RFC 4180). Tab/newline chars in a cell are collapsed to spaces.
- Frontend ledger.service: download filename uses .txt for banana.
- Tests updated for the new banana shape (tab header, .txt, text/plain).
The column names already matched Banana's NameXml; only the serialisation was
wrong. bexio left as comma-CSV (verify against bexio's import spec separately).
The CSV rework (unified, typed ledger) replaced the 'Rechnung' column with
'Referenz' (+ a 'Typ' column) and dropped the separate cancelled 0/1 column in
favour of a localised '(Cancelled)' suffix on the Reference cell. Update the
two assertions in taxReportPdf.test.js accordingly. All 11 cases pass.
Einnahmen-Ausgaben view for the Milchbüchlein/simple-accounting case:
- taxReportService.getTaxReport now returns a cost side (loadCosts:
incoming invoices + internal expenses, company- or event-booked,
schema-guarded) plus a summary (income / costs / result, VAT payable)
- declined/duplicate costs excluded; re-billed costs kept (matching
re-bill revenue is counted, so the net is correct)
- CSV + PDF exports gain a Costs section and an income/costs/result
summary; pdf-i18n keys added for all 6 locales (fr/nl/pt/ru machine —
flag for native review)
- frontend tax page renders the summary card, a costs table (company
vs event), and a 'verify with Treuhänder' disclaimer
- tax-report tests cover the cost aggregation + zeroed summary when the
accounting tables are absent; adminCrmAuth test enables the accounting
master flag the route now requires
fr/nl/pt/ru strings are machine-generated and need native review.
Implements the split decided in review:
Incoming invoices (external) - the inbound_documents row IS the payable:
- categorizeInbound now UPDATES the document (disposition + tax_treatment +
booking event_id (null=company) + category), no derived expense row, so a
supplier invoice appears only in the incoming-invoices surface.
- rebillInbound mints the client invoice from the document (base = invoice
total + markup) and links it on the doc.
- markInboundSupplierPayment records supplier payment ON the incoming invoice
(mark-paid lives here now).
Expenses (internal) - own costs only:
- createExpense: kind = amount|mileage|per_diem; amount = quantity x rate
(rate from accounting settings, per-entry override; snapshotted); optional
proof file; booked to an event or the company; require-proof enforced from
settings. No supplier payment, always own-cost.
- listExpenses returns internal rows only (inbound_document_id IS NULL).
Routes: per-flag gating (incomingInvoices vs expenses; categories on the
accounting master); supplier-payment + re-bill moved under /inbound/:id/*;
POST/PATCH expenses accept a multipart proof upload; GET /:id/proof streams it
(PDF download-only, image inline). getAccountingSettings reads app_settings.
Verified: node -c, require-graph, 12 unit tests (markup + expense amount/build).
Frontend rework (service + the two UIs + settings tab + category i18n) follows.
Covers the silently-regressable money + classification bits of the re-bill
flow (the maintainer's "thin CRM test coverage" concern). Pure functions via a
new expenseService._internal export — no DB, no date-harness pitfalls:
- computeMarkupMinor: percent rounding, flat, none/null.
- resolveMarkup precedence: override > expense clause > none.
- buildExpenseInsert: bad-disposition guard, tax_treatment/status defaults,
declined -> status+reason, markup field matches type, parked -> status.
11 tests, all green (npx jest expenseService.markup).
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).
#574 follow-up — @blazmaric flagged that once an admin user is
deactivated, the UI loses every affordance to manage that record.
The deactivate button hides (rightly — they're already deactivated)
but nothing replaces it, leaving the row stranded in the list with
no path to either restore access or permanently remove it.
## Backend
New on `userManagementService`:
- **`activateAdminUser(id, activatedById)`** — symmetric to
`deactivateAdminUser`. Flips `is_active` back to true, logs
`admin_user_activated` activity. Idempotent: already-active target
short-circuits without bumping `updated_at`. No "can't activate
yourself" guard needed (actor is by definition already active).
- **`deleteAdminUser(id, deletedById)`** — hard-deletes the row.
Same self-action and last-super-admin guards as deactivate.
Last-super-admin guard counts ACTIVE super admins excluding the
target — so an already-deactivated super_admin can still be
deleted when an active super_admin remains. FK ON DELETE rules
in core migrations handle the cascade: SET NULL on
`created_by_admin_id` everywhere (events, photos, quotes,
invoices, contracts, customer_accounts, …); CASCADE on the
user's own `api_tokens` + their pending admin / customer
invitations.
New routes on `adminUsers.js`:
- `POST /api/admin/users/:id/activate` — `users.delete` permission
(same tier as deactivate; reverting deactivation is the same
scope of action as performing it).
- `DELETE /api/admin/users/:id` — `users.delete`.
## Frontend
`UserManagementPage.tsx`:
- New mutation hooks: `activateUserMutation`, `deleteUserMutation`.
- The row's action cell now branches on `user.isActive`: active
users see Edit + Deactivate (unchanged); deactivated users see
Edit + Reactivate (`UserCheck` icon, green hover) + Delete
(`Trash2` icon, red hover).
- The shared `ConfirmDialog` handles all four action types
(deactivate / activate / delete / cancelInvitation) via per-type
title / message / confirmText / variant lookup.
`userManagement.service.ts`:
- New `activateUser(id)` and `deleteUser(id)` methods mirroring the
existing `deactivateUser` shape.
i18n keys are added with English fallbacks via `t(key, fallback)`
so the page works on every locale without a missing-translation
warning. Native translations can be filled in via a follow-up.
## Test plan
- [x] 8 new service tests pin: activate happy-path, idempotency on
already-active, NotFoundError on missing target, activity log
emitted, delete self-refusal, last-super-admin guard for both
active and already-deactivated super_admin targets, hard-delete
success, delete activity log.
- [x] Frontend type-check clean.
- [x] Frontend lint clean for the changed files.
- [x] Backend lint clean.
- [ ] Manual: deactivate a user → row now shows Reactivate + Delete
→ reactivate → user can log in again. Then deactivate again →
delete → row vanishes, pending tokens for that user invalidated.
Closes the UX gap blazmaric called out in
https://github.com/the-luap/picpeak/pull/579#issuecomment-... .
CI's SQLite returned `[N]` (plain int) from `.insert().returning('id')`
while local SQLite returned `[{ id: N }]` (object form). The brittle
`const [{ id }] = ...` destructure crashed on the int shape. Switched
to the unwrap pattern used by the existing crmDb test harness so the
suite runs on both PG and every SQLite/knex combo the project supports.
Diagnostic for the bug fixed in a9280ea — confirms every *_path
column on quotes / contracts / invoices points at a file that
actually exists on disk and (where a *_sha256 column is set) the
file's bytes still hash to the expected value. Read-only;
on-demand only; no scheduler.
Per the design decisions locked in this PR's design call:
D1 — on-demand only for v1; scheduling deferred until we have
runtime data on large installs
D2 — not auto-triggered after restore; surface a "verify
integrity now" CTA on the restore-completed screen instead
D3 — wet-upload contracts hash-verified same as system-rendered
(signed_pdf_sha256 is computed at upload time, no special
case needed in the verifier)
Coverage (single source of truth in backupIntegrityService.CHECKS):
quotes.pdf_path existence
contracts.pdf_path + pdf_sha256 existence + hash
contracts.signed_pdf_path + signed_pdf_sha256 existence + hash
contracts.signed_customer_signature_path existence (PNG/JPG, no hash)
contracts.signed_admin_signature_path existence (PNG/JPG, no hash)
invoices.pdf_path existence
invoices.imported_pdf_path existence (admin-uploaded scans)
Report shape buckets each row into verifiedOk / missing /
hashMismatches / existsButNoHash so callers can distinguish hash-
verified from existence-only — the latter is weaker evidence in
a legal dispute and the UI should reflect that.
Route GET /api/admin/system-health/backup-integrity accepts an
optional ?scope= CSV filter (quote | contract | contract-signature
| invoice). Unknown scope tokens are rejected with a 400 +
BACKUP_INTEGRITY_UNKNOWN_SCOPE code rather than silently scanning
everything.
Frontend half (BackupIntegrityCard on a System Health page) is
deferred until backlog #11 (System Health page) is scaffolded.
The endpoint is independently useful via curl in the meantime.
Closes#567.
The sidebar already had a "vX.Y.Z available" indicator (#566 made it a
link to that release's page) but there was no way to read the actual
changelog inline or to grab a copy-paste upgrade command. This adds
the modal the issue spec'd, layered on top of the existing
updateCheckService / environmentService backend infrastructure that
already shipped.
## Backend
- `updateCheckService.fetchAvailableVersions` now returns full release
objects (tag, name, body, publishedAt, htmlUrl) instead of just
version strings — body data is what the changelog modal renders.
`checkForUpdates` extracts the version strings for its existing
consumers; no API change visible to callers.
- New `getReleasesSince(currentVersion, channel)` returns the list of
releases strictly newer than current, filtered to the user's
channel. Reuses the same 1-hour cache as `checkForUpdates` so the
modal opening doesn't trigger an extra GitHub round-trip.
- New `GET /admin/system/updates/changelog` route in `adminSystem.js`,
same auth + UPDATE_CHECK_ENABLED gating as the existing
/updates and /updates/instructions endpoints.
- 4 unit tests (axios mocked) pin: strictly-newer filtering,
channel-scoped, empty array on GitHub fetch failure, empty array
when already on latest.
## Frontend
- New `UpdateAvailableModal.tsx` — opens from the sidebar chip. Two
sections:
1. **How to upgrade** — fetches /updates/instructions for the
environment-detected copy-paste command (Docker compose / git /
standalone). Copy-to-clipboard button per step.
2. **Release notes** — fetches /updates/changelog for every
version between current and latest in the user's channel.
Latest is auto-expanded; older releases are collapsed by
default (click to expand). Each release also has a "View on
GitHub" link to the canonical release page.
- Renders release body markdown through the existing safe
MarkdownContent component (marked + DOMPurify allowlist).
- New `updateDismissal.ts` helper — single localStorage key holds the
last-dismissed version. Chip stays hidden until a STRICTLY newer
version appears, using the same compare semantics as the backend
(stable > beta, higher beta > lower beta, semantic numeric on
major.minor.patch). 9 unit tests pin the rules.
- `VersionInfo.tsx` — chip is now a button that opens the modal
instead of an external link (the #566 link-to-release behaviour is
preserved on the modal's per-release "View on GitHub" affordance).
Dismissal triggers an immediate re-render so the chip disappears
without waiting for the next route change.
No new dependencies — uses `marked` + `DOMPurify` that were already
present in the bundle for the contract block renderer.
Wires customer_accounts.billing_email into the invoice, Storno, and
payment-reminder send paths. Previously the column existed on the
schema and the customer-detail page rendered an input for it, but no
send path read it — every outbound email landed on customer_accounts.email
regardless. That mismatch is the failure mode flagged in
feedback_data_driven_completeness: a UI field that promises behavior
the backend silently doesn't deliver.
Routing matrix:
- invoice / Storno / payment reminder
To: billing_email (fallback email when unset)
CC: email (when billing_email took the To slot) + per-doc cc_pdf_email
- quote / contract / event reminder / gallery share
To: email (unchanged — decision-maker address)
- payment-check / paid-notification
To: admin contact (unchanged — internal flow)
A new resolveBillingRecipients helper centralises the rules:
prefer billing_email, dedupe addresses case-insensitively, keep
per-doc cc_pdf_email as a supplemental CC. Lives in its own file
(_billingRecipients.js) to match the _renderContext.js convention.
New Settings → General toggle `Use original filenames on download` (off by
default). When on, single-photo downloads, bulk/selection zips, and per-event
archive zips surface `photos.original_filename` instead of the sanitized
storage filename. Storage paths are unchanged.
- Content-Disposition uses RFC 5987 (`filename=` ASCII + `filename*=UTF-8''…`)
so unicode camera filenames survive while header-injection bytes are stripped.
- Zip entries are deduplicated with a deterministic `_1` / `_2` suffix on
collision (folder structure preserved in archive zips).
- Pre-generated download-all zips and the in-memory setting cache are
invalidated when the toggle flips so the next download rebuilds with the
new names.
- Falls back to the storage filename whenever `original_filename` is null
(legacy uploads predating migration 062).
Two issues in the fonts service test suite added by #390 — the behaviour
assertions all passed, but 5 of 24 tests had assertions that silently
no-op'd, so any regression in those code paths would not have been
caught.
## Issue 1: jest.resetModules() bypassed the logger mock
`beforeEach` called `jest.resetModules()` then re-required `fontsService`.
After resetModules, the `jest.mock('../../src/utils/logger', ...)` factory
at the top of the file no longer applied to subsequent requires — so the
freshly-required `fontsService` captured the REAL logger while the test
file's `logger` variable still pointed at the mocked one. The 4
"warning logged" / "info logged" assertions resolved as 0 calls and
silently passed-as-noop.
The resetModules call wasn't necessary in the first place — module-level
state in fontsService is just the cache, which clearFontsCache() already
resets. And both getBundledFontsRoot() and getUserFontsRoot() read
process.env at call-time, not at module load, so the env vars set in
beforeEach are picked up without needing a fresh require.
Fix: require fontsService once at module top (inside the jest.mock
hoisting scope) and drop resetModules + the per-test re-require.
## Issue 2: case-insensitive filesystem (macOS / Windows)
The "case-insensitive duplicate within the same root" test created
`Inter/` and `INTER/` to trigger the dedup warning. On a case-sensitive
FS (Linux ext4) both directory entries exist and the dedup branch fires;
on macOS APFS or Windows NTFS the second mkdir resolves to the same
folder as the first, so only one ever exists and the dedup is
unreachable from this test setup. Test failed on macOS dev, passed on
Linux CI.
Fix: probe at load time by creating a lowercase file and checking if
its uppercase variant resolves to the same inode, then conditionally
test.skip the affected test on case-insensitive hosts. Comment in the
test body explains why.
## Result
23 of 24 tests now pass on macOS; the case-sensitive-only test runs on
Linux CI. All previously-no-op'd assertions now exercise their code
paths.
Bundle of email-renderer and email-caller fixes triggered by a
reproducer on picpeak.nothaft.cloud (gallery_created mail showing
literal `{{#if welcome_message}}` markers and `Passwort: (set at
creation)`). The audit that followed surfaced six more user-visible
defects in the same surface; all are fixed here so customer-facing
mail renders cleanly.
Renderer (`backend/src/services/emailProcessor.js`)
- `safeTemplateReplace` now resolves `{{#if VAR}}…{{/if}}` blocks
before flat `{{var}}` substitution. The shipped templates have used
Handlebars-style conditionals since migration 026; the renderer
ignored them, so the markers leaked verbatim into every mail with
an empty welcome_message. Lifted to module scope and exported so
the conditional contract is unit-testable. Single-pass, non-nested
(commented).
- Added `passwordSetAtCreationI18n` next to the existing two i18n
password sentinels so `(set at creation)` (sent by the publish-
from-draft flow when only the bcrypt hash remains) is localised
to "Das bei der Erstellung der Galerie gesetzte Passwort" /
equivalent in EN/DE/NL/PT/RU instead of the raw English string.
- Added an opt-in `{ escapeHtml: true }` mode to `safeTemplateReplace`
so admin-supplied free text (`event_name`, `host_name`, …) is
HTML-escaped on substitution into the HTML body. Allowlist of
passthrough keys (`welcome_message` already-HTML, server-generated
URLs `gallery_link` / `client_link`). Subject and text body keep
the legacy unescaped behaviour. `formatWelcomeMessage` now escapes
before nl2br so the welcome_message allowlist is safe.
- New `htmlToText()` strips `<style>` and `<script>` blocks (and
their content) before tag-stripping, decodes common entities, and
collapses whitespace. Used by the textBody fallback in
`sendTemplateEmail` — without this, every template missing a
`body_text` produced a "plain-text" mail starting with the 100+
lines of CSS embedded by `wrapEmailHtml()`.
- The client-access section (#172) now mirrors its HTML block into
`textBody` using the same per-language strings, so plain-text
recipients see the link / PIN / warning. `pinLabel = 'PIN'` moved
into `clientAccessI18n` (RU uses ПИН-код).
- Added `getSupportEmail()` exported helper that reads
`branding_support_email` from `app_settings` (JSON-decoded), with
the SMTP from-address as fallback. Used by the gallery_expired and
archive_complete callers below.
- Removed dead `require('handlebars')` (unused since the regex
renderer landed; pre-existing lint error in this file).
Callers (data the templates already reference)
- `expirationChecker.js queueExpirationWarning`: send `expiry_date`
(templates use this, the old code sent `expiration_date` —
typo'd key, never read), drop the hard-coded `.de`/`en` sniff
(the processor formats with the recipient's resolved language),
add the `{{password_security_message}}` sentinel for
`gallery_password` (plaintext is gone by warning time, so
customers used to see literal `{{gallery_password}}` in the mail).
- `expirationChecker.js handleExpiredEvent`: both queueEmail calls
now supply `host_name`, `event_date`, `expiry_date`,
`support_email` so the EN/DE/NL/PT/RU `gallery_expired` template
doesn't render literal `{{host_name}}, your gallery expired on
{{expiry_date}}`. Skip the duplicate admin send when
admin_email == customer_email.
- `archiveService.js`: `archive_complete` queue now supplies
`host_name`, `photo_count` (from `photoEntries.length`),
`archive_date`, `support_email` — the previous payload had only
`event_name` and `archive_size`, so most of the mail was
unfilled placeholders.
Tests
- `__tests__/services/emailProcessor.safeTemplateReplace.test.js`:
16 cases — flat substitution, conditional truthy/falsy/missing/
multi-line/sibling/numeric-0, plus 5 cases for the new
`escapeHtml` option (default off, escape on, allowlist
passthrough for welcome_message and gallery_link).
- `__tests__/services/emailProcessor.htmlToText.test.js`: 7 cases —
the regression scenario (full wrapped body with embedded `<style>`
block), tag-stripping, entity decoding, paragraph spacing.
- `__tests__/utils/formatters.test.js`: 12 cases for `escapeHtml`,
`nl2br`, and the now-escaping `formatWelcomeMessage`.
35 cases total, all green. Lint clean on every touched file
(also fixes a pre-existing `no-prototype-builtins` warning in the
process). Pre-existing failures in
`__tests__/services/backupService.enhanced.test.js` are unrelated
and pre-date this branch.
Cover the two new pieces of the async pipeline:
backgroundProcessor.claimNextPhoto
- returns null when no pending rows
- returns the row + flips status under postgres FOR UPDATE SKIP LOCKED
- returns null when SQLite UPDATE-with-guard loses the race
- returns the row when the SQLite guard wins
photoProcessor.processPhoto
- happy path: writes thumbnail / dimensions / EXIF capture date and
marks 'complete'; fires watermark queue + photo.uploaded webhook
with the right payload
- video path: writes ffmpeg duration / codec / dimensions; does NOT
queue watermark (image-only)
- throws cleanly when the photo row no longer exists
Mocks db / imageProcessor / videoProcessor / storage / sharp /
watermarkGeneratorService / webhookService / logger so the tests run
without a real DB or any image library calls — fast and deterministic.
- Add S3/MinIO storage adapter with multipart upload support
- Implement database backup service for SQLite and PostgreSQL
- Create backup manifest generator for tracking backup contents
- Enhance backup service with S3 integration and incremental backups
- Add restore service with safety measures and rollback capability
- Create comprehensive test suite for all backup functionality
- Add admin API endpoints for backup/restore management
- Implement frontend UI with dashboard, configuration, and restore wizard
- Add roadmap section to README with implemented backup feature
This implementation provides:
- Multiple backup destinations (local, rsync, S3/MinIO)
- Intelligent change detection to minimize backup frequency
- Full database backups with compression
- Manifest-based restore with integrity validation
- Pre-restore safety backups with rollback
- Comprehensive error handling and monitoring
- User-friendly admin interface
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>