Commit Graph

2177 Commits

Author SHA1 Message Date
Paul Nothaft be39929476 fix(accounting): allow creating a customer from the picker
With Accounting on and CRM/customerPortal off, the picker renders but there
was still no way to create the first customer: /admin/clients/accounts and
every CRM editor with inline-create are feature-gated, and the picker's
empty-state hint pointed at that unreachable page.

Reuses the existing InlineCustomerCreate that CustomerPicker already mounts
for the CRM editors. The affordance is gated on customers.create, matching the
backend, where POST /admin/customers is permission-gated and not flag-gated.
mode is 'passive' when customerPortal is off -- a portal invitation would
email a link to a login that does not exist -- and 'both' when it is on. On
success the customer is appended to the selection, which is what the
accounting call sites' next.slice(-1) already expects.

The noResults hint pointing at the hidden page is replaced by two keys: one
naming the button, one for admins without the permission.

Refs testplan REPORT.md B12.
2026-09-02 09:43:10 +02:00
Paul Nothaft 34685505be fix(analytics): serve self-hosted trackers same-origin so CSP stops blocking
A self-hosted Umami/Rybbit domain configured in Settings could never load: the
CSP script-src allowlist is static, and the earlier pass could only add an
admin-visible warning because nginx.conf:58 strips helmet's header and
location / serves the SPA document off disk via try_files -- so helmet can
never govern it in Docker. Verified by reading the config, not inferred; that
kills the "make helmet dynamic" option outright.

