Commit Graph

673 Commits

Author SHA1 Message Date
Luca 0175007abc feat(projects): gated project pickers on quote/contract/hours editors
- ProjectSelect: a reusable picker that renders nothing when the projects
  flag is off (satisfies 'book to project hidden unless projects enabled').
- projects.service.ts: full frontend API client (list/get/create/update,
  overview, assign event/quote/contract, email preview + 4 actions).
- Quote + contract editors carry an optional projectId (state, prefill,
  payload); service payload/detail types updated.
- HoursSection gains a 'book to project' control; backend createEntry
  persists project_id (migration 118, hasColumnCached guarded).
2026-06-06 13:20:59 +02:00
Luca 6420047e7c feat(projects): link quotes & contracts to a project (precise cockpit rollup)
- Migration 121 adds quotes.project_id + contracts.project_id (nullable FK,
  index) and backfills the unambiguous single-project-per-customer case.
- projectService rolls quotes/contracts up by project_id, with a
  customer-based fallback on pre-121 DBs (hasColumnCached guarded).
- quote/contract create+update accept an optional projectId; detail
  transforms surface it for editor prefill.
- POST /projects/:id/quotes and /:id/contracts assign endpoints.
2026-06-06 13:05:36 +02:00
Luca 1bf0b34ea5 feat(projects): gate Project Overview behind a projects feature flag + cockpit email actions
- Migration 120 seeds the projects flag (default OFF), idempotent.
- Backend feature-flags whitelist + DEFAULT_FLAGS + clients derivation.
- adminProjects routes 403 PROJECTS_DISABLED when the flag is off.
- projectService email actions (resend/cancel/retry/send-now) + routes.
- Frontend flag type, DEFAULT_FLAGS, Features tab card (en+de).
2026-06-06 12:59:57 +02:00
Luca 874c91f944 feat(crm): Project Overview phase 3 — persist sent email HTML
processEmailQueue now stores the actual rendered HTML in email_queue
.rendered_html on a successful send (sendTemplateEmail returns it). Guarded
by hasColumnCached so installs without migration 119 just skip it; never
blocks the send. Powers the cockpit's exact-sent email preview.
2026-06-06 04:00:16 +02:00
Luca eb263137b9 feat(crm): Project Overview phase 2 — project service + routes
Backend API for the cockpit (admin-only, Model A):
- projectService: list/get/create/update, assignEvent (re-point events.project_id),
  getProjectOverview (rollup — invoices/emails/gallery by event, quotes/contracts
  by customer since they carry no event_id, hours by project_id, + a milestone
  timeline), getEmailPreview (actual sent HTML).
- adminProjects routes (/api/admin/projects): read=events.view, write=events.manage;
  the overview gates each money-doc type on the admin's own bills/quotes/contracts
  .view permission. Registered in server.js.
All aggregation queries verified against the real schema on a temp DB.
2026-06-06 03:50:55 +02:00
Luca efa47d697d feat(crm): Project Overview phase 1 — projects schema
Data model for the admin-only Project Overview cockpit (Model A — projects
group events; money docs stay per-event and roll up).
- migration 117: projects table (name, customer_account_id nullable, status)
  + events.project_id FK; backfill one auto-project per existing event (1:1
  default, customer = the event's single assignment when unambiguous), admins
  relink freely afterward. 1 project : N events.
- migration 118: customer_hour_entries.project_id (book hours to a project).
- migration 119: email_queue.rendered_html (store actual sent HTML for the
  cockpit's email preview).
All idempotent (hasTable/hasColumn guards), reversible downs. Verified: full
migration boot + backfill on a temp DB.
2026-06-06 03:06:21 +02:00
Luca a2b2d3fb31 fix(crm): PR #603 review follow-ups + Outlook-proof email design
Addresses the maintainer's non-blocking review items + the Outlook email bug:
- invoice create: verify the chosen event belongs to the customer (only when
  the event has assignments; legacy unassigned events pass through).
- mark-paid + import: bound paidAt to [2000-01-01, now+30d] so a typo'd year
  can't silently drop a payment out of every cash-basis revenue window.
- customer routes: country_code now {min:2,max:2}+isAlpha+uppercase-normalize
  (was isString/max:2 — allowed '', '1', '!@'), matching the business-profile
  route.
- email transporter: close the previous instance before re-init (leak guard
  for a future pooled transport).
- scheduled-email tz: warn loudly when business_hours is set but the profile
  timezone is blank (was silently using the server/UTC tz).
- wrapEmailHtml: rebuild the chrome as inline-styled tables + bgcolor and
  inline the themed CTA button, so the design survives Outlook/Apple Mail
  stripping the head <style> (kept the <style> as progressive enhancement).
2026-06-06 00:42:08 +02:00
github-actions[bot] 13b3a4cfa1 chore(beta): release 3.60.3-beta.0 2026-06-04 20:09:37 +00:00
Paul Nothaft ea074d3102 Merge pull request #603 from Luca-Timo/feat/crm-improvements
CRM improvements: invoicing & payments, hours, email queue/scheduling, branding (dark mode + favicon), country pickers
2026-06-04 22:09:08 +02:00
Luca 1214b6b762 fix(security): re-apply SVG CSP on the direct favicon route (PR #603 blocker)
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.
2026-06-04 21:49:27 +02:00
github-actions[bot] 6ee30a357d chore(beta): release 3.60.2-beta.0 2026-06-04 19:40:36 +00:00
Luca 68c967f9bb fix(email): recover stuck queue — reinit transporter on config save + manual flush ignores retry cap
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.
2026-06-03 19:40:36 +02:00
Luca 7ccfdc1aea fix(branding): stream favicon bytes directly (Safari ignores the 302)
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.
2026-06-03 18:35:46 +02:00
Luca 82ec23824a feat(crm): cash-basis revenue + backdatable payment date on mark-paid
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').
2026-06-03 18:20:55 +02:00
Luca db3e3270f3 fix(branding): serve favicon via backend route so Safari picks it up
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.
2026-06-03 17:35:32 +02:00
Luca 5e79c69cda fix(crm): recognise imported-invoice revenue on issue_date, not paid_at
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.
2026-06-03 17:02:50 +02:00
Luca 05c1e8d18b fix(branding): theme-aware logo on customer-facing public pages
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.
2026-06-03 16:48:54 +02:00
Luca a5011b1ea2 fix(pdf): version the logo rasterisation cache so the font fix takes effect
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.
2026-06-03 16:34:38 +02:00
Luca 12591556a0 fix(branding): accept SVG favicons
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.
2026-06-03 16:34:38 +02:00
Luca 9454f43eba fix(pdf): install fonts so SVG logo text rasterises correctly
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.
2026-06-03 16:07:29 +02:00
Luca 11257a7936 fix(branding): allow larger square favicons
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.
2026-06-03 16:01:07 +02:00
Luca 807ae3d4fa fix(security): block script execution in served SVGs via CSP
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.
2026-06-03 14:37:40 +02:00
Luca 5d7b545bf7 feat(admin): System health page surfacing stuck/failed emails
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.
2026-06-03 13:56:08 +02:00
Luca 4790ea5ccd feat(branding): dark-mode logo variant
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.
2026-06-03 13:26:56 +02:00
Luca a03146edc9 feat(events): "Create invoice" action on the event detail page
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.
2026-06-03 00:07:00 +02:00
Luca b378ad679f feat(email): hold relationship mail to business 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.
2026-06-02 19:44:04 +02:00
Luca 626ab45e0b refactor(time): app-wide setting-aware TimeField for all time inputs
Add shared components/common/TimeField (displays per general_time_format,
stores canonical HH:MM, parses tolerant free-text, browser-independent)
and migrate every native <input type="time"> to it: business hours,
HoursSection, HourEntryInlinePopover, CreateEventPage, Quote/Bill/Contract
editors. Removes the unreliable lang-hint plumbing.
2026-06-02 17:24:27 +02:00
Luca 333379b321 Merge branch 'beta' of https://github.com/the-luap/picpeak into feat/crm-improvements 2026-06-02 14:17:46 +02:00
Luca 603ba1e504 feat(email): read-only "Sent emails" tab over email_queue
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.
2026-06-02 13:58:54 +02:00
Luca 8d14441df4 feat(quotes): admin decline-on-behalf with optional reason
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.
2026-06-02 13:47:31 +02:00
Luca 621ce942b5 feat(email): per-weekday business hours + manual queue flush
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.
2026-06-02 13:00:13 +02:00
Luca 93956db0ca feat(hours): aggregate open-hours landing view on /admin/clients/hours
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).
2026-06-02 11:43:42 +02:00
Luca ab6bad17c9 feat(hours): install-wide default rate + inline missing-rate CTA
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).
2026-06-02 11:33:45 +02:00
Luca d9251c0850 feat(crm): Finder-style sortable column headers, default sort by issue date
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.
2026-06-02 10:59:36 +02:00
Luca db7d11dc7f feat(bills): capture event name/date when importing historical invoices
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.
2026-06-02 09:50:51 +02:00
Luca 06b4522dec feat(billing): manual cadence + fix admin date inputs ignoring date-format setting
Manual billing cadence
  Adds a "Manual (trigger only)" cadence alongside monthly/quarterly. It reuses
  the monthly draft accumulator — invoices and billed hours pile onto one running
  draft — but stores NULL monthly_period_start/end so the scheduler's auto-flush
  never matches. The draft ships only when an admin clicks "Trigger invoice now".
  No migration: billing_cadence is a free-form string column gated by validators.
  - adminCustomers.js: allow 'manual' in billing_cadence validator
  - invoiceService.js: route manual through accumulator; NULL periods + placeholder
    issue/due date in getOrCreateMonthlyDraft
  - customerHoursService.js: manual auto-appends hours to running draft;
    billUnbilledEntries refuses manual (CADENCE_MISMATCH)
  - CustomerDetailPage.tsx: dropdown option, cycle-day hidden for manual,
    NULL-period-safe draft preview + trigger button, manual-specific copy
  - customerAdmin.service.ts: cadence union + nullable periodStart/periodEnd
  - en.json / de.json: manual, triggerConfirmManual, triggerHintManual,
    draftPreview.titleManual
Date-format fixes
  Replace raw <input type="date"> (browser-locale) with LocalizedDateInput so
  these admin surfaces honor the general_date_format setting:
  - ContractEditorPage.tsx (issue / valid-until / event dates)
  - QuoteEditorPage.tsx (event / valid-until dates)
  - EventDetailsPage.tsx (expiry date)
  - HoursSection.tsx (entry date)
2026-06-02 09:27:10 +02:00
github-actions[bot] 3361a8bb51 chore(beta): release 3.60.1-beta.0 2026-06-02 07:17:10 +00:00
Paul Nothaft 940fc60740 Merge pull request #598 from the-luap/fix/notifications-clear-all-597
fix(notifications): restore /clear-all route the frontend already calls (#597)
2026-06-02 09:16:47 +02:00
github-actions[bot] 987911a03b chore(beta): release 3.60.0-beta.0 2026-06-02 07:11:30 +00:00
Paul Nothaft 9d424d0dbb Merge pull request #596 from Luca-Timo/bugfix/crm-backup
Backup & Restore hardening — close the silent files-only data-loss class
2026-06-02 09:10:57 +02:00
Luca d788a6cde5 feat(crm): per-customer Skonto opt-out
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).
2026-06-02 09:00:05 +02:00
Luca 45c7cc80ab fix(invoices): anchor issue date + Skonto window to the actual send date
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.
2026-06-02 08:54:23 +02:00
Luca 522cf2aa4a fix(invoices): auto-track due date from send date + payment term
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.
2026-06-02 01:52:45 +02:00
Luca c6b8cc9199 fix(crm): anchor imported invoice dates to issue_date, not import time
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.
2026-06-02 01:36:52 +02:00
Luca db2c482ae9 feat(crm): country dropdown + name guard for customer create/edit
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.
2026-06-02 01:28:00 +02:00
Luca 7988c18972 fix(restore): set was_successful=true on the completed update
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.
2026-06-01 22:56:02 +02:00
Luca 20e3092c14 fix(restore): move operator-meta replay after post-restore verification (PR #596 round 3)
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.
2026-06-01 22:44:51 +02:00
Luca 354fbed182 fix(restore): coerce pg bigint counts to Number before comparing (PR #596 round 2)
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.
2026-06-01 22:23:26 +02:00
Luca 3322a1d998 feat(restore): docker-logs visibility + ADMIN_CREDENTIALS.txt restore notice
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.
2026-06-01 21:52:38 +02:00
Luca a23fa3bb12 fix(restore): hoist preservedMeta above SQLite/PG split (PR #596 blocker)
`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.
2026-06-01 21:51:49 +02:00