Rather than templating the CSP, the tracker is now same-origin. The script and
every endpoint it talks to are served from /api/analytics/tracker/* and
proxied server-side to the configured instance, so script-src 'self' and
connect-src 'self' already cover it. The CSP is unchanged: nothing to
template, no env var, no restart -- it takes effect when Settings is saved.
That also closes A3 structurally rather than by widening a directive.

Endpoint mapping taken from vendor sources, not guessed: Umami's
host || currentScript.src + /api/send, and Rybbit's documented
/track, /site/tracking-config/<id>, /site/<id>/feature-flags/evaluate.
data-host-url is set explicitly so a COLLECT_API_HOST-built Umami cannot
bypass the proxy. Session replay is deliberately NOT proxied: replaying
gallery pages would capture the share token (GHSA-7m6c).

nginx still needed one line, for a non-obvious reason: the static-asset regex
location outranks the plain /api prefix in nginx's matching order, so
/api/analytics/tracker/script.js resolved as a static file. Confirmed
empirically against a real nginx:alpine -- 404 before the ^~ block, 502
(proxied) after, with /assets/app.js and /api/public/settings unchanged.
The native SERVE_FRONTEND install needed no change; helmet already has 'self'
in both directives and the proxy mounts ahead of express.static.

Security boundary, since this makes the server fetch an admin-supplied URL:
closed per-provider path+method allowlist (4 paths), DNS-resolving
isHostAllowed blocking private/internal/metadata addresses in production
(matching the s3Storage prod-only precedent), base rebuilt as
origin + pathname so userinfo/query/fragment cannot smuggle anything,
redirect: 'error', cookie/authorization/referer/host never forwarded, an
HTML upstream response re-served as application/octet-stream + nosniff, and
64KB request / 2MB response / 5s timeout / 120rpm caps. X-Forwarded-For and
User-Agent are forwarded so geo and device attribution survive.
Residual, stated plainly: an unauthenticated rate-limited relay to one
admin-chosen public host on 4 paths, and TOCTOU DNS rebinding is unmitigated
as it is elsewhere in the repo.

The Umami and Rybbit panels now explain they are proxied; the Custom panel
keeps a CSP warning -- it is the one mode with nothing to proxy -- naming both
script-src and connect-src.

Refs testplan REPORT.md A2, A3.
2026-09-02 09:43:10 +02:00
Paul Nothaft fe5ac9162d fix(photos): emit visibility and processing_status from the list mapper
The "hidden photo has no indicator on the admin grid" warning was not a
missing badge. The badge markup has existed since #172; the defect was in
GET /:eventId/photos, which hand-builds its response literal field by field
and never emitted `visibility` -- so the value was always undefined and
neither the grid tile nor the list row badge could render. Same omission class
as the view_count/download_count bug already commented in that file.

(The `visibility` line itself was swept into 4721bd83, whose message does not
mention it -- recording that here.)

Fixes the adjacent instance too: `processing_status` is missing from the same
mapper, so the grid's "Processing…" and "Failed"/Retry placeholders could
never render either.

On the card, reuses the existing EyeOff badge pattern from the list-view rows,
adds a tooltip on both layouts, and drops the category badge to top-9 so the
hidden badge can own the top-left corner.

Also fixes the Photo Limit spinbutton's aria-valuemax, which read 0 even with
a real cap set. Root cause: min={0} with no max -- for input[type=number]
Blink's MaxValueForRange returns DBL_MAX, fails isfinite and supplies no max,
so a11y tooling prints the default 0. Set to 2147483647, the events.photo_cap
column's real signed-32-bit ceiling (migration 074), which also stops an
out-of-range value failing only at INSERT. The sibling expires_in_days input
already had proper bounds.

Known: EventInformationCard carries the identical Photo Limit input with the
same defect; it is held by another concurrent change and follows next.

Refs testplan REPORT.md, hidden-photo and aria-valuemax warnings.
2026-09-02 09:43:10 +02:00
Paul Nothaft 3acb452090 fix(settings): remove the duplicated section heading on 11 tabs
A generic shell heading stacked on top of each tab component's own internal
heading. The report named five tabs "at least"; auditing all 28 found 11:
downloads, sso, apiTokens, webhooks, businessProfile, crm, accounting,
whatsapp, slideshow, moderation, styling. On the first eight the two headings
resolve to the identical string -- sso and businessProfile literally render
the same key twice. The other three were near-identical stacked titles
("Moderation"/"Word Filters", "Custom CSS"/"Custom CSS Templates",
"CRM behaviour"/"CRM settings").

Clean, and left alone: general, events, categories, thumbnails, security, seo,
imageSecurity, status, analytics (its first heading is a genuine sub-section),
plus the eight already in TABS_WITH_OWN_HEADER.

Removed the component side and kept the shell heading: the shell heading is
the consistent one (icon + label + divider on ~20 tabs) and always matches the
nav item the admin clicked, and none of these components are mounted outside
SettingsPage, so nothing loses a title. Subtitles and intro copy preserved
throughout; orphaned icon imports removed.

The guard test was checked against the pre-fix blobs and does fail on them.

Refs testplan REPORT.md, "duplicate H2 section heading" warning.
2026-09-02 09:43:10 +02:00
Paul Nothaft 22cada9082 fix(ui): drop themed text colours from the last three admin surfaces
SystemHealthPage, CrmOverviewSection and HoursSection used text-theme
(color: var(--color-text)) explicitly, so on a dark-toned branding theme they
render near-invisible on the light admin background -- and because the class
is explicit it beats the AdminLayout default that protects everything else.

Converted to the neutral scale using the convention from da9ceb14. Swept the
whole of each file rather than the cited lines: 3 in SystemHealthPage, 5 in
CrmOverviewSection (two of them h3 elements with no colour class at all), 17
in HoursSection. All three are admin-only -- call sites verified as
AdminDashboard, CustomerDetailPage, HoursLoggingPage and /admin/system-health
-- so no customer-portal or public-token surface is affected, where these
utilities are correct by design. Zero themed utilities remain in the three
files.

Refs testplan REPORT.md B14.
2026-09-02 09:43:10 +02:00
Paul Nothaft bd44708a03 fix(contracts): widen the block-library list column
Block names ellipsized to ~4-6 characters ("Vertr...", "Bildr..."). The
tooltip added earlier made them recoverable but the list still was not
scannable.

The file's "intentionally mirrors EmailConfigPage's Templates tab" comment was
the reason the ratio was left alone. Re-evaluated: both pages render in the
same Settings shell so the ratio is shared, but the content is not. A block
tile spends a fixed ~105px of its row on the "System" badge plus the n/6 pill,
and block names are long German noun phrases; EmailConfigPage's tiles carry
one badge and short display names ("Gallery Created"). So the shared ratio is
not simply wrong -- it is wrong here. Diverged only here: lg:grid-cols-3 /
col-span-2 becomes lg:grid-cols-5 with a 2/3 split (40/60). Mobile stack
untouched, and the comment now names the divergence and why.

Refs testplan REPORT.md A5.
2026-09-02 09:43:10 +02:00
Paul Nothaft a28f96b304 fix(gallery): no-store private JSON, and give guest uploads a real status
B6 -- seven gallery routes returned private, per-guest data with no
Cache-Control at all, relying on heuristic freshness. noStoreCache is mounted
per route rather than on the router, because the media routes set their own
private, max-age=1800/3600 and must keep it. Covered: /photos (own
likes/favourites/ratings, hidden photos for a client token), /people, /stats,
/verify-token/:token (an authorization decision -- a cached {valid:true}
outlives a rotated token), /show/:token/session (the response IS a credential;
it mints a gallery JWT), /show/:token/state and /download-jobs/:token (live
polls, where a cached "preparing" strands the caller). Deliberately untouched:
the photo/thumbnail/hero/preview and css-template routes, which set their own
caching, the binary downloads, and /info + /resolve, which are unauthenticated
public metadata rather than per-guest private.

ETag/304 revalidation is intact and pinned by a test: no-store stops the
browser retaining the body, not express agreeing an unchanged payload is
unchanged. That matters because the post-upload poll depends on it.

B7 -- the guest upload flow had no progress signal, so the UI polled the photo
list blind and gave up after 60s with no explanation. Adds
GET /:slug/uploads/status?ids=... rather than pending counts in the photos
payload: counts there are event-wide, so another guest's or the admin's stuck
upload would spin the notice forever and it could never say "your photo
failed".

Authorization: verifyGalleryAccess already resolves req.event from the
caller's token, and the query is scoped `.where('event_id', req.event.id)`, so
an id from another gallery matches no row -- neither a cross-event read nor an
existence oracle, since it returns all-zero counts rather than a 403/404 that
would confirm the id exists elsewhere. Slideshow tokens are denied (a kiosk
never uploads). Ids are pattern-validated, max 50. The response is counts
only: no filenames and specifically no processing_error strings, which can
carry internal paths. Not gated on allow_user_uploads, so an admin flipping
the toggle mid-flight does not strand an in-progress guest.

The frontend now finishes on the real terminal condition, refetches as each
photo lands rather than only at the end, shows a processing pill, and reports
real failures instead of silently timing out.

Refs testplan REPORT.md B6, B7.
2026-09-02 09:43:10 +02:00
Paul Nothaft 7c9baff751 fix(upload): scope category ids, stop temp-file leaks, split the video cap
Four related fixes on the admin upload/photo path.

B5 -- PATCH /photos/:photoId and POST /photos/bulk-update took any
parseInt(...) > 0 straight into the update with no existence or scope check,
so a photo could be moved into another event's category. The upload route
already validated `event_id = X OR is_global` per #500/#525; extracted that
query as findScopedCategory() and used it on all three routes so the 400 body
is byte-identical. 0/negative/'individual'/'collage'/null still clear without
a lookup, so the clear path costs no extra query.

B9 -- three distinct temp-file leaks, not one. The validator's size branch
never unlinked; the cleanup lived in the final handler, unreachable on any
400; and multer's `destination` callback runs per file and overwrote
req.tempUploadPath, so even the success path only ever removed the last
file's directory. Now: discardUploadedFiles() runs on every 4xx and the 500
(ENOENT tolerated, and files are only dropped when the whole request is being
rejected, so the passing path is untouched); cleanup registered before multer
so it also covers multer's own LIMIT_FILE_SIZE return; one directory per
request.

B8 -- the admin uploader filtered on MIME only, so an oversized file was
uploaded in full before the server's 400. Mirrors UserPhotoUpload's existing
per-file toast-and-drop.

C4 -- general_max_file_size_mb was a single cap for photos and videos, so the
50MB default meant admins could not upload ordinary video without also
raising the photo limit. Adds general_max_video_size_mb (default 500MB,
clamped by the same 10GB MAX_ALLOWED_FILE_SIZE_MB ceiling, read per request,
60s cache), editable in Settings -> General.

Photo uploads are protected from regressing by keeping multer's type-blind
limit at max(photoCap, videoCap) and moving the per-kind decision into
validateUploadContent, where file.mimetype exists. It 400s with the existing
message shape, so an oversized photo is still rejected with the identical
body it produced when multer did the rejecting.

Known gap: chunked-upload/init still applies the photo cap to video. Making
it video-aware would change an existing assertion that pins a 200MB video
init being rejected under a 1MB general cap. No component calls that path
today and the direction is strict rather than a bypass, so it is left as-is.
Guest video uploads still share the single cap in gallery.js.

Refs testplan REPORT.md B5, B8, B9, C4.
2026-09-02 09:43:10 +02:00
Paul Nothaft 57dd084763 fix(events): add archive_size to the immutable column deny-set
IMMUTABLE_EVENT_COLUMNS is documented as a COMPLETE deny-set that new
server-managed columns must be added to. archive_size is written by
archiveService from the zip's real byte count and is what the archives list
now sorts and displays, so an events.edit holder could otherwise set a
cosmetic size on a non-archived event.

Follow-up to 59666b59, which added the column.
2026-09-02 09:43:10 +02:00
Paul Nothaft 42ba8351c1 fix(middleware): log ownership lookup failures; drop dead auth surface
ownership.js caught a lookup failure, returned 500 and logged nothing -- the
file had no logger import, so a failing ownership check was invisible in the
logs. Added logging matching photoAuth.js/permissions.js
({ error, stack } plus the relevant id), response behaviour unchanged. Fixed
both swallowed catches: requireEventOwnership, the reported one, and the
byte-identical requireProjectOwnership.

Also removes AdminAuthContext.updatePasswordChanged, now dead -- superseded
by the deliberate full-page reload in onSuccess, with zero callers left.
setMustChangePassword and mustChangePassword stay; nothing else orphaned.

Refs testplan REPORT.md B13, B16.
2026-09-02 09:43:10 +02:00
Paul Nothaft 103863cbab fix(quotes): enforce the status state machine, and correct the table
VALID_QUOTE_TRANSITIONS was a complete-looking quote state machine that
nothing consulted, so status changes were unvalidated.

Mapping every writer of quotes.status (quoteService.js is the only one --
dealsService, projectService, adminDashboard and customer.js all read) showed
the table itself was wrong: six legitimate transitions were missing.
sendQuote allows draft/declined/expired -> sent but the table had draft only;
adminAcceptQuote allows draft/sent/expired but had sent only;
adminDeclineQuote allows draft/sent/expired but had draft/sent; recordResponse
had no same-status entry. Enforcing it as written would have broken
accept-on-behalf from a draft, resend-after-decline, every expired revival and
the 15-minute response-toggle window.

So the table is reconciled to reality first, then assertQuoteTransition()
(409, QUOTE_INVALID_TRANSITION) is called at all seven sites.

Two things worth carrying forward. Nothing in the codebase ever sets
'expired' -- the header comment says "set by the scheduler" and there is no
such scheduler; sent -> expired is retained as documented intent only. And
the backstop's added value is narrow: every reachable invalid transition is
already caught by a call site's own better-worded guard, which fires first.
What it newly catches is a status the machine has never heard of -- a legacy
or corrupt row like 'cancelled' sails through adminAcceptQuote's guard, which
only excludes accepted/declined/converted, and used to be silently
overwritten. That is what the new tests pin.

Refs testplan REPORT.md B4.
2026-09-02 09:43:10 +02:00
Paul Nothaft a7d45ddd0d fix(workflows): restore the once-per-process seed guard
`booted` was assigned but never read, so the guard's early return was missing
and the builtin workflow seeder ran on every call.

Impact was wasteful, not harmful: seedOneBuiltin is idempotent -- it keys on
builtin_key and returns early when adminOwned or storedVersion >= def.version,
writing a graph only on a fresh insert or a version bump. So repeat calls cost
a lookup per builtin plus a graph rebuild, with no duplicate rows.

`booted = true` stays inside the try, so a seed that never got off the ground
(workflows table not migrated, DB down) leaves the flag clear and retries. A
per-builtin failure is still swallowed by the inner catch and does not block
the flag, unchanged.

Restoring the guard broke workflowEngine.test.js, which calls the boot seeder
seven times in one worker and needs the second call to run in two of them.
Followed the existing _backupPathsBoot/_restoreSettingsBoot precedent:
exported _resetBootForTests().

Refs testplan REPORT.md B3.
2026-09-02 09:43:10 +02:00
Paul Nothaft 355fe4ff43 fix(email): give every template a real display name in the config UI
D4 audit found defaultTemplateKeys was worse than stale sample data:

- Only .name was ever read. The subject/body/variables triple on each entry
  was dead data -- and it is where {{password}} and {{expiration_date}}
  originated, neither of which exists in any shipped template (they are
  gallery_password and expiry_date).
- It covered 4 keys out of ~40. A fresh install already carries 17 templates,
  and ~40 with the CRM flags on. Every key not in the list rendered its raw
  snake_case template_key as its display name in both the sidebar and the
  read-only "Template name" field -- customer_gallery_assigned,
  database_backup_completed, invoice_collections_handoff, all five
  event_reminder_*, and so on.

Replaced with TEMPLATE_DISPLAY_NAMES covering every key from
migrations/core/*.js plus the crm/contract/eventReminder template services,
falling back to the raw key. Drops the now-orphaned password sample value.

Adjacent drift found, not fixed (different const, and fixing it would be
scope creep): eventReminderTemplates.js inserts with category 'crm' /
subcategory 'event_reminder', neither of which is in CATEGORY_ORDER or
CORE_SUBCATEGORY_ORDER, so all five reminder templates fall through the
unknown-category fallback into core -> "other". They are visible, just filed
in the wrong bucket.

Refs testplan REPORT.md D4.
2026-09-02 09:43:10 +02:00
Paul Nothaft 41e1de7818 fix(email): repair and seed the gallery lifecycle templates
Correction: the reported premise held for only one of the three templates,
verified by running the core migration set against an empty database.

- expiration_warning is German-is-English on every fresh install, exactly as
  reported. Repaired with migration 194's pattern verbatim.
- gallery_expired and archive_complete are NOT German-is-English -- they do
  not exist at all. Their master rows are inserted only by migrations/legacy/
  010+020, which never run on a fresh install, so 075/099/106/108 seeded zero
  translations for them (they key off a master row that is not there). A
  fresh install's email_templates holds 17 keys and neither is among them.
  The consequence is worse than a translation gap: expirationChecker's
  sendGalleryExpiredEmails and archiveService's completion mail both hit
  "Email template not found", retry three times and die silently in
  email_queue on every expiry and every archive.

So 195 also seeds those two (master row + en/de translations + category),
but only when the master row is absent -- it never overwrites. English
follows legacy 028, which emailProcessor's own comments call the shipped
copy; German follows legacy 026's wording. Both are restructured into the
plain unstyled shape the other core-seeded templates use, so wrapEmailHtml's
configurable palette governs styling rather than hard-coded hex. The
support-contact line is wrapped in {{#if support_email}} because
getSupportEmail() can return ''.

196 adds the {{#if welcome_message}} block that nl/pt/ru/fr/es/sl already
have in gallery_created but en and de lack, so the photographer's personal
note was silently dropped for those two locales even though the value is
passed at send time. safeTemplateReplace does resolve {{#if}} before variable
substitution, so this is a real conditional -- there is a test rendering the
migrated body both ways. HTML body only, matching the other locales:
emailProcessor rewrites welcome_message through formatWelcomeMessage
(escape + nl2br) once for both bodies, so the text part would print literal
<br /> and &amp;.

Both migrations keep 194's conservative condition -- rewrite only while the
German is still byte-identical to English or empty -- so admin-edited and
legacy-translated installs are untouched. Idempotent, guarded, no-op down().

Known gap, documented in 195's header: the two newly seeded templates get
en/de only. nl/pt/ru/fr/es/sl fall back to en via processTemplate's fallback
chain, which is strictly better than today's hard failure but is not real
localisation.

Refs testplan REPORT.md B1, B2.
2026-09-02 09:43:10 +02:00
Paul Nothaft afc5779ce7 fix(events): return 409 instead of 500 when a slug is taken
Correction to the reported cause: both create paths already loop
`while (await db('events').where({ slug }).first())` before inserting, so a
sequential duplicate never 500s -- it gets -1 appended. The 500 is purely the
read-then-insert race: two concurrent creates for the same name+date both
clear the check and the loser's INSERT trips events_slug_unique.

isDuplicateSlugError(), built on the existing utils/dbErrors.isUniqueViolation,
is wired into the catch of POST / and POST /:id/duplicate ->
409 { code: 'EVENT_SLUG_TAKEN' }. The predicate is deliberately narrower than
isUniqueViolation: on PG it matches err.constraint, on SQLite the specific
"UNIQUE constraint failed: ... events.slug" text. A loose message test would
misfire because knex prefixes the whole INSERT -- which always names slug --
to err.message, and events has other unique columns (share_token).

PUT /:id cannot collide: slug is in IMMUTABLE_EVENT_COLUMNS. No other
adminEvents sub-router writes slug. CreateEventPage already toasts data.error,
so no frontend change is needed.

The test makes the race deterministic without timers: it hooks knex's `query`
event and injects the colliding row the instant the route issues its
slug-existence SELECT. The route then spends a full bcrypt hash before its own
INSERT, so the injected row always lands first.

Refs testplan REPORT.md B10.
2026-09-02 09:43:10 +02:00
Paul Nothaft da6e34d6a3 fix(archives): sort and total on real archive sizes, escape LIKE wildcards
Closes the three trade-offs the server-side archives query deliberately
accepted.

C1 -- the sorted number and the displayed number are now the same one.
There was no archive_size column, so the Size column came from a per-row
fs.stat done after pagination while the sort fell back to summed photo bytes:
the list could be ordered by a number the user was not looking at. Adds
events.archive_size (bigInteger -- int4's 2.1GB ceiling is the same limit
that forced the restore path off adm-zip), written at archive time from
archive.pointer(), which is the exact byte count the completion email already
reports. The route now sorts and displays that column and no longer touches
the filesystem. The migration backfills by stat-ing every archive_path where
the column is null, outside the column guard so a half-finished run
self-heals; unstatable rows (missing zip, S3-backed storage) stay null, order
last via COALESCE and display 0 -- exactly what the old fs.stat produced for
a file it could not read. Restore nulls it alongside archive_path.
Accepted: the list no longer notices a zip deleted out of band and shows the
last recorded size. The detail route still stats the real file.

C2 -- escape \ % _ in the bound value plus an explicit ESCAPE '\'. The
ESCAPE clause is load-bearing rather than decorative: SQLite has no default
LIKE escape character, so without it the escaped pattern matches literal
backslashes and the search silently returns nothing on SQLite while working
on Postgres. The value stays bound; no interpolation.

C3 -- the four stat cards aggregated only the current page, so every total
was wrong for any dataset past page one. The list response now carries
totals { archives, photos, archiveSize } computed with the same applyFilters()
closure as pagination.total, so cards and footer cannot drift. Two aggregate
queries: archive_size sums on the unjoined events query (joining photos
multiplies it by photo count) and photos count on the joined one, both read
back through Number() for pg's bigint-as-string. The "Showing X of Y" line
moved out of the totalPages > 1 guard so it survives a single-page result,
now gated on total > 0 so a zero-result search does not render
"Showing 1 to 0 of 0"; only the page controls stay conditional.

Test fixtures deliberately order zip sizes differently from summed photo
bytes, so the sort test can only pass on the right column.

Refs testplan REPORT.md C1, C2, C3.
2026-09-02 09:43:10 +02:00
Paul Nothaft 4c2eeab2f4 refactor(gallery): drop the unreachable Story feedback sheet
StoryFeedbackSheet could never open: handleOpenFeedback was the only caller
of setSelectedPhotoForFeedback and was itself never called. This was the last
remaining build:check error (TS6133).

Removed rather than wired up, on three findings:

- The sheet offered nothing PhotoLightbox does not, and was strictly worse.
  It held comments and ratings in layout-local useState and never called
  feedbackService.getPhotoFeedback, so existing server-side feedback was
  invisible; it rendered stars and a comment form unconditionally, ignoring
  allow_ratings/allow_comments; and it had no reactions, colour labels,
  identity modal or rate-limit handling. This layout already renders
  PhotoLightbox with feedbackEnabled, which does all of that against the
  server.
- It was not a mobile affordance. The CSS styled it as a fixed right-edge
  desktop drawer (right: 0; max-width: 28rem) with no media query.
- Every sibling layout routes feedback through the lightbox. Grid, Masonry,
  Timeline, Mosaic and Carousel expose a per-card onQuickComment that calls
  onOpenPhotoWithFeedback to open the parent's lightbox on the feedback tab;
  none has a standalone feedback surface. The closest sibling,
  GalleryPremiumLayout, renders its own lightbox and deliberately voids
  _onOpenPhotoWithFeedback with no per-card control -- exactly the shape
  Story now has.

Drops the component, its state and handlers, the feedbackOptions destructure
(only the sheet read it) and 251 lines of orphaned CSS. savedIdentity also
fed guest_name/guest_email into the like call; those were always undefined at
runtime since the unreachable sheet was their only writer, so no behaviour
changes.

Also widens the Story nav search input, which clipped its placeholder. At the
input's computed 14px the placeholder measures en 121px, de 145, ru 152,
fr 174 against a 128px box -- so German was 17px over and French 46px over.
8rem -> 13rem collapsed, 12rem -> 17rem focused, keeping expand-on-focus;
verified at 1280px and at the 768px breakpoint where the search appears.

Refs testplan REPORT.md A1 and the gallery-story placeholder warning.
2026-09-02 09:43:10 +02:00
Paul Nothaft 0ae424ff42 test(migrations): pin migration 194's per-field guard
The per-field fix landed without a test for the case it exists for: an
admin-translated subject over a still-English body, and the reverse.
2026-09-02 09:43:02 +02:00
Paul Nothaft 72894e22c2 fix(gallery): stop browser zoom tripping the devtools viewport heuristic
innerHeight is in page CSS pixels and shrinks under browser zoom;
outerHeight does not. At 150-200% zoom a normal window therefore shows an
absolute outer/inner gap of 400-500px, past every threshold, so an
accessibility zoom read as a docked DevTools panel and - at protectionLevel
"maximum" - redirected the guest off the gallery on load. Pre-existing
(the previous threshold was 100px), but the rewrite kept the shape.

The gap is now measured relative to a baseline taken at mount, and the
baseline is re-taken whenever devicePixelRatio changes, which a zoom step
does and a docked panel does not. Only a gap that grows past the threshold
at a constant ratio counts. The mount-time check is dropped: a panel that
is already open at load is indistinguishable from a zoomed window.
2026-09-02 09:33:34 +02:00
Paul Nothaft 77b11ab874 fix(upload): enforce the chunked-upload cap on bytes received, not declared
The init route checked the client-declared fileSize against
general_max_file_size_mb, but nothing checked what then came through the
chunk route: a client could declare `fileSize: 1` and stream any amount,
and completeUpload only logged the size mismatch before handing the merged
file on. The cap the earlier commit added at init was therefore a gate with
no fence.

The service now carries the cap from init and enforces it on the running
byte total per chunk (aborting the upload once crossed, since the chunks on
disk are already over the limit), rejects chunk indices outside the
announced range, and re-checks the merged file as a backstop. Both routes
answer 413/400 for these instead of a blanket 500.
2026-09-02 09:33:34 +02:00
Paul Nothaft 814f205da0 fix(feedback): make the "block" severity tier actually reject
The block level is advertised as "comment is rejected immediately", but every
non-approved comment was saved with is_approved = false instead of the
submission being refused.

moderateText now sets an explicit `blocked: true` on the blocking-violation
branch -- branching on the reason string in the route would have been fragile
-- and the route 400s with code COMMENT_BLOCKED and stores nothing. Everything
else that is not approved (moderate/high, the spam and caps checks, and the
"Moderation system error" fallback) deliberately omits the flag and keeps the
held-for-moderation path, so a moderation failure still fails safe.

Also fixes an adjacent defect that made the tier split unobservable:
feedbackService.submitFeedback ignored feedbackData.is_approved entirely and
hard-derived is_approved from moderate_comments. So a moderate/high
word-filter hit on an event with moderation switched OFF was published
immediately -- the route's `feedbackData.is_approved = false` was dead code.
Now honoured one-directionally: a caller-supplied false is respected, but
nothing a caller passes can RELAX the event's setting. That deliberately
leaves the route's reputation.autoApprove -> is_approved = true branch inert
rather than letting a trusted guest bypass an event's moderation setting.

Refs testplan REPORT.md B11.

(cherry picked from commit b1b57b1615aaf02fe76e789a86b7e11933288d77)
2026-09-02 09:30:49 +02:00
Paul Nothaft da8fcc82ef fix: per-field template guard, LIKE escaping, wait for all uploads
Codex review round 1 on #1266.

Migration 194 gated all three German fields on body_html alone, so an admin
who had translated only the subject would lose it the moment the HTML still
matched English -- and down() is a deliberate no-op, making that loss
unrecoverable. Each field is now judged independently, for both the
translations table and the legacy _de columns.

Archives search escapes LIKE wildcards. % and _ are literal characters to the
client-side includes() this replaced but wildcards to LIKE, so searching
"100%" matched every archive and reported a nonsense total. The ESCAPE clause
is load-bearing: SQLite has no default LIKE escape character, so without it
the escaped pattern matches literal backslashes there while working on PG.

The post-upload poll waits for every queued file. Each is processed
independently, so stopping at the first new photo left the rest of a
multi-file upload hidden until a manual refresh -- the exact symptom the
polling was added to prevent. UserPhotoUpload now reports how many files the
server accepted.

(The latter two are superseded by stronger fixes in #1267 -- the upload-status
endpoint and the shared escape helper -- but each PR has to be correct on its
own.)
2026-09-02 08:41:36 +02:00
Paul Nothaft 6e5755de02 fix(types): resolve the TypeScript build:check backlog
74 errors -> 1. No suppressions: zero `any`, `as unknown as`, `@ts-ignore` or
non-null `!` added, and tsconfig is untouched. Each error was triaged as
"the type is wrong" vs "the code is wrong" and fixed on that side.

Live bugs the checker was pointing at:

- admin.service.ts TS1117 duplicate key: admin_password_reset was defined
  twice and the later one won at runtime. Removed it so the earlier entry
  wins, which matches the actual emitter in userManagementService.js and
  carries the email fallback.
- PhotoGridWithLayouts dropped allowReactions from its prop type, so the
  Premium layout's reactions never activated even though GalleryView passes
  it and GalleryPremiumLayout reads it.
- SlideshowPage's poll never copied `order` into next/prev, so live
  play-order changes never reached a running kiosk.
- CustomerLayout compared branding_force_color_mode against 'auto', which is
  never persisted (only 'dark'|'light'|null), so the customer portal always
  picked the light logo even in OS dark mode.
- EmailConfigPage rendered lang.flag, but SUPPORTED_LANGUAGES exposes Flag, a
  component -- so nothing rendered. And editing a language with no translation
  yet spread undefined, storing a partial object missing required fields.
- publicQuotes.js projected only 6 line-item fields, omitting
  parentLineItemId/parentPosition/detailsText, so the migration-119 sub-item
  hierarchy and details text could never render on the customer-facing quote
  page -- the frontend code for it was unreachable. It reads from the same
  quoteService.getQuoteById the admin route uses, where those fields are
  present; adminQuotes.js projects all three. Fixed the projection rather
  than adding fields to the frontend type, which would have compiled while
  leaving the feature broken.
- DuplicateEventDialog's helper text was silently dropped: LocalizedDateInput
  had no helperText prop. Added, mirroring Input.tsx incl. aria-describedby.
- ThemeEditorModal/EventThemeSection still passed isPreviewMode, a prop
  822be9a9 deliberately removed but missed at these two call sites.
- GalleryPage's hero-photo injection was dead: /gallery/:slug/info does not
  return hero_photo_id (only /photos does) and GalleryView already does it
  correctly. Removed the dead block rather than adding a field the API
  never sends.

Stale types corrected against the backend route that produces each payload:
GalleryInfo (allow_downloads, allow_user_uploads), GalleryData.event
(download_zip_ready), UpdateEventData (client_access_enabled, client_password,
regenerate_client_token), InvoiceSummary (replacesInvoiceId), ExportOptions
(mark_source, plus a snake_case ExportFilter matching the actual wire format),
customer.service contracts, AdminUser timestamps widened to string|null,
formatMoney currency widened to match its own (currency || 'CHF') guard,
faceCropStyle dimensions widened to match its !photoWidth guard, DEFAULT_FLAGS
faces, logo_position 'sidepanel', and the hand-rolled t() props replaced with
i18next's TFunction in four files.

Unused symbols were checked before deletion; UpdateInstructionsDialog's
targetVersion prop was completed rather than deleted (declared and passed but
never rendered -- now the fallback before the query resolves).

Left unfixed, deliberately: GalleryStoryLayout's handleOpenFeedback (TS6133).
It is the only caller of setSelectedPhotoForFeedback and is itself never
called, so StoryFeedbackSheet can never open on the Story theme. Wiring it
needs a new affordance on StoryPhotoCard (no sibling layout exposes one to
copy) and deleting it would orphan the sheet -- a product decision, not a
type fix. Note PhotoLightbox on the same layout already handles feedback,
so the sheet may simply be superseded.

Refs testplan REPORT.md #22 (Part 1.3.04).
2026-09-01 17:09:24 +02:00
Paul Nothaft 5dbb43549c fix(i18n): make i18n:ci pass by fixing the extractor config
exit 1 -> exit 0.

Three findings, none of which matched the reported symptoms.

1. The two "unparseable .d.ts files" are not malformed. RestoreWizard.d.ts and
   BackupHistory.d.ts are valid declaration files sitting next to their .jsx
   implementations; i18next-cli feeds them to SWC as ordinary .ts modules with
   no ambient flag, where an uninitialised `const` is a hard syntax error. They
   should never have been scanned at all. Root cause is the input glob:
   i18next-cli passes `input` straight to `glob`, which does NOT honour
   `!`-prefixed negation inside the pattern list, so
   '!src/**/*.{test,spec,d}.{ts,tsx}' was a silent no-op and all four .d.ts
   files plus 57 test files were being scanned. Moved the exclusions to
   extract.ignore, where they take effect; the extracted key set is unchanged.

2. The "missing French keys" were not English-vs-French drift. The extractor
   wanted to add ~2771 keys to fr.json with value "" -- and src/i18n/config.ts
   does not set returnEmptyString, whose i18next default is true, so those
   empty strings would be returned as valid translations and render as blank
   UI rather than falling back to English. Filling nl/pt/ru/fr with ~11000
   empty strings would have been a worse regression than the failing check.
   The check was demanding parity for locales this project deliberately keeps
   partial, so `locales` is now ['en','de'] -- the two actually kept at parity.
   nl/pt/ru/fr join sl/es as hand-maintained partial locales on
   fallbackLng 'en'. No French was written.

3. de.json is a parity locale, and the extractor legitimately found 307 keys
   missing from both en and de (shipped t() calls never added to the locale
   files). Rather than accept 307 blank German strings these were written by
   hand: 105 are _one/_other variants derived from existing German bases with
   correct singular/plural, the rest translated against each section's register
   (Sie on admin/public-billing surfaces, du in the customer portal to match
   customer.quotes/customer.bills) reusing terms already established in de.json.
   Verified: 0 interpolation-placeholder mismatches between en and de across
   all 308 new keys, 0 empty and 0 key-shaped values remaining, and the diff is
   strictly additive (en +308, de +307, 0 removed, 0 changed).

removeUnusedKeys is now false, replacing the dead preservePatterns: []. It
wanted to delete ~355 live keys per locale across ~90 prefixes -- families
built at runtime (admin.activities.*, admin.notificationMessages.*,
projects.status.*) or held in constant tables the extractor cannot resolve
(AdminSidebar nameKey, CrmDevelopmentPage titleKey/descKey). Covering them
would need ~30 wildcards spanning most of the key space; disabling pruning is
the same behaviour, honestly stated, with the call sites named.

Refs testplan REPORT.md #22 (Part 1.3.03).
2026-09-01 17:07:07 +02:00
Paul Nothaft d16137bb2a fix(contracts): add tooltips to the ellipsized block-library names
The Blocks list column ellipsizes names to ~4-6 characters ("Vertr...",
"Bildr...") with no title attribute, so the list is unscannable without
clicking into each block. Add title on the name and description, plus min-w-0
so the name shrinks instead of pushing the badges out.

Did not widen the column: the file carries an explicit design-intent comment
that its two-column grid "intentionally mirrors EmailConfigPage's Templates
tab", and changing the span would break that deliberate parity. The tooltip
resolves the reported unscannability on its own.

Refs testplan REPORT.md #21 (Part 8, S13).
2026-09-01 16:48:24 +02:00
Paul Nothaft 78b1ddd0db fix(admin): interpolate activity and notification message values
Users were shown raw "{{quoteNumber}}", "{{name}}" and "{{count}}" tokens.
Two distinct render-side causes; nothing is persisted as a rendered string
(messages are stored as type + metadata JSON and formatted client-side), so
no backend change was needed.

{{quoteNumber}} / {{name}} -- AdminDashboard's getActivityMessage built a
hardcoded five-value allowlist (eventName, email, count, template,
categoryName) and passed it to t('admin.activities.<type>'). The backend does
record quoteNumber (quoteService.js) and name (adminWebhooks.js); the values
just never reached i18next, so every activity string interpolating anything
outside that allowlist rendered its literal token. Spread activity.metadata
first, keeping the five derived entries as overrides since they resolve from
columns that are not in metadata. Extracted as buildActivityParams for
testability, mirroring the formatDayHeader extraction.

{{count}} -- different cause, the notification-bell path: archiveBulk.js logs
successfulCount, but the locale string expects count and
bulk_archive_completed had no explicit case, so the default branch spread a
metadata object without one. Added a case next to the existing
bulk_delete_completed, following that idiom.

Also fixes bulk_delete_completed, which has the identical mismatch: it reads
metadata.deleted || metadata.count while archiveBulk.js writes successfulCount,
so that notification always rendered "0 events deleted". It degrades to a wrong
number rather than a visible placeholder, which is why it was not among the
three reported instances -- but it is the same one-token bug.

Refs testplan REPORT.md #15b.
2026-09-01 16:48:24 +02:00
Paul Nothaft d8bd0cd449 i18n: close the admin translation coverage gaps
Recurring pattern of components and strings shipped without translation
coverage, found across unrelated feature areas. +212 keys each to en.json and
de.json, provably additive (flattened-key diff: removed=0, changed=0;
formatting round-trips byte-identically).

Genuinely un-wired components (grep -c useTranslation == 0), now wired:
BulkArchiveModal (8 strings, count-pluralised), WebhookDeliveriesPage (27),
CMSEditor's TipTap toolbar/link dialog/status bar/help modal (64).
Hardcoded strings fixed in code: ImageSecurityTab's 4 spinbutton hints,
ProjectsListPage's unlocalized status enum.
Keys-only (component already calls t() correctly): General "Time format",
Branding Social Media + Promotional Banner, Quotes detail/editor, cms.showInFooter.

Two corrections to the report's attribution:
- BlockLibraryPage was NOT un-wired -- it calls t() on every string with
  English defaults; all 32 contracts.blocks.* keys were simply absent from
  both locale files, so everything fell back to the JSX default. Same for
  ContractsListPage, where the report cited 3 missing keys and there are
  actually 9 (all 5 table column headers plus the pagination line).
- CustomerDetailPage has full t() coverage; its single English "Contracts"
  was a missing customer.nav.contracts key behind a dynamic labelKey.

Locale convention followed: i18next.config.ts manages en/de/nl/pt/ru/fr, but
only en and de are kept at parity (5198/5200 keys); the rest are ~50% partial
and rely on fallbackLng 'en'. Added to en + de only rather than inventing
212x6 unreviewable translations.

Also added the 25 missing businessProfile.* keys (PDF-letterhead section,
bank-accounts QR disclaimer). That component already calls t(), so those
strings localize as soon as the keys exist; no wiring needed.

Refs testplan REPORT.md #15a.
2026-09-01 16:48:24 +02:00
Paul Nothaft 9143997f8e style(backend): clear the eslint backlog to zero
929 problems (928 errors, 1 warning) -> 0, exit 0.

Rule breakdown, which corrects the report's premise -- `indent` dominated, not
`quotes`: indent 719, quotes 68, no-unused-vars 54, no-empty 36,
no-useless-escape 22, no-case-declarations 17, no-inner-declarations 6,
no-control-regex 5, no-useless-catch 1, no-console 1 (warn).

--fix handled only indent + quotes (719+68 = exactly the "fixable" count).
no-useless-escape was NOT auto-fixable in this eslint version, so the one
genuinely risky class never went through the autofixer -- all 22 were done by
hand. Two mechanical proofs on the autofix diff: a token-level AST diff
(espree, before vs after) shows exactly 68 differing tokens, all quotes, with
the 719 indent fixes producing zero token changes; and a cooked-value diff of
every string/template/regex literal shows 0 differences.

Regex escapes: eslint was correctly conservative and did not flag the
load-bearing ones -- \- in [^a-zA-Z0-9_\-\.] (unescaping makes an invalid
reversed _ -> . range) or in [!@#$%^&*()_+\-=...] (would become a + -> = range
silently matching ",-."). Every removal was a \/ \[ or \. inside a character
class; all 11 old/new pairs were brute-forced over 794 inputs with 0
mismatches.

Manual fixes: no-empty were all deliberate best-effort catches around activity
logging, annotated rather than restructured; no-case-declarations braced in
two adminBackup switches; no-inner-declarations converted to const arrows
after checking no call precedes the declaration and no this/arguments use;
no-control-regex and no-console got targeted disables with stated reasons;
one `catch (e) { throw e; }` wrapper removed.

Two unused bindings were near-misses worth noting: secureStatic.js's
`fullPath` is a path-traversal guard (safePathJoin throws on escape) and
restoreService.js's `backupManifest` is the throw-on-corrupt-manifest gate
before a rollback -- deleting either would have silently removed a check. Only
the bindings were dropped; the calls stay.

Two real bugs found and deliberately preserved with a comment plus a narrow
disable rather than deleted, since deleting would erase the evidence:
_workflowSeedBoot.js's `booted` is written but never read, so the intended
once-per-process guard is missing its early return and workflows re-seed on
every call; and quoteService.js's VALID_QUOTE_TRANSITIONS is a full state
machine nothing consults, so quote status changes are unvalidated.

Backend test suite: 253 suites / 2552 tests passing, 0 failures, before and
after.

Refs testplan REPORT.md #22 (Part 1.2.02).
2026-09-01 16:46:34 +02:00
Paul Nothaft da9ceb14ca fix(ui): stop branding-theme text colour rendering headings invisible
Components that render headings with no explicit text-colour class inherit
`body { color: var(--color-text) }`, and the branding theme sets --color-text
on <html> app-wide -- so on a dark-toned theme they render near-invisible
(#f5f5f5 on #fff), including inside the admin panel in light mode.
Compliance-adjacent: /impressum and /datenschutz are two of the surfaces.

Convention copied from AccountingTab, the QA control that is visually
identical but not affected: h2 -> text-neutral-900 dark:text-neutral-100,
labels -> neutral-700/300, checkbox labels -> neutral-800/200, hints ->
neutral-500/400.

Fixed beyond the reported lines, after sweeping each file:
- LegalPage: the CMS prose wrapper and the single-segment 404 heading.
- CMSContentBlock: the multi-segment CMS 404 and the admin unknown-route 404
  turn out to be the same component (App.tsx path="*"; there is no admin-level
  catch-all). Its text already used var(--color-text); the actual defect was
  .card hardcoding bg-white under themed text, so the surface was fixed, not
  the text.
- SettingsBusinessProfilePage (11), CrmSettingsPage (15, incl. both shared
  checkbox-label helpers covering ~20 rendered rows), ReminderTemplatesPage (7,
  incl. text-theme/text-muted-theme on an admin page where they are wrong).
- The <select> elements on those tabs: Tailwind preflight sets color:inherit
  on form controls, so they picked up the near-white body colour on a white
  background. Same root cause, not previously reported.

Plus one line of defence-in-depth on the admin shell (AdminLayout): an
explicit text colour there stops the whole admin panel inheriting the themed
body colour. Components with their own class, including text-theme, still win.

Interpretation -- the robust fix was evaluated and rejected. Scoping the theme
tokens to gallery contexts is not feasible: the leak is deliberate product
behaviour (GlobalThemeProvider applies branding on every non-gallery page),
40 files read var(--color-*) with only 9 under components/gallery, and it
would break the customer portal, the public token pages, AdminLoginPage and
the Branding live preview. It also cannot be done at container level without
moving `body { color: ... }` and the whole .text-theme/.bg-surface/.card-themed
utility family, which are global by construction.

Known remaining instances, not converted: SystemHealthPage, CrmOverviewSection
and HoursSection use text-theme explicitly on admin surfaces, so they keep the
themed colour and stay affected. Outside the reported surfaces.

Refs testplan REPORT.md #14 (Part 8, S3/S4/S13).
2026-09-01 16:41:13 +02:00
Paul Nothaft ac50f0b48b fix(admin): portal the update-available modal to document.body
AdminSidebar's root div carries a Tailwind `transform` utility for the mobile
slide-in, and per the CSS spec a transformed ancestor becomes the containing
block for position:fixed descendants. The modal renders inline inside
VersionInfo/AdminSidebar, so its `fixed inset-0` backdrop was trapped in the
256px sidebar column (measured 256 vs window 1440) -- copy buttons overlapping
text, content truncating.

Reuse the codebase's one existing portal convention, from
gallery/FeedbackLimitReachedModal: assign the JSX to a const and return
createPortal(node, document.body).

Checked for other modals with the same trap; there are none.
UpdateInstructionsDialog is also fixed inset-0 but is mounted from
AdminDashboard inside <main>, and CustomerLayout has an identical transformed
aside with no modal inside it.

Refs testplan REPORT.md #13 (Part 3, B.07).
2026-09-01 16:41:13 +02:00
Paul Nothaft 1be27404fa fix(email): derive preview sample data from each template's variables
The preview modal's hardcoded sampleData had drifted from the templates'
declared variables arrays: it carried `password` and `expiration_date` and no
`host_name` at all, so {{host_name}}, {{gallery_password}} and {{expiry_date}}
rendered as literal placeholders in the gallery_created preview while
event_name/event_date/gallery_link substituted fine.

Derive the key set from the template's own `variables` instead, so nothing can
be missing again. editedTemplate already carries the array at the call site,
so no plumbing was needed. A small module-level lookup keeps sensible shapes
for the ~11 variables where shape matters (dates look like dates, links like
URLs), with a readable [name] fallback for anything uncurated -- curating all
~60 distinct variable names across the ~32 template seeds would just recreate
the drift trap.

Preview-only; real sent mail was never affected.

Refs testplan REPORT.md #17 (Part 3, J.04).
2026-09-01 16:40:02 +02:00
Paul Nothaft 73b08a7b5c fix(email): give gallery_created a real German translation
translations.de for gallery_created was the English copy word for word, while
nl/pt/ru/fr/es/sl are all localized. This is the mail sent on every gallery
creation, so German-default installs have been silently mailing English.

Root cause chain, fresh installs only: 001_init seeds the English template;
059 introduces the multilingual columns and fills subject_de/body_html_de/
body_text_de from their _en counterparts (its own comment: "Copy to German as
default"); 075 then materialises exactly those columns as the `de` row. The
real German only ever existed in migrations/legacy/026, and run-migrations.js
runs core/ only for fresh installs -- so every install created since 059 has
the English-as-German row.

A code-only fix would have changed nothing: knex will not re-run 059/075, so
existing installs would keep the bad row forever. Fixed as a content migration
following the repo's precedent for template repairs (094, 172).

Conservative about what it touches: the German row is rewritten only while it
is still byte-identical to English (or empty) -- precisely the broken state --
so a legacy install whose German came from 026, or any admin-edited template,
is left alone. Also repairs the legacy _de columns, which are still
emailProcessor's fallback path. Idempotent, hasTable-guarded, no-op down()
(reverting would restore English-as-German).

Placeholder parity with the English original is exact and test-asserted:
host_name, event_name, event_date, gallery_link, gallery_password, expiry_date.

Two related gaps found but deliberately not fixed, both outside the reported
bug: expiration_warning, gallery_expired and archive_complete are German-is-
English on fresh installs through the identical 059 mechanism (legacy 026
fixed all four). And nl/pt/ru/fr/es/sl additionally wrap a
{{#if welcome_message}} block that the English original lacks, even though
welcome_message is passed at send time -- so EN and now DE drop the
photographer's personal note. That is an English-side gap needing its own
decision.

Refs testplan REPORT.md #16 (Part 3, J.04).
2026-09-01 16:40:02 +02:00
Paul Nothaft 76a1453fa7 fix(calendar): don't put a fixed reference date in the month header
dayHeaderContent assumed arg.date is always the real column date. It is in
the time-grid views, but FullCalendar v6 fills it from an internal reference
week (1970-01-04..10) for dayGridMonth headers, so the month header read a
fixed "Mo 05.01. ... So 04.01." regardless of the visible month. Body dates
were correct; only the header row was wrong.

Interpretation (flagged as ambiguous): a month-view column header labels seven
generic weekday columns shared by every week in the grid -- it has no single
date, so forcing one in is wrong by construction rather than just mis-computed.
Month view now renders the localized weekday alone, which is also FC's own
default there; timeGridWeek keeps weekday + DD.MM. since each column really is
one date.

Branches on view.type === 'dayGridMonth' exactly, not a dayGrid prefix:
dayGridWeek/dayGridDay do have real per-column dates and a prefix match would
break them if either is ever added.

Extracted to an exported formatDayHeader so it is testable without mounting
the page; the test mounts a real FullCalendar in both views, so an upgrade
that changes the arg.date contract fails rather than silently regresses.
FullCalendar dependency untouched.

Refs testplan REPORT.md #10 (Part 8, S9).
2026-09-01 16:31:09 +02:00
Paul Nothaft fc7cb226f4 fix(archives): run search, filter and sort server-side
ArchivesPage fetched one 20-row page and then filtered and sorted only that
array in memory, while "Showing X of 802" / "Page 1 of 41" kept reporting the
full unfiltered count. Searching for an archive that exists but is not on the
current page returned a false "0 results" with no hint the search was
page-scoped.

The backend did not support the params (it read only page/limit and hardcoded
orderBy archived_at desc), so all three are new. Follows adminEvents/crud.js
for the shape and customerAccountsService for the case-insensitive predicate:
whereRaw with a bound parameter, never interpolated, and sortBy whitelisted to
date/name/size before it reaches orderBy. The same applyFilters() closure runs
against both the count query and the row query, so the total cannot drift from
the rows again.

Frontend mirrors EventsListPage: 300ms debounce, reset to page 1 on any query
change, placeholderData so keystrokes don't flash the spinner.

Two interpretation calls:
- sortBy=size orders by summed photo bytes, not the zip's on-disk size. The
  Size column comes from a per-row fs.stat done after pagination and there is
  no archive_size column, so a global sort on the real zip size would stat all
  802 files per request. Ordering is near-identical except for rows whose zip
  is missing. Adding events.archive_size would be a migration, out of scope.
- No LIKE-metacharacter escaping. escapeLikePattern() does .replace(/'/g,"''"),
  which corrupts a bound value ("Sarah's Birthday"), and its backslash escaping
  is a no-op on SQLite without an ESCAPE clause. Matched customerAccountsService
  instead. A literal % typed by an admin acts as a wildcard in a read-only
  search; no injection risk.

Pre-existing and untouched: the four stat cards still aggregate the current
page only.

Refs testplan REPORT.md #9 (Part 3, I.01).
2026-09-01 16:31:09 +02:00
Paul Nothaft 3f6c81a846 fix(photos): treat category_id 0 as uncategorized instead of storing it
Genuine product bug, found behind the adminPhotos.reference suite (which was
failing for an unrelated reason -- see below).

parseInt('0') is 0 and !isNaN(0) is true, so a '0' category_id was written
literally. photo_categories.id is an increments() column, so 0 can never be a
real category, and every read path already assumes it cannot happen: the list
mapper does `category_id || type` (0 is falsy, renders as uncategorized) and
the list filter explicitly skips '0'. The result was a filter black hole -- the
photo matches no numeric category filter, and misses the "uncategorized"
filter too because that is whereNull(). Displayed as uncategorized, reachable
by nothing.

null rather than a 400: unparseable input ('abc' -> NaN) already falls through
to null, so 400ing on '0' while silently accepting 'abc' would be incoherent,
and '0' is just the HTML <select> shape where the "none" option carries
value="0".

Fixed at all three call sites that share the branch -- PATCH /photos/:photoId,
POST /photos/bulk-update, and the upload route, where the dangling 0 was
written at creation time and the scope-validation guard
(`if (parsedCategoryId && ...)`) skipped on the falsy 0 and let it in
unvalidated. Only the PATCH one was behind the failing test; leaving the other
two would have left the bad state creatable.

The suite's 3 failures were all masked by a fixture gap, not this bug: it
stubs middleware/auth but not middleware/permissions, so requirePermission's
admin_users JOIN roles query hit tables the fixture never creates and every
request 500'd before reaching a handler. Stub it, bring the photos fixture up
to the 7 migrations it had drifted behind, and correct a stale 200 that became
202 when uploads went async in 851744c3.

Known adjacent gap, not fixed (wider than this bug): PATCH and bulk-update
accept any positive category_id with no existence or scope check, unlike the
upload route which validates event_id = X OR is_global per #500/#525 -- so a
photo can be PATCHed into another event's category.

Refs testplan REPORT.md #22 (Part 1.2.01).
2026-09-01 16:30:46 +02:00
Paul Nothaft 18715b5efd fix(gallery): show a guest's own upload without a hard reload
Correction to the QA root cause: the 304 is correct server behaviour, not a
stale cache. The guest upload route answers 202 and queues the file, so the
row lands as processing_status 'pending', and the photos list returns only
completed rows. The immediate post-upload refetch therefore produces a
byte-identical payload, express's body-derived weak ETag matches, and the
browser is answered 304. Cache-busting would not have fixed it -- a busted
request 200ms after the upload returns a 200 whose body still lacks the
photo. The hard reload only worked because it happened seconds later.

Poll instead: refetch immediately and every 2s until the photo count exceeds
the pre-upload baseline, with a 60s deadline and cleanup on unmount. This
also replaces two window.location.reload() callbacks, which could not have
waited for the worker anyway and threw away scroll and folder state.

Not done (out of scope, recommended follow-ups): GET /api/gallery/:slug/photos
sets no cache headers at all for private per-guest data and relies on
heuristic freshness -- noStoreCache.js already exists and would fit. And the
guest upload flow has no progress signal, so the UI polls blind where a
processing-status endpoint (or pending counts in the photos payload) would let
it say "processing...".

Refs testplan REPORT.md #12 (Part 4, P4-E.01).
2026-09-01 16:30:06 +02:00
Paul Nothaft 9d4bd7ab30 fix(gallery): stop devtools protection from breaking the whole page
With enable_devtools_protection on, every click on the gallery failed and
trivial script evaluation hung -- confirmed on two independent events. A
guest with DevTools open for an unrelated reason (network tab, a CDP-attaching
extension) got a silently unresponsive gallery with no error shown.

Mechanisms found, all in the hook (both callsites were innocent):

1. detectByDebugger ran a bare `debugger;` on every tick at medium/high
   sensitivity -- and the per-event flag maps to medium. With any debugger or
   CDP client attached the renderer paused there continuously. This is why
   Runtime.evaluate hung on 1+1 and clicks reported their target gone.
2. Four separate detectors called console.clear() -- the observed clear loop.
3. handleDevToolsDetected was useCallback([options]) over a fresh object
   literal, so runDetection changed identity every render and the effect tore
   down, rebound and re-ran detection on every render -- a 1s interval turned
   into a tight loop.
4. detectByConsole monkey-patched console.log/error/warn/info every tick
   inside a try/catch that swallowed throws, so a throw between patch and
   restore left the guest's console permanently hijacked.
5. contextmenu was preventDefault'd document-wide regardless of target,
   killing the menu on text, links and form fields -- disable_right_click is
   the separate setting meant to cover the whole page.

Kept: the DevTools shortcut keys (only those exact combos; everything else
passes through), the docked-DevTools viewport heuristic as a pure measurement
on resize plus one check at mount, right-click blocked on IMG/CANVAS/VIDEO
targets only. The public API (onDevToolsDetected, redirectOnDetection,
redirectUrl, isDetected, reset) is unchanged, so PhotoLightbox needed no edit.

Removed: debugger traps, console.clear, console monkey-patching, the
timing/element/toString probes, the polling interval, document-wide
contextmenu blocking. Undocked DevTools is now deliberately undetectable --
every technique that catches it costs the page its responsiveness for
everyone. This is a deterrent, not a security boundary.

Also raised the viewport threshold (100 -> 160/200/260 by sensitivity):
browser chrome with a bookmarks bar is ~140px, so the old check false-positived
on ordinary windows, which at protectionLevel 'maximum' redirected legitimate
guests off the gallery.

Refs testplan REPORT.md #3 (Part 4).
2026-09-01 16:30:06 +02:00
Paul Nothaft c6cb01865e fix(settings): derive the sidebar preview from the real sidebar declaration
SidebarPreview kept its own hand-maintained 6-item array with only two gates
wired (analytics, userManagement), so toggling e.g. Workflows changed nothing
in the preview even though it does add a real sidebar entry once saved.

Export AdminSidebar's `navigation` as `adminNavigation` (2 lines) and derive
the preview from it, so every gate -- transfers, messaging, analytics,
userManagement, clients incl. its featureFlagsAny set, accounting, workflows --
is covered and the two can't drift again.

Note the report's item list was partly wrong: Quotes, Contracts, Invoices,
Hours, Projects, Calendar and the CRM dev tools have no top-level sidebar
entries at all -- they are sub-nav inside /admin/clients and surface in the
preview through the CRM entry's featureFlagsAny.

Permission filtering is deliberately not applied (unchanged): the preview
answers "what do these flags do to the sidebar", not "what can this admin see".

Refs testplan REPORT.md #20 (Part 3, J.14).
2026-09-01 16:29:33 +02:00
Paul Nothaft 3e16b81be8 fix(settings): clear the accounting flag when its parent is turned off
Turning Invoices off left the Accounting master flag -- and its sidebar
entry -- silently on and freshly unlocked, because the bills=true =>
accounting=true force-enable had no reverse.

A dependency model already exists and handles every true parent->child pair
(quotes->bills, calendar->calendarBooking, accounting->{taxReport,
incomingInvoices,expenses}), mirrored client-side in applyDependencyRules and
server-side in adminFeatureFlags.js. The gap is only this asymmetric rule.

Interpretation, two decisions:

- Cascade on the client at toggle time, not on the server at persist time.
  applyDependencyRules is a pure invariant over a single state (the GET
  handler runs it too), so it structurally cannot distinguish "accounting is
  on because the admin wants it" from "...because bills forced it". The
  Features tab PUTs the full flag set, so on the wire an explicit true and a
  stale forced true are byte-identical -- a server-side transition rule would
  silently discard an admin who turns Invoices off and deliberately keeps
  Accounting on in the same save. The client is where the gesture is known.
  The persisted result is still server-enforced: the client sends
  accounting:false and the existing server invariant forces the sub-flags off.

- Re-enabling the parent does NOT restore children. Flags are state, not
  history, and silently re-lighting a sub-feature with its routes and sidebar
  entries is the exact failure this bug is about.

Refs testplan REPORT.md #8 (Part 8, S9).
2026-09-01 16:29:33 +02:00
Paul Nothaft 3790156fc9 fix(accounting): let "bill to a customer" work with the portal off
CustomerAccountPicker returns null when customerPortal is off. That is right
for its original use -- the event form assigns portal logins that bypass the
gallery password -- but the Accounting flows reuse it as-is, so their required
"Client" field rendered a bare label with no input and the submit button could
never enable, with no explanation. Accounting-on + CRM-off is a valid,
UI-supported flag combination.

Took option (a): the bill-to-customer path does not depend on the portal.
POST /admin/expenses/:id/invoice is gated by requireExpenses + accounting.manage
only, and /admin/customers{,/search} are permission-gated rather than
flag-gated -- POST /admin/customers exists precisely to create passive,
portal-less customers "to attach a quote / invoice / gallery to". The
un-gated CustomerPicker used by the quote/bill/contract editors is the
precedent. (The comment claiming search 410s with the flag off was stale.)

Add portalAssignment (default true) so the gate and the portal-specific
label/help text apply only in event-assignment mode; the accounting call
sites render their own label. Event-form behaviour is unchanged.

Also fixes AccountingInboxPage's TriageModal, which has the identical
label-only failure on the rebill disposition from the same root cause --
outside the reported surface, but leaving it would half-fix the bug.

Refs testplan REPORT.md #7 (Part 8, S10).
2026-09-01 16:29:33 +02:00
Paul Nothaft 1d84c738d8 test: repair four stale backend suites
All four asserted contracts the product has since moved past. No genuine
product bugs behind any of them; assertions were tightened, not loosened.

adminAuth (3 tests): never mounted errorHandler, so ConflictError/
ValidationError arrived as empty Express defaults. The route also checks
username before email, so the "email conflict" fixture was hitting the
username branch. Mount the handler, fix the fixture, match the real response
shapes.

backupService.enhanced (12 tests): three stacked drifts -- the db mock had no
.returning(), so every runBackup threw at the insert; ensureDatabaseDumpForBackup
now lazily requires ./databaseBackup inside the run, which fails under
mock-fs; and the rsync path moved from exec(shell string) to
spawnAsync('rsync', args) with an isHostAllowed SSRF preflight. Also updates
getBackupStatus to its current shape (frontend aliases, nextScheduledRun null
when no schedule is enabled, #871).

adminSettings.logo: POST /logo gained requirePermission('settings.edit');
the hand-rolled db mock returns a bare Promise from select(), so the
permission lookup threw a TypeError into a 500. Mock the permissions
middleware alongside the already-mocked auth.

crmMintPaths (2 tests): macOS-only. The expected prefix was realpath'd while
the services persist under the raw STORAGE_PATH -- identical on Linux CI
(/var vs /private/var only diverges on macOS), which is why it passed there.
The comment justifying the realpath referenced process.cwd() behaviour the
services no longer have.

Refs testplan REPORT.md #22 (Part 1.2.01).
2026-09-01 16:29:03 +02:00
Paul Nothaft c5c5a6b0c8 fix(webhooks): write delivery timestamps as ISO strings
Applies the repo's documented Jest+SQLite guidance (CLAUDE.md) to the webhook
delivery path, which was the last one still passing raw Date objects into
knex writes. Under jest those store as the literal string "[object Object]",
so next_retry_at came back NaN and the retry/backoff test could not assert on
it. Production (PG, and SQLite outside jest) was unaffected.

Convert the timestamp writes -- and the `next_retry_at <=` due comparison,
which has to stay type-consistent with them -- to .toISOString(), matching
the existing precedent in downloadJobService.js.

Refs testplan REPORT.md #22 (Part 1.2.01).
2026-09-01 16:29:03 +02:00
Paul Nothaft 31ffbc8ae4 fix(users): give the cancel-invitation dialog a distinct confirm label
The cancelInvitation dialog type fell through to the generic
t('userManagement.cancel'), colliding with ConfirmDialog's own dismiss
button -- two buttons both reading "Cancel", where clicking the wrong one
does the opposite of what the user intends.

Reuse the existing userManagement.cancelInvitation key: "Cancel Invitation"
vs "Cancel" (EN), "Einladung abbrechen" vs "Abbrechen" (DE). No new key.

Refs testplan REPORT.md #19 (Part 3, I.04).
2026-09-01 16:28:31 +02:00
Paul Nothaft c19e944b99 fix(events): guard create-event submit against re-entrant submissions
Correction to the QA root cause: the submit Button has carried
`disabled={createMutation.isPending}` since 3424bd22, and it does disable
synchronously after the first click (validateForm's setErrors forces a
re-render that re-reads the mutation snapshot), so an ordinary double-click
could not by itself produce two POSTs.

What was actually missing is a re-entrancy guard in handleSubmit, so any
submission that never touches the button -- implicit form submission, a
programmatic requestSubmit, or two submit events dispatched in one task,
which is the likely shape of the QA repro -- still fired two mutate() calls
racing the same computed slug, one of which 500'd on events_slug_unique.

Add isSubmittingRef (matching the isMountedRef idiom already in this file),
cleared in onSettled. Test proves 2 submit events -> 1 POST.

Not done: turning the backend's raw 500 on events_slug_unique into a
graceful "event already exists" 409. That is an adminEvents.js change and a
separate concern from the client-side race.

Refs testplan REPORT.md #6 (Part 7.03).
2026-09-01 16:28:31 +02:00
Paul Nothaft 673f05556d fix(settings): don't crash on a fresh load before permissions resolve
On a hard navigation or deep link, usePermissions() starts out empty, which
filters every settings nav group down to nothing. allItems is then [], so
`allItems.find(...) ?? allItems[0]` yields undefined and `<activeItem.icon>`
threw -- sometimes into the error boundary, sometimes racing past it.
Reproduced 6+ times across the webhooks/moderation/slideshow/security/events
tabs; in-app SPA navigation never hit it.

Extend the file's existing early-return to `isLoading || permissionsLoading`.
activeTab lives in useState seeded from ?tab= at mount, independent of the
gate, so deep links still land on the right tab once permissions arrive.

Also null-guard activeItem before the section heading: a role holding zero
settings-tab permissions crashes identically even after permissions finish
loading, which the loading gate alone does not cover.

Refs testplan REPORT.md #11 (Part 3, J.08).
2026-09-01 16:28:21 +02:00
Paul Nothaft c2428aa23a fix(events): render a not-found state instead of hanging on a 404
EventDetailsPage gated on `if (eventLoading || !event)`. The backend returns
a clean 404 for a nonexistent id, but once isLoading settled false `event`
stayed undefined forever, so /admin/events/999999 sat on the loading spinner
permanently with no error state.

Destructure isError and split the gate: spinner while loading, then a
not-found Card. Reuses the existing `events.notFound` key (already used by
EventFeedbackPage for the same entity) and the Card padding="lg" not-found
shape from contracts/ContractDetailPage. No new i18n keys.

Refs testplan REPORT.md #5 (Part 7.02).
2026-09-01 16:28:21 +02:00
Paul Nothaft 3489610cb8 fix(analytics): warn about the CSP allowlist on every tracker provider
A self-hosted Umami/Rybbit domain configured in Settings -> Analytics is
always blocked by the static script-src allowlist, silently, with only a
console error. The amber CSP warning that explains this already existed but
was rendered only inside the "custom" provider panel -- not on the two
providers where an admin actually types a self-hosted URL.

Extract it to a local CspWarning and render it in the Umami and Rybbit panels
too. Both translation keys already exist in en.json/de.json.

Interpretation: the dynamic-CSP option was investigated and rejected as not
reachable for the header that actually governs these documents. In the Docker
deployment nginx.conf:58 does `proxy_hide_header Content-Security-Policy`, so
helmet's CSP and the res.setHeader CSP at server.js:445 are stripped before
they leave the stack -- nginx's static server-level CSP is the only one the
browser sees for the SPA documents the tracker is injected into. nginx.conf is
COPYied verbatim by the Dockerfile (only index.html goes through envsubst),
and the tracker URL lives in the DB rather than the environment, so making it
reflect the setting would need start-time templating plus a DB read. The CSP
itself therefore still has to be edited by hand; the warning now says so where
the admin can see it.

Refs testplan REPORT.md #18 (Part 3, B.02).
2026-09-01 16:23:40 +02:00
Paul Nothaft 5fa04e647e fix(categories): validate category name length instead of 500ing
photo_categories.name is varchar(100). Neither the input nor the route
checked length, so a 267-char name hit a raw Postgres "value too long",
came back as a 500, and the form silently stayed open with no toast.

Add isLength({ max: 100 }) to POST / and PUT /:id (the update route had the
identical gap) so it returns the route family's normal 400 { errors: [...] }
shape that the toast helper already renders, and maxLength={100} on the three
category-name inputs (create + inline edit in CategoryManager, create in
EventCategoryManager).

Refs testplan REPORT.md #4 (Part 7.01).
2026-09-01 16:23:28 +02:00
Paul Nothaft 6f7aa59fad fix(feedback): align word-filter severity vocabulary with the admin UI
WordFilterManager.tsx sends low/moderate/high/block; the validator only
accepted mild/moderate/severe, so 3 of the 4 UI levels 400'd with "Invalid
severity level" -- including "block", the strongest advertised tier.

Aligning isIn() alone would have made "block" accepted but semantically
inert: feedbackModeration.js branches on 'severe'/'moderate', so "block"
would fall through to the flag-only branch and behave as the weakest level.
Map the UI vocabulary onto the existing outcomes instead, per the legend the
UI itself renders: block -> reject, moderate/high -> needs approval,
low -> flag only.

'severe' stays an accepted alias in the blocking predicate so any row written
through the old validator (the field is optional, so a direct API caller
could have stored one) keeps blocking. No data migration needed: the column
is a bare varchar(20) default 'moderate' with no CHECK, no enum and no seed
rows, and 'mild' already lands in the flag-only branch that 'low' now means.

Refs testplan REPORT.md #2 (Part 3, J.11).
2026-09-01 16:23:22 +02:00
Paul Nothaft e18ab0d842 fix(upload): enforce the configured per-file size limit on admin uploads
getMaxFileSizeBytes() (general_max_file_size_mb, default 50MB) was only read
by adminSettings.js to display the value. The admin upload routes streamed
against a hardcoded ceiling instead, so the dropzone's "max. 50MB pro Datei"
was never enforced:

- adminPhotos.js POST /:eventId/upload    -> 10GB hardcoded
- adminPhotos.js POST /:eventId/chunked-upload/init -> 10GB hardcoded
- v1/events.js   POST /events/:id/photos  -> 100MB hardcoded

Resolve the cap per request (it is admin-configurable at runtime) and build
the multer instance from it, mirroring what gallery.js and adminTransfers.js
already do. The 400 names the configured limit and reuses gallery.js's exact
error string so the frontend surfaces it identically. getMaxFileSizeBytes()
clamps to MAX_ALLOWED_FILE_SIZE_MB, so the 10GB hard ceiling still bounds
everything.

gallery.js (guest upload) already enforced this correctly and is unchanged --
the report's claim that it did not is stale.

Interpretation: general_max_file_size_mb is a single per-file cap with no
photo/video split, and gallery.js already applies it blanket to guest video
uploads, so admin video uploads now share it too. On a default install that
means a 200MB video needs the setting raised first -- which is what the UI
has been advertising all along.

Refs testplan REPORT.md #1 (Part 7.06).
2026-09-01 16:23:04 +02:00