Commit Graph
281 Commits
Author SHA1 Message Date
Paul Nothaft 4944b9b3b6 fix(usage): scope the participation notice, highlight it, and call ignoring what it is
It appears on the dashboard and settings only. It is an invitation, not
an alert, so it belongs on pages an admin opens deliberately rather than
on top of whatever task they are in the middle of.

The activity ticker deliberately did NOT move with it. That ticker is
what triggers the daily rollup — the backend has no scheduler — so
tying it to the banner would have stopped reporting for an admin who
works on Events and never opens the dashboard, and stopped it entirely
for a participating install, where the banner never renders at all. The
effect stays mounted on every admin page and only the visible aside is
scoped. Two tests pin exactly that, because it is the kind of thing a
later refactor would helpfully "clean up".

Highlighted like the migration banner it sits under: tinted surface,
border, icon, a title line above the body. It was previously the same
neutral surface as the page behind it and read as filler.

"Not now" is now "Ignore". The button calls dismiss(), which persists
notice_dismissed on the server — the invitation never comes back. "Not
now" promised otherwise. The label says what happens and a hint says
where to join later.

Only shown while participation is off. activation_pending,
deletion_pending and identity_conflict are in-flight states the settings
page explains properly; inviting someone to join in the middle of their
own withdrawal would be worse than saying nothing.

The first version of these tests was worthless: the negative cases
asserted absence after waiting only for the status call, so the
component was still rendering null for want of data and every one passed
with the gates removed. They now wait for the query cache to fill.
Removing the route gate fails 4; removing the status gate fails 6.

Refs #1110
2026-09-05 21:42:21 +02:00
Paul Nothaft b53e5d97b4 feat: add opt-in product usage and feedback integration (#1110) 2026-09-05 12:59:06 +02:00
Paul Nothaft fc595409b4 feat(crm): newsletter campaigns behind a newsletters flag (#1264)
Part B of #1264. Flag off by default, so an install that never enables it
gains no route, no nav entry and no way to mass-mail.

A campaign is a body plus a recipient rule. Queueing one writes ordinary
email_queue rows (email_type 'newsletter', origin 'campaign', new
campaign_id), so retry, rendered_html, sent_at and error_message all come
from the existing processor rather than a parallel sender. Throttling
staggers scheduled_at; the processor loop is untouched.

Two rules the service enforces: no raw HTML is ever stored (sanitized on
write and again on render, idempotently), and opt-out is checked at queue
time AND again at send time.

Migration 199 adds email_campaigns, email_campaign_recipients,
email_queue.campaign_id, customer_accounts.marketing_opt_out(_at), and
the newsletters.view / newsletters.send permissions.

Three rounds of external review are folded in, including several that
would otherwise have shipped broken:

- Campaign rows never came due on SQLite. queueEmail writes a Date, which
  the sqlite3 binding stores as epoch ms; ISO text in the same column
  compares as TEXT against an INTEGER, and SQLite orders every INTEGER
  below every TEXT. The feature silently sent nothing there.
- The flag had no Settings card and no sidebar entry, so it could not be
  enabled through the UI at all.
- Consent is per ADDRESS, not per row: two accounts sharing an inbox meant
  unsubscribing stopped one and not the other, at both queue and send time.
- The unsubscribe GET mutated consent, so a mail-security scanner walking
  a campaign could have unsubscribed much of the list. GET now confirms,
  POST acts.
- The rate ceiling is clamped to the queue's real throughput (10/min), so
  the composer's estimate stops being wrong by up to 12x.

Closes #1264
2026-09-04 14:32:31 +02:00
Paul Nothaft bc90b4db62 fix(crm): label the two invitation conflicts and stop guessing after a 5xx
Codex review round 2 on #1274. Both findings are the same shape as round 1:
a message that asserts more than the response supports, and sends the admin
somewhere that makes it worse.

A 5xx is no longer treated as a clean failure. createInvitation inserts the
customer_invitations row and only then queues the email, with no transaction
around the pair, so a 500 out of the queueing step leaves an OPEN invitation
behind. Telling the admin "no invitation went out, retry" there walks them into
a 409 that still queues nothing. 5xx now joins the no-response case as
unconfirmed; a plain 4xx keeps the clean-failure message, because that is the
one shape where nothing was written.

The two already-active conflicts now say so. The send-invite route returns
CUSTOMER_ALREADY_ACTIVE from its own check, but createInvitation rechecks
customer_accounts afterwards and threw a bare ConflictError -- code CONFLICT,
indistinguishable from the pending-invitation conflict. An invitation accepted
between the two checks therefore landed in the pending branch, telling the
admin to cancel an invitation that acceptance had just closed. Both conflicts
in the service now carry a code of their own, so the client reads the code
rather than inferring from the status.

4 more tests. One round-1 test changed with the behaviour it pinned: its 500
now asserts the unconfirmed message, and a new 400 case covers the clean
failure it used to stand for.
2026-09-02 14:52:03 +02:00
Paul Nothaft 6bb12c6612 fix(crm): stop the invitation UI claiming more than it can know
Codex review round 1 on #1274. Three places where this branch replaced one
overclaim with another.

The badge tooltip said "Invitation sent". createInvitation inserts the
customer_invitations row and only then queues the email, with no transaction
around the pair, so an open invitation does not prove an email_queue row exists
-- and even when it does, delivery is the queue processor's business minutes
later. The tooltip now describes the invitation link itself and points at
System health, which is the same distinction #1273 draws.

Both conflicts are 409 and were being treated as one. Migration-era
send-invite returns 409 with code CUSTOMER_ALREADY_ACTIVE when the customer
already has a password, which happens if an open invitation for that address is
accepted between createDirect and sendInvite. There is then no invitation row
to cancel, so directing the admin to the Invitations tab points them at
something that does not exist. The code is now read before the message is
chosen.

A dropped connection or a timeout rejects with no `response` at all, and the
request may well have succeeded server-side. Saying "no invitation went out"
there sends the admin into a retry that then 409s, which is the same trap the
CUSTOMER_ALREADY_ACTIVE case sets. That branch is now explicitly unconfirmed
and says where the answer is.

3 more tests, all 3 failing before this commit.
2026-09-02 14:16:24 +02:00
Paul Nothaft 1b8e5f83d7 fix(crm): tell the admin whether a customer's invitation actually went out
Closes #1261.

"Invite customer" is two calls: createDirect, then sendInvite. The mode wiring
is right -- CustomerManagementPage passes mode='invite' and
InlineCustomerCreate does call sendInvite -- so the reported symptom is not a
missed branch. It is that nothing downstream distinguishes the outcomes.

Three things could not be told apart afterwards:

- The success toast claimed "portal invitation sent". sendInvite only queues an
  email_queue row; whether it was delivered is decided minutes later by the
  queue processor. The toast now says queued, and says what sends it.
- When sendInvite failed, the warning read "Invitation email failed -- retry
  from the customer detail page", which sounds like the mail bounced. What
  actually remains is a PASSIVE customer with no invitation at all, so it says
  that instead. A 409 is now separated out: that means an invitation for the
  address is already open and the RE-invite was refused, so the customer is
  invited and telling them to retry sends them the wrong way.
- The customers table rendered a customer whose invitation never went out
  identically to one the admin created as passive on purpose -- both showed
  only "Passive - admin only". Passive customers with an open invitation now
  show "Invitation pending", matched case-insensitively because
  customer_invitations lowercases the address while customer_accounts keeps
  what the admin typed. The invitations list was already being fetched for the
  tab; this only cross-references it.

Active customers are left alone: they have portal access, so a stale
invitation row for their address says nothing about them.

7 tests; the 5 that assert the new behaviour all fail before the change, and
the 2 negative controls pass on both sides.
2026-09-02 13:49:31 +02:00
Paul Nothaft 504a8b6fae fix(events): honour ?tab=, show a load error, and stop lying about uploads
Three warnings on the event-details surface.

?tab= deep links were ignored -- activeTab was hardcoded to 'overview' and
nothing read or wrote the search param, unlike Settings. Mirrors SettingsPage's
pattern exactly (module-level key list + type guard, seed useState from the
param, write-back and reflect-back effects), plus a snap-back for the `guests`
tab, which only renders when identity_mode is 'guest' -- a deep link to it on
any other event would otherwise show a tab bar with no content. The snap-back
is guarded on the query's isLoading so it cannot fire against undefined
settings and kill a legitimate deep link.

Worth recording: the two effects ping-pong infinitely if activeTab and a valid
URL tab disagree at mount, which is exactly the pre-fix state. The seeding is
what makes them agree, so the fix is also what makes the pair safe.

Offline Photos tab rendered the "no media uploaded yet" empty state on a
failed fetch, because `data: photos = []` makes a rejected query
indistinguishable from an empty one -- a user could reasonably think their
photos were gone. Threaded isError through and added a third branch, reusing
TaxReportPage's existing error-with-retry shape. Needed no new keys.

The spurious "Upload completed successfully" toast was in the host, not the
uploader: PhotosTab hung toast.success off PhotoUpload's onUploadComplete,
which is documented as a grid-refresh signal and fires as soon as the transfer
loop exits -- including when the request 400'd on the photo cap or every file
was rejected by magic-byte validation. PhotoUpload's own toasts were already
correct. Removed it, and added a real partial-success branch reporting the
actual split instead of a plain "Upload complete!".

The guest uploader had a variant of the same bug in a different place: its
toast is gated on successCount, but successCount++ fired on any resolved
request -- and the upload route answers 202 with count: 0 and an errors[]
entry when the file is refused. So a refused guest photo produced "Upload
completed successfully (1 photos)" and pushed a useless upload_id into the
processing poll. Now gated on count.

Refs testplan REPORT.md, ?tab= / offline-empty-state / spurious-toast warnings.
2026-09-02 09:43:10 +02:00
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 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 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 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 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 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 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 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 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 NothaftandPaul Nothaft 1ef2b3c85b feat(events): publish without notifying, and send the gallery email later (#1235) (#1241)
* feat(events): publish without notifying, and send the gallery email later (#1235)

Publishing queued the gallery_created email whenever any customer email
existed, with no opt-out. A photographer working with a client who has no
address yet — the Instagram-team case in discussion #1086 — had to type their
OWN address into the required field, publish, receive the client-facing email
themselves, and hand the link over by DM. Turning off
`event_require_customer_email` is not the answer either: that is global, and
the same photographer usually does collect addresses.

Two halves, because a checkbox alone is only half a workflow:

- `notify_customer` on publish, default TRUE. Absent means notify, so the v1
  API, an older frontend and any script keep behaving exactly as before. When
  false the gallery goes live and nothing is queued — not the gallery_created
  email, not the assigned-customer-account notice, not WhatsApp. Publishing
  still logs activity and still fires the event.published webhook, because
  those describe a state change rather than a message to a customer.

- POST /:id/send-gallery-email for an already-published gallery. Deliberately
  not restricted to galleries published quietly: re-sending is a normal thing
  to want (spam folder, wrong address since corrected) and refusing would push
  people to unpublish and republish, changing gallery state to work around a
  mail problem. Refused for a draft, whose link would not work yet, and for an
  event with no recipient.

The email composition is now one helper shared by both, so an email sent a
week later is identical to one sent at publish.

UI: a checkbox in the publish dialog (checked by default, hidden when nobody
would be notified anyway), and a "Send gallery email" action on published
galleries that have a recipient. The password field follows the checkbox —
unchecking it means nothing is being sent, so there is no plaintext to carry
and no reason to demand it. EN + DE strings.

7 integration tests. Two fail without the change, verified by forcing
notifyCustomer true and re-running; the rest pin the default, the draft and
no-recipient refusals, and that a gallery with no recipient still publishes.

* fix(events): make the publish dialog description follow the checkbox (#1235)

Caught by screenshotting it. With "Send the gallery email now" unchecked, the
paragraph above still read "...and sends the notification email to
[email protected]" while the control directly beneath it said nothing would be
sent — the dialog contradicted itself at exactly the moment the admin is
deciding whether anything goes out.

It now reads "No email will be sent — you can send it later from this page."
when the box is clear. EN + DE.

* fix(events): close six gaps in publish-quietly found by external review (#1235)

TWO CORRECTIONS TO MY OWN VERIFICATION FIRST. `npx tsc --noEmit` in frontend/
is a NO-OP — the root tsconfig is solution-style with references and no
include, so it checks nothing. Every "tsc clean" I claimed on this branch came
from that. The real check, `tsc -p tsconfig.app.json`, showed two TS2339s I had
introduced: `event.host_email` does not exist on the frontend Event type, which
the admin API normalises away. Both recipient checks now use `customer_email`.

PASSWORD ON SEND-LATER. The action promised to send the link and password but
always called the endpoint without one, so a protected gallery got the
"(set at creation)" sentinel — unusable — and this is most needed right after a
quiet publish, the path that never collects a password. New
SendGalleryEmailDialog asks for it, same shape and reasoning as the publish
dialog (#627). Galleries with no password skip the field.

WHATSAPP-ONLY GALLERIES COULD NOT PUBLISH QUIETLY. willNotify ignored
customer_phone, so a phone-only gallery hid the opt-out AND told the admin
nothing would be sent — while publish queued the WhatsApp anyway. Phone now
counts, with its own description line.

ASSIGNED-ACCOUNT NOTICES COULD NOT BE SENT LATER. The dialog promised it; the
endpoint rejected anything without an inline recipient. It now falls through to
the same customer-account path publish uses.

EDITORS COULD NOT SEE THE ACTION. The send button was nested inside the
events.archive gate, so the default editor role — events.edit, no archive —
never saw a button for an endpoint it is allowed to call. Separate gates now.

DEAD LINKS. The endpoint only checked is_draft, so an archived, inactive or
expired gallery would send a link the gallery middleware rejects. All three are
refused with a reason.

9 backend tests (2 new), 22 across the event suites. eslint clean on every
changed frontend file; crud.js keeps its 2 pre-existing errors.

* fix(events): persist the send-later password, and fix a long-standing isGalleryPublic misuse (#1235)

Round 2 of external review.

THE EMAIL COULD CARRY A PASSWORD THE GALLERY REJECTS. The send-later dialog
invites "or pick a new one", but the route queued that plaintext without
touching password_hash — so the customer got credentials that do not open the
gallery. Worse than the sentinel it replaced, because it looks usable. The
route now hashes and persists first, exactly as publish does.

isGalleryPublic TAKES A VALUE, NOT AN EVENT — and this is pre-existing.
normalizeRequirePassword returns its default for anything that is not a
boolean/number/string, so isGalleryPublic(event) is ALWAYS false and
`requirePassword` was always true. The publish dialog on main has demanded a
password for public galleries for exactly this reason. Both call sites now
pass event.require_password. Fixing the older one alongside mine rather than
leaving a broken copy one line above a fixed one.

ASSIGNED-ACCOUNT GALLERIES HAD NO BUTTON. The route falls through to the
customer-account notice when there is no inline email, and the publish dialog
promises that notice can be sent later — but the button only appeared with a
customer_email, making the promise unkeepable.

WHATSAPP CLAIM SOFTENED. Publish only queues WhatsApp when the config exists
and is enabled, which the dialog cannot see. It now says the customer is
notified there "if WhatsApp is configured" rather than asserting a send.

10 backend tests (1 new, covering the rehash). eslint clean on every changed
frontend file; crud.js keeps its 2 pre-existing errors.

* fix(events): don't reset the password for an account-only notice, hide unusable actions (#1235)

Round 3 of external review. The first is a harm my own round-2 fix introduced.

PASSWORD RESET FOR NOTHING. Round 2 persisted the supplied password before
knowing which mail would go out. For a protected gallery with no inline email
but assigned accounts, the dialog still demands a password, the hash was
rewritten, and then the fallback sent customer_gallery_assigned — which links
to the customer portal and never mentions a password. Net effect: the live
gallery password silently changed and everyone holding the old one was locked
out, in exchange for nothing. It is now persisted only when the mail that
carries it is actually being sent.

BUTTONS THE BACKEND WOULD REFUSE. The send action rendered for expired and
inactive galleries, and counted assigned accounts the endpoint filters out as
inactive — walking the admin through a dialog to reach a generic error toast.
The card now mirrors the endpoint's eligibility rules, and only active accounts
count toward having a recipient.

11 backend tests (1 new, pinning that the hash is untouched on the account
path), 24 across the event suites. tsc and eslint clean on the changed files.

* fix(events): make the send-later action agree with what the endpoint will do

Three findings from an external review round, all the same shape: the UI
predicted the endpoint's behaviour and got it wrong.

GET /admin/events/:id mapped customer_accounts without is_active, so the
"only ACTIVE accounts count" filter in OverviewTab compared undefined and
excluded nothing. A gallery whose only assignments were deactivated showed
the send action, and the endpoint then filtered every recipient and
returned 400. is_active is exposed now, and the count applies the same
predicate the fallback uses — active AND holding an address.

is_active is coerced through toBoolean rather than compared with === false.
On the default SQLite backend it comes back as 0, and 0 === false is false,
so an inactive gallery kept offering a send that parseBooleanInput then
rejected. Same class as #1028.

The password prompt is gated on there being an inline recipient. With no
customer_email the backend takes the account fallback, which sends
customer_gallery_assigned — a portal link that never mentions a password —
and deliberately skips the rehash. Asking for one there blocked the send
behind a six-character value nothing consumes, and the dialog's promise
that it would be rehashed was false.

Frontend suite: 291 passed. tsc and eslint clean.

* fix(events): don't mail a portal link to a customer who cannot sign in

Round-2 finding from the external review.

A passive customer — created directly and never invited — is an active
account with a real address whose password_hash IS NULL. The account
fallback happily mailed it customer_gallery_assigned, which links to
/customer/dashboard, and customerAuth rejects login without a hash: the link
goes to a door that will not open. Worse than failing, the route counted it
and reported success, so the admin believed the customer had been told.

getAssignmentsForEvent now derives can_sign_in (the predicate, never the
hash) and the three call sites share one canReceiveGalleryNotice helper —
publish, send-later, and the payload the UI predicts from all have to agree
or the button appears and then 400s. The UI mirrors it.

Sending passive customers an invitation instead of skipping them is the
better product answer, and a separate feature. Refusing visibly beats a
silent non-delivery in the meantime.

Test asserts the refusal; it fails without the can_sign_in arm.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-09-01 08:18:00 +02:00
Paul NothaftandPaul Nothaft f18bc568c8 feat(admin): shift-click range selection in the photo grid (#1212) (#1213)
* feat(admin): shift-click range selection in the photo grid (#1212)

Selecting photos was one tile at a time. Select All is all-or-one, so
're-assign these two hundred' meant two hundred clicks — which is how #1209
ran into it, re-categorising a large imported set.

Shift-click now selects the span from the last plain click to the tile under
the cursor, the way a file manager does.

It extends the selection rather than replacing it: the grid already lets you
accumulate tiles one at a time, so a range is another addition to that set. And
it only ever adds — deselecting by dragging a range back over itself is a
different gesture, and guessing at it would let a mis-aimed shift-click destroy
a selection instead of growing it. The anchor stays put across repeated
shift-clicks, so the second one re-aims the same span from the original point
instead of walking along behind the cursor.

The anchor carries the id of the tile it was set on, not just the index. An
index means a different photo after a filter or a re-sort, and a range measured
from a stale anchor would select the wrong span with nothing to show for it;
the write checks the anchor still points where it was set and falls back to a
plain toggle when it does not. Validating at use rather than clearing on every
list change means a background refetch, which hands back an equal list, leaves
the anchor usable.

Seven tests, four of which fail without the change; the other three pin the
plain-click and no-anchor behaviour that must not move.

* fix(admin): clear the range anchor whenever the selection is cleared (#1212)

External review. Cancel Selection, Deselect All and a successful bulk move or
delete all emptied selectedPhotos and left the anchor behind. The anchor is
invisible, and the list is usually unchanged, so it stayed valid — the next
shift-click reached back into a selection session the user had already ended
and selected a range they never started.

Cleared at all four reset points now. Test fails against the un-fixed code.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-28 08:27:46 +02:00
Paul NothaftandPaul Nothaft 5c85e0c0e4 fix(guests): surface duplicate guest registrations, and stop making so many (#1210) (#1216)
* fix(guests): surface duplicate guest registrations, and stop making so many (#1210)

Guest registration always inserts. A client whose token expired — or who opens
the gallery on a second device — becomes a new gallery_guests row, and their
likes and favourites split across the copies. The photographer's 'final
selection' is then only trustworthy if somebody notices two Tinas with half the
picks each.

Two halves, neither of which touches the registration path.

**Say which rows are the same person.** Merging already worked, endpoint and UI
both; nothing said WHICH rows to merge. The guests list now marks each row with
the others sharing its email and returns a count for the banner, and the admin
list offers the group straight to the merge mode that already exists.
Case-folded and trimmed, because the same person types Tina@ one day and tina@
the next and both read as distinct rows. Email only — two guests called Anna
are not evidence of anything, and rows without an email are not grouped at all
since require_name_email is off by default and a shared link produces plenty of
them.

It preselects rather than merges: which row survives decides the name and
verification state the merged guest keeps, and that is the admin's call.

**Create fewer of them.** The guest token was 24h and every call site took that
default, so even the same browser lost its identity after a day of inactivity.
Now 30 days, GUEST_TOKEN_TTL to override. A guest token is scoped to one event,
carries no admin capability, and the gallery is already behind whatever
protects it — 30 days is the shape of a real proofing cycle.

Deliberately NOT done: reusing a guest row when a typed email matches, which
the report suggests first. It would let anyone who knows an address inherit
that person's identity and selections, and answering differently for a known
email would leak which addresses are in the gallery — the thing
/guest/recover already goes out of its way to avoid. Prevention at the entry
path needs the verification round-trip, which is a separate decision about
friction.

13 tests; 8 of the 9 backend ones fail without the change. The frontend ones
caught a real bug while being written — the new useMemo sat after the loading
early-return, so the hook count changed between renders.

* fix(guests): merge must not strand a pending invite (#1210)

Three findings from external review of #1216.

**A merge could kill an emailed invite link.** Creating an invite inserts a real
gallery_guests row, so an admin who pre-mints one and then sees the guest
self-register has two rows sharing an email — which this feature now points out
and offers to merge. Redemption resolves guest_invites.guest_id with
is_deleted: false, so merging soft-deleted the row the link pointed at: the
client got 404 guest_missing while the invite dialog still showed the invite as
Pending. Nothing anywhere said the link was dead. Unredeemed, unrevoked invites
now move to the survivor first. Spent ones stay put — a redeemed invite records
who redeemed what, and retargeting it would rewrite that.

**The preselection silently chose the survivor.** performMerge keeps
mergeSelection[0], and the group was handed over in API order, which is
newest-first — so Review then Merge discarded an older, email-verified row
holding most of the picks in favour of a fresh re-registration. The proposal is
now ordered deliberately: verified first, then whoever holds the most feedback,
then the oldest. Still only a proposal, and the confirmation now names the
survivor by email as well as name, because duplicates share a name and 'Merge 2
guests into Tina?' said nothing.

**duplicate_of was quadratic.** Every row carried the other n-1 ids, so a group
of n serialised n² of them — and nothing consumed the list: the UI asked only
whether a row was in a group, then regrouped by email itself. Replaced with
duplicate_group, the normalised email, which keeps the payload linear and the
case/whitespace folding in one place instead of reimplemented on the client.

Two new backend tests for the invite paths, one frontend test asserting the
merge call keeps the verified row. The invite test fails against the un-fixed
code.

* fix(guests): keep guest-controlled input out of who survives a merge (#1210)

Round 2 of external review on #1216.

**The survivor ranking used an attacker-controlled signal.** Preferring
whoever holds the most feedback looked like the obvious tiebreak and is exactly
the wrong one: registration does not verify the address, so anyone who knows a
guest's email can register with it, mark enough photos to out-rank the real
person, and be preselected as the survivor. An admin accepting a confirmation
between two rows with the same name and email would then move the victim's
picks onto an identity whose token the visitor still holds. distinct_photos is
guest-controlled and has no business deciding this. The ranking is now
email_verified_at then created_at — both server-set.

**A merge could make the survivor unrecoverable.** Rows are grouped with case
and whitespace folded out, so a merge can be proposed between [email protected]
and [email protected]. /guest/recover lowercases what the guest types and then
matches on equality, so a survivor left holding the raw value can never be
recovered by email again. The kept row's address is now canonicalised during
the merge. Both write paths normalise today, so this covers rows that predate
that — which are exactly the rows case-folded grouping surfaces.

Two more backend tests. The residual, stated plainly: an admin can still merge
two unverified rows in either order. What is gone is the tool ranking them by
something a visitor controls.

* fix(compose): pass GUEST_TOKEN_TTL through to the backend (#1210)

The override was documented in .env.example and could never take effect: the
backend service takes an explicit environment list, so a variable not named
there never reaches the container. An operator following the documentation
would have shortened the guest session and seen nothing change.

docker-compose.production.yml uses env_file: .env and already passed it
through; docker-compose.dev.yml is gitignored, so only this file needs it.

* fix(guests): the admin picks the merge survivor, the tool does not (#1210)

Fourth review round on the same point, and the right conclusion is that there
is no correct automatic answer.

Every rule tried was wrong somewhere. Most-feedback is guest-controlled — the
address is never verified at registration, so anyone who knows it can register
and mark photos until they out-rank the real person. Oldest-first, the
replacement, is worse for the ordinary case: when a token expires the OLD row
is the dead identity and the new one is the visitor's live session, so keeping
the oldest deletes the identity they are actually using, and the frontend holds
that deleted guest in sessionStorage without clearing it on a 401. Registration
timing is visitor-controlled too.

The data does not say which row is really the person. So the UI asks: merge
mode gains a Keep column, the button stays disabled until a row is nominated,
and only rows included in the merge can be nominated. The group is still
preselected — finding the duplicates was always the point — but nothing about
who survives is decided by sort order any more.

This also makes the claim in the PR description true. It said the admin decides
which row survives; until now the preselection quietly decided it for them.

Two rewritten frontend tests: the merge is blocked until a survivor is chosen
and then keeps exactly that row, and a row outside the group cannot be
nominated. The test i18n mock now interpolates, so aria-labels are queryable by
their rendered text.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-28 08:27:15 +02:00
Paul NothaftandPaul Nothaft 22e00f80b6 feat(feedback): a third identity mode with one shared colour tag per photo (#1197) (#1208)
* feat(feedback): a third identity mode with one shared colour tag per photo (#1197)

Split out of #1178, where @boergu asked for a colour tag with no identity
dimension at all: not everyone sharing a device's state, but everyone — on any
device — sharing the PHOTO's state. Guest A marks it green, guest B later marks
it orange, and the tag simply becomes orange. One collaboratively-agreed verdict
per photo instead of per-person tallies.

identity_mode gains 'shared'. The mode is scoped to the colour tag: likes,
ratings, comments, favourites and reactions stay per-visitor exactly as in
'simple', because that is what was asked for and widening it would change what
every other control means.

Stored as an ordinary photo_feedback row under a reserved identifier rather
than as a column on photos. That is what keeps the rest of the system working
untouched — the per-colour tally simply has exactly one entry, so
dominant_color_label, color_label_count, the admin colour filter and the
XMP/CSV export that #745 reads all keep their existing shapes, and no consumer
has to learn a second one. The identifier cannot be claimed: real ones are
sha256 hashes or server-minted UUIDs, and the per-guest write path rejects it
outright.

Last write wins, inside a transaction that locks the photo row. Without the
lock two guests tapping different colours in the same instant both read 'no
tag', both insert, and the photo ends up carrying two shared tags — the
per-guest tally this mode exists to remove. Re-sending the colour already on a
photo clears it, from any guest: the same toggle every other colour path uses,
and the only way to remove a tag without inventing a second control.

Switching modes is non-destructive. Existing per-guest labels are left alone
and simply not read while shared is on; the shared tag starts empty rather than
collapsing marks nobody agreed on, and switching back restores every original
exactly. An event can hold both sets, only one of which is live.

The tag stays visible with show_feedback_to_guests off — it arrives through the
per-viewer channel, being the photo's own state rather than someone else's
opinion — while the per-colour tallies stay hidden. The colour filters answer
from it for the same reason, so a gallery with sharing off cannot show colours
on tiles that no filter can find.

Attribution is gone by design, and the settings panel says so before an
operator picks the mode.

Decisions (1), (4) and (5) from the issue were settled up front, as it asked.
Decision (3) turned out not to need anything: guest colour filters already read
my_color_label, and the admin's my_color_labels filters photo_admin_marks
(#1183), not guest identity — so nothing collapses on either side.

* fix(feedback): shared mode saves on Postgres, and dormant labels stay dormant (#1197)

Three findings from external review, all confirmed against source before fixing.

**The mode could not be saved on Postgres at all.** Migration 078 created
identity_mode with a CHECK constraint pinned to ('simple','guest'), guarded on
`client === 'pg'` — so SQLite never has it and no SQLite test can see it, while
the database every default production install runs rejects the new value
outright. Migration 192 drops and re-adds the constraint with 'shared' included;
its down() resets any event using the mode to 'simple' first, or the narrower
constraint could not be restored. Verified against a real Postgres on a scratch
database: the insert fails before, succeeds after, up() is re-runnable, and
down() puts the old constraint back.

**Dormant labels were still being read.** Switching modes is deliberately
non-destructive, which leaves both sets of colour labels in the table with only
one live — and every read that did not say which set it meant kept counting the
other. The per-colour tallies, color_label_count, the admin grid badge, the
XMP/CSV export, both admin colour filters and the guest colour filter all saw
labels the mode does not show; switching back exposed the shared row as an
anonymous other guest's dot. The settings panel promises these are 'kept but not
shown', and that has to mean every surface, not just the badge. Scoped at the
source — the two count helpers resolve the mode themselves — so the admin grid
and the export are fixed without touching either.

**The create form's identity mode was dropped.** CreateEventPage has always
rendered the chooser and the create route never read it, so a gallery created as
'guest' came out 'simple' and had to be set again on the event afterwards. A
pre-existing bug that adding a third option made worse; threaded through now,
which fixes it for all three modes.

Six regression tests, each verified to fail against the un-fixed code.

* fix(feedback): keep every colour surface consistent across a mode change (#1197)

Second review round, four findings, all confirmed in source first.

**Stored counters went stale on a mode switch.** photos.color_label_count is
denormalized and recomputed on feedback writes, so changing identity_mode —
which changes nothing about the rows, only which of them are live — left the
old mode's totals on the tiles, the admin grid and the filter summary until
each photo happened to be touched again. On a finished gallery that is never.
Recounted for the event when the mode actually changes, as two statements
rather than a per-photo recompute: four of the five counters cannot have moved.

**Duplicating an event dropped the mode**, the same shape as the create-form
bug from the last round — a gallery cloned to reuse its proofing setup came
back in 'simple'.

**The event feedback summary counted dormant labels**, inflating total_feedback
in the admin analytics and the guest /feedback-summary while every other
surface hid them.

**The swatch trusted its optimistic guess over the server.** In shared mode the
tag belongs to the photo, so another guest can move it between this viewer's
last read and their click: a viewer still showing green clicks green, the
server sets green because the tag had become red meanwhile, and the optimistic
'same colour, so clear' blanked the swatch against a server that holds one. The
response already says which happened, so it is used. The per-guest modes are
unaffected — only the guest can move their own label, so guess and answer
always agreed there.

Three regression tests, each verified to fail against the un-fixed code.

* fix(feedback): shared tag is not a participant, and the keyboard path reconciles too (#1197)

Third review round, two findings.

**feedback_count counted the shared tag as a guest.** It is COUNT(DISTINCT
guest identity) across all feedback types, and the reserved identifier looked
like a person: a photo with one rating and a shared tag reported two. The
column is exported as rating_count (photoExportService), so merely tagging a
photo inflated its rating count in the CSV and JSON exports.

**The lightbox keyboard path still trusted its own guess.** The reconciliation
from the last round covered clicks through PhotoColorLabels, but the proofing
shortcuts call PhotoLightbox.submitColorLabel directly and set local state from
a locally computed toggle. That is the path a proofing client actually uses, so
it had the divergence the previous fix was for: another guest moves the tag,
this viewer presses the key, the server sets a colour and the swatch blanks.
Both branches now read the outcome off the response.

One regression test, verified to fail against the un-fixed code.

* fix(feedback): identity-mode lookup must survive a migration-time caller (#1197)

updatePhotoFeedbackStats is called from migrations as well as from the request
path — migration 186's duplicate-photo dedupe (#1162) recomputes the survivor's
totals — and a migration runs against a half-built schema where
event_feedback_settings need not exist yet. The new inner join threw there,
which took the whole stats update down with it, so the reparented rows were
never counted and eight assertions in the 186 suite failed.

Falls back to 'simple', which is the right answer rather than merely a safe
one: an install with no feedback settings table has no event in shared mode, so
the non-shared scope is exactly correct.

Caught by CI, not by me — I had been running affected suites rather than the
full one after each review round.

* fix(feedback): atomic shared-tag write, scoped feedback list, safe PG fallback (#1197)

Round 4 of external review, and one of the three is about the fix I made for
the CI failure two rounds ago.

**The identity-mode fallback could poison a Postgres transaction.** The join
was wrapped in try/catch so a migration-time caller with a half-built schema
would fall back to 'simple'. On Postgres a failed statement aborts the entire
transaction, so catching it and carrying on left the caller's trx poisoned and
the aggregate that follows failed with 'current transaction is aborted' —
defeating the very compatibility the fallback was added for. It now asks
whether the table exists before issuing the join, which is safe to ask and
aborts nothing. Memoised once true, since a table does not un-create itself and
this sits on the feedback write path.

**The shared-tag stats were recomputed after the commit.** A failure there
returned 500 for a tag that had already been written, so the client reverted
its swatch and the next tap on the same colour toggled the committed tag off
instead of setting it. Two concurrent writers could also race their aggregate
updates. Recomputed inside the transaction now, while the photo row is still
locked.

**The raw feedback list still carried both label sets.** Only the tallies and
my_feedback had been scoped, so a dormant per-guest label was still visible to
anyone reading the list — and with sharing off it came back flagged is_mine.
getPhotoFeedback now filters colour labels to the active set.

One test for the list; the migration suite that caught the original CI
regression still passes.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-28 08:19:41 +02:00
Paul NothaftandPaul Nothaft a490b64954 fix(admin): the "Uncategorized" photo filter returns every photo (#1211) (#1214)
The dropdown offered the filter and it never worked. It rendered as
`value="0"`, and adminPhotos.js skips '0' outright:

    if (category_id !== undefined && category_id !== '' && category_id !== '0') {

so no category condition was applied and the whole event came back. Four lines
below that guard sits the branch that does the work, keyed on the literal
'uncategorized' — which nothing was sending. The two ends have never agreed on
the wire value, and neither is wrong on its own.

It fails silently, which is why it went unnoticed: a full list reads as 'the
filter found nothing to narrow' rather than 'the filter did not run'.

Send what the backend already understands rather than teaching it a second
spelling. The onChange passes non-numeric values through unchanged, so the
string arrives intact.

Reported in #1209 by someone re-categorising a few thousand photos imported
without a category — the filter is the first step of filter, Select All, bulk
assign, so its failure takes the whole path with it.

Tests both ends of the contract, since the bug was the pairing rather than
either half: the frontend emits 'uncategorized', and the endpoint answers it
with only the null-category rows. The backend test also pins that 0 means no
filter, so a future change there has to be a decision rather than an accident.

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-28 08:05:32 +02:00
Paul NothaftandPaul Nothaft 0a36ca6056 feat(gallery): folders that contain photos instead of filtering them (#1160) (#1161)
* feat(gallery): folders that contain photos instead of filtering them (#1160)

A category has always been a filter: its photos stay in the root grid and
picking the category narrows that grid. D#1086 asked for the opposite — put
the selects in a bucket and get them OUT of the main grid, so the client sees
the 40 finals and clicks through for the other 200.

`photo_categories.is_folder` makes that a per-category choice. One column is
enough because the neighbouring features already built the substrate:
hero_photo_id (#163) is the folder cover, allow_downloads (#640) is per-folder
download rules, display_order + event_category_order (#782) is folder ordering,
and photos.category_id being single-valued is already folder semantics.

Deliberately no parent_id. "Root -> Selects folder" is depth one, i.e. plain
containment; folders-inside-folders waits until someone asks.

Containment lands in the one useMemo where the category filter was already
applied, and the tiles render above the grid rather than inside a layout, so
all eight gallery layouts inherit folders without eight implementations.
Scope drives the counts too, so root reports 40 photos and not 240.

`?folder=<slug>` carries the open folder, preserving token and admin_preview,
so a folder is linkable and Back walks out of it instead of leaving the gallery.

Defaults to false, so every existing gallery keeps filtering exactly as before.

Folders are organisational, not access control: a foldered photo is served by
the same per-photo auth as any other. A test pins that, so nobody later mistakes
containment for a security boundary.

* feat(gallery): download a folder on its own, and label folders when moving photos (#1160)

Downloads now cover both halves of the requirement:

  - the gallery-wide "download all" keeps zipping every photo including the
    foldered ones (verified: 62 files), so a folder never quietly removes
    photos from the client's one-click download;
  - inside a folder there is a "Download folder (n)" button that zips only
    that folder, once (verified: 20 files). It reuses /download-selected, so
    there is no new endpoint and no second zip-building path.

The button honours the per-category opt-out (#640) both ways: a folder with
allow_downloads = false renders no button, and individual photos that opted
out are excluded from the id list rather than silently 403-ing mid-zip.

Moving photos into a folder already worked — a folder IS a category, so the
existing bulk "move to category" flow does it. What was missing is that a
folder and a filter category looked identical in that dropdown while having
very different consequences, so folder options now read
"<name> (folder — hidden from the main grid)". Threading is_folder through to
the dialog needed the admin category prop types widened; the data was already
on the wire.

* fix(gallery): folders were unreachable in the full-bleed layouts (#1160)

Containment comes from `filteredPhotos`, which BOTH layout branches use, but the
tiles were only rendered in one. On a Premium or Story gallery the foldered
photos therefore disappeared from the grid with no tile to click — moving 200
selects into a folder effectively deleted them from the client's view. The
folder nav is now built once and rendered by both branches, so a branch can't
hide photos without also offering the way in.

Those two layouts are edge-to-edge by design, and a block of cover cards above
the hero wrecks the opening they exist for, so they get a compact chip row
(`Folders  [icon Selects 20]`) instead. It only renders when the gallery
actually has folders, leaving every existing full-bleed gallery byte-identical.

Also scopes the people strip to the photos on screen. `face_count` comes from
/people and spans the whole event, which contradicted the grid in two ways:
inside a folder a face read "12 photos" but filtered down to the handful in that
folder, and at root a person whose photos ALL lived in a folder showed up and
filtered to nothing — a dead chip. Recounted from `photo.person_ids`, which is
already what the filter itself uses, and zero-count people are dropped. No
backend change; the ids were on the wire already.

Verified in the running app: Premium renders the chip and navigates; the
lightbox counter inside a folder reads "1 / 20", not 1 / 62; the people strip
inside the folder drops from 12/11/4 to the one face actually present.

* fix(gallery): folder edge cases found in external review (#1160)

Seven issues, all verified against the code before fixing.

Unreachable folders (the serious one). `adminCategories` derives a slug with
`[^\w\s-]` stripping and `\w` is ASCII-only, so a valid name in a non-Latin
script slugs to the empty string — `Избранное` and `日本語` both do. Keying the
URL on the slug meant such a folder wrote no param and resolved to nothing: its
photos left the root grid with no way back to them. This repo ships ru and sl
locales, so that is a reachable state, not a hypothetical. Folders are now keyed
by `folderKey()` — slug when there is one, id otherwise.

Stale selection across a scope change. The grid clears its selection when
`categoryId` changes, which is already null at root, so a selection made outside
a folder survived into it and the toolbar would offer to download (or a client
to hide) photos no longer on screen. Cleared on both explicit navigation and
popstate.

Dead category chips inside a folder. The filter branch ignores
`selectedCategoryId` while a folder is open, so the chips did nothing when
clicked. They are no longer offered there.

"No photos found" beside folder tiles. A gallery whose photos all live in
folders rendered the tiles and then the grid's empty state directly under them,
claiming the gallery was empty while pointing at its contents.

Counts that contradicted the grid. The filter bar and both people surfaces were
still counting over every event photo, so a chip could advertise a total the
scoped grid would never produce. All now count over `scopedPhotos`.

`!!` on a validated boolean. express-validator's isBoolean() accepts the STRINGS
"false" and "0", and `!!'false'` is true — a form-encoded caller asking for a
filter would have silently got a folder. Uses the existing parseBooleanInput.

Duplicate-event dropped folder-ness. The category clone selected only name, slug
and is_global, so every folder in a duplicated gallery came back as a filter.

* fix(gallery): folder scoping gaps from external review round 2 (#1160)

Cache mutation, introduced by this branch. `photosInScope` returned the caller's
own array on the no-folders fast path, and `filteredPhotos` sorts in place — so
every gallery WITHOUT folders was reordering the React Query cache for every
other consumer of `data.photos`. The pre-branch code cloned; now it always does.

Colliding folder keys. UNIQUE is (slug, event_id), so a global folder and an
event folder can share a slug, and the gallery merges both scopes. Keying on the
slug alone meant the second folder resolved to the first and its photos could
not be opened. The id is now always part of the key.

"Download folder" downloaded a subset. Search, feedback, media and people
filters stay active when entering a folder, and the ids came from
`filteredPhotos` — so the button promised the folder and delivered whatever the
filter had left, or vanished when it matched nothing. Built from `scopedPhotos`.

Folder-only root misdetected. `rootIsFoldersOnly` tested `filteredPhotos`, so a
search matching none of the loose root photos looked folder-only and swallowed
the no-results message. Tests the unfiltered scope instead.

Empty state in the full-bleed layouts. The Premium/Story branch was missing the
folder-only guard the standard branch got, so a folder-only gallery printed
"no photos found" under its own folder chips.

Filter metadata still event-wide. `availableMediaTypes` and `colorLabelCounts`
counted over every photo, so the sidebar could offer a Video or colour chip for
something that only exists in another scope — always filtering to nothing. Both
derive from `scopedPhotos`, which moved above them for that reason.

* fix(gallery): honest folder downloads and scoped totals (#1160)

Silent truncation. /download-selected slices the id list to 500 server-side
(gallery.js:1776), so a folder larger than that delivered a truncated archive
under a button promising the whole thing. The limit is now mirrored client-side:
the request carries only what the server will honour and the label says
"Download first 500 of 620" instead of claiming the folder.

Gallery shell was being unmounted. Suppressing the folder-only empty state by
skipping PhotoGridWithLayouts took the hero, event title, logout and download
controls with it in the full-bleed layouts, since those render from inside that
component — a folder-only Premium gallery collapsed to a bare chip row. Replaced
with a suppressEmptyState prop so only the message goes.

Two more counts that could contradict the grid: the sidebar's total and the
people match-count denominator ("42 of 62" at a root that holds 42). Both scoped.
The client-access visible/total stat is deliberately left event-wide — that one
is a photographer-facing statistic about the gallery, not a filter affordance.

Stale admin cache. EventDetailsPage caches the same category rows under
'admin-event-categories' and hands them to the Photos tab's move dialog, so
toggling a folder left that dialog labelling it a plain category until remount.
Both keys are invalidated now.

Not changed, after challenging the review: select-all in the full-bleed layouts
stays scoped to the displayed photos. Wiring it to the full event would select
photos that are not on screen, contradicting containment and reviving the stale
selection bug. The reviewer withdrew the finding on that basis. The residual UX
gap — no one-click "everything" in Premium/Story once folders exist — is real
and noted on the PR.

* feat(gallery): one-click download-everything in the full-bleed layouts (#1160)

Premium and Story have no header download button — their only gallery-wide
download is select-all followed by download-selected, and select-all is
correctly scoped to what is on screen. Once folders exist that left no single
way to get the whole gallery. The folder strip now carries an event-wide
"Download all photos" that hits /download-all (which has always included
foldered photos), shown at the root only, since inside a folder the breadcrumb
already offers that folder's download.

Also lands the capped folder label that was written but never actually applied
in the previous commit — the edit silently didn't match, so a 510-photo folder
still advertised "Download folder (510)" while the request was capped to 500.
Caught by building a real 510-photo folder rather than trusting the reasoning:
it now reads "Download first 500 of 510". A unit test pins the client constant
to the backend's cap so the two can't drift apart unnoticed.

* fix(gallery): remount layouts on folder change, and stop scoped counts leaking into event-wide controls (#1160)

Carousel crash. Layout state is only meaningful for the photo set it was built
against, but the layout instance was reused across a folder change. In carousel
mode an index valid at root (31 of 42) indexes past the end of a smaller folder,
and CarouselGalleryLayout does `photos[currentIndex]` unguarded. The grid is now
keyed by the open folder, so a scope change remounts: verified live, 31/42 at
root becomes 1/20 on entering the folder instead of dereferencing undefined.
The key also avoids driving one instance between the empty and non-empty render
paths, which matters because that component's `photos.length === 0` early return
sits ABOVE four useState calls — a pre-existing conditional-hook hazard this
feature would otherwise have made reachable.

Nested empty state. suppressEmptyState only silenced PhotoGridWithLayouts' own
early return; the Premium and Story layouts have their own noPhotosFound return,
so a folder-only root still printed "no photos" under the tiles proving
otherwise. The flag is forwarded to them.

Download All was labelled from the wrong number. The sidebar's total is now the
folder scope (correct for the category list), but the same value labelled and
disabled Download All — which fetches the event-wide archive. On a folder-only
root that showed 0 and refused a valid download. Split into a separate
downloadAllTotal.

Feedback chip counts. likeCount, favoriteCount and ratedCount still counted over
every event photo while clicking them filters the scope, so a chip could promise
matches from another folder and deliver none.

* fix(gallery): premium crash, story Download All, and empty-mount hazard (#1160)

ReferenceError blanking the Premium gallery — my own bug from the previous
commit. The suppressEmptyState prop landed on the nested PhotoCard instead of
GalleryPremiumLayout (both destructure `allowDownloads = true`, and the patch hit
the first one), so the layout's guard referenced an identifier that was not in
its scope. A folder-only Premium root threw instead of rendering. Now declared
and destructured on the layout, and exercised: 62 photos all foldered renders
the tile, the hero and the download button with no message and no throw.

Story's footer "Download All Photos" built its id list from the `photos` prop,
which is now the folder scope — so it silently omitted every foldered photo
while still calling itself Download All. Layouts now receive an event-wide
downloadAllIds and prefer it. Premium's equivalent control is a select-all, not
a download, and stays scoped by the same reasoning as before.

Empty-array mounts. Suppressing the empty state meant the layout got mounted
with photos=[], and CarouselGalleryLayout returns before four of its useState
calls — driving one instance between empty and non-empty changes its hook count
and React throws. Only the full-bleed layouts, which own the hero and logout
chrome, are now mounted empty; every other layout renders nothing instead.

* fix(gallery): keep the shell and drop dead controls on folder-only roots (#1160)

Skipping the empty layout took the hero and welcome message with it. The early
return sat above both, so a gallery whose photos all live in folders lost its
configured hero and welcome copy at the root and only regained them after
opening a folder. Only the layout child is skipped now; the surrounding shell
renders as it always did.

The filter bar was gated on the event-wide photo count, so a folder-only root
still rendered search, sort and the feedback chips with nothing in scope for
them to act on — the same empty filter row discussion #317 asked us to remove.
Gated on the current scope.

Story's download toast counted `photos` while the request now carries the
event-wide id list, so it could announce "Downloading 0 photos" and then fetch
the whole gallery. Counts the ids it actually sends.

* fix(gallery): clear the person filter on scope change, and fix two folder-only shell details (#1160)

A person selected in one scope can have no photos in the next. peopleInScope
drops them from the strip, so the filter stayed active with nothing left to
clear it — and the full-bleed layouts have no people UI at all, leaving a guest
staring at an empty grid with a reload as the only way out. Cleared on both
folder navigation and popstate, alongside the category selection and the photo
selection already reset there.

Story's hero announced "0 Photos" on a folder-only root, since it derives that
stat from the scope it renders and the scope is empty by definition there.
Falls back to the event-wide count.

Premium's integrated Download All is a select-all over the current scope, so on
a folder-only root it was a visible control that did nothing when clicked. It is
hidden while the scope is empty rather than left dead.

* fix(gallery): uncapped Story download, protected folder covers, scoped people order (#1160)

The event-wide id list I added for Story's "Download All Photos" made it worse,
not better: /download-selected caps at 500 ids server-side, so a gallery larger
than that silently shipped a partial archive under a button promising all of it.
Replaced with an onDownloadEverything callback that runs the whole-gallery
/download-all path, which has no cap. eventPhotoCount now carries the number
Story needs for its hero stat, so no id list crosses the boundary at all.

Folder covers bypassed image protection. A cover is a real gallery photo, but it
was rendered through AuthenticatedImage's defaults while every photo tile passes
the gallery's protection settings — so on a gallery configured for canvas
rendering or maximum protection, each cover was an ordinary blob-backed <img>.
The tiles now receive and apply the same props as the grid.

People kept /people's event-wide ordering after their counts were rescoped, so a
folder's most-photographed person could sort behind someone with a single match
— and PeopleStrip only shows the first twelve inline. Sorted by the recomputed
count, with a test.

* fix(gallery): folder covers honour maximum protection (#1160)

Maximum protection implies canvas rendering even when the independent
use_canvas_rendering toggle is off, which is its default — every other gallery
image path spells that out as `useCanvasRendering || protectionLevel ===
'maximum'` (PhotoGrid, PhotoLightbox, HeroHeader, JustifiedGalleryLayout). The
folder cover forwarded the raw toggle, so on a maximum-protection gallery with
the toggle untouched the cover fell back to a blob-backed <img>. Matches the
convention now.

* fix(gallery): don't let download-everything bypass a category opt-out, and keep folder links alive across renames (#1160)

The whole-gallery route serves a prebuilt zip containing EVERY event photo with
no per-category filter — gallery.js says so itself, next to
bumpEventDownloadCounts, as a known pre-existing gap. Wiring Story's footer to
that route therefore converted a path that DID enforce the #640 opt-out into one
that doesn't, and because the callback was supplied unconditionally it affected
Story galleries with no folders at all.

The same reasoning applies to the download-everything button this branch added
to the full-bleed folder strip: it routes there too, so on a gallery with a
restricted category it would have handed over exactly the photos the opt-out
withholds. Both are now withheld whenever any category opts out; those galleries
keep the per-folder download, which enforces it. Verified both ways — the
control disappears with a restricted category present and returns once the
restriction is lifted.

Folder links also survived a rename badly: the key embeds the slug for
readability, and renaming a category rewrites that slug, so a URL already sent
to a client stopped matching and silently opened the gallery root. Resolution
now keys on the trailing category id, which does not move.

* fix(gallery): make folder navigation clickable in the Story layout (#1160)

Story renders `.story-nav` as `position: fixed` across the top of the viewport
at z-index 50, and the folder strip sits in exactly that band — so the nav
swallowed every click on the chips and the breadcrumb. A Story gallery whose
photos all live in folders had no way to reach them at all. Confirmed with
elementFromPoint at the chip's centre returning NAV.story-nav; the strip now
carries its own stacking context above it and the same probe returns the chip.

Story's footer download could also be offered with nothing to send: on a
folder-only root of a gallery that has a category download opt-out, the parent
deliberately withholds the whole-gallery callback and the scope is empty, so the
button would have posted an empty id list and taken a 400. It is only rendered
when one of the two actually exists.

* fix(gallery): stop the Story folder strip from blocking the layout's own nav (#1160)

The previous commit raised the whole folder strip above `.story-nav` so the
chips could be clicked, and thereby traded the bug for its mirror image: the
strip is mostly empty space, so as a solid z-60 container it swallowed the
clicks for Story's own search, favourites and logout sitting underneath.

The container no longer takes hits at all; only the chips, breadcrumb and
download button opt back in. The download button also loses its ml-auto, since
being pushed to the right put it physically on top of the nav's controls rather
than merely above them in stacking order.

Verified by hit-testing all three at once — folder chip, download button, and
Story's nav control each resolve to themselves under elementFromPoint, so none
is covering another.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-28 08:03:49 +02:00
Paul NothaftandPaul Nothaft 849a5807b7 fix(admin): make "Storage used" report storage used (#1164) (#1170)
* fix(admin): make "Storage used" report storage used (#1164)

The tile summed photos.size_bytes — the catalogued size of the ORIGINALS,
which has no relationship to the disk PicPeak runs on. In reference mode those
files are never copied and sit on the NAS; duplicate rows counted the same
file twice (#1162); and it ignored everything PicPeak genuinely does write
locally: thumbnails, previews, hero renditions, watermarks and the per-event
download cache. The reporter's tile read ~80 GB against 21 GB of real usage.

Worse than the label: the same number drove the storage soft-limit warning bar
and, via /storage/info, the recommended soft limit — so a reference-mode
install got a disk-capacity recommendation computed from bytes that are not on
the disk.

- new localStorageUsage service walks the storage root and reports the total
  plus a breakdown. Walking rather than summing DB columns is the point:
  thumbnail/preview/hero rows record a key and never a byte count, and orphans
  from a deleted event or an interrupted import are real bytes. Symlinks are
  not followed, so a link into the media mount cannot put the NAS back in the
  total. Cached for 5 minutes, since the dashboard polls.
- the dashboard tile and /storage/info now report that, with the catalogued
  figure kept and labelled as such next to it. A failed measurement reads as
  "unavailable" rather than substituting a number that means something else.

On the local rig: 64.37 MB used against 15.75 MB catalogued, of which 27.9 MB
is watermarks and 6.8 MB is download cache — none of which the old figure
could see.

Not addressed here: `.download-cache/all.zip` still has no TTL or size cap. It
is now at least visible in the breakdown, which is what makes the case for
capping it.

* fix(admin): exclude the media share from local storage usage (#1164)

External review found the walk could reintroduce the exact over-count it
replaces.

EXTERNAL_MEDIA_ROOT's compose default is `<storage>/external-media`, where the
NAS is bind-mounted. That is a plain directory, not a symlink, so the symlink
guard did not cover it and the walk descended into the share — putting every
referenced original back into a figure whose whole purpose is to leave them
out, and comparing NAS bytes against statfs() of the local disk. On the
reference-mode installs this issue is about, that is the failure mode
reappearing inside its own fix.

The configured root is now skipped when it lies inside the storage root, and
the result reports which path was excluded. A directory that merely shares the
name is still counted, because those really are local bytes.

Also from the review:

- concurrent cold-cache callers now share one walk. /dashboard/stats,
  /storage/info and the sidebar are routinely requested together, and each was
  starting its own stat-per-file traversal of the whole library.
- storage_partial is surfaced in the StorageInfo type and the sidebar tile, not
  just the dashboard and analytics cards. An unreadable subtree makes the total
  a floor, and a floor silently compared against a soft limit reads as "safely
  under".

* fix(admin): do not report a disk walk on an S3 backend (#1164)

Second review round.

S3 installs were regressed. With STORAGE_BACKEND=s3 the originals, renditions,
archives and download caches are objects in the bucket and STORAGE_PATH holds
only incidental local files — so the walk reported near-zero and the soft-limit
recommendation was derived from it. Those installs now keep the catalogued
figure, which is the approximation they had before this PR, and the response
says which measurement it is (`storage_measurement: 'disk' | 'catalog'`) so the
UI labels it instead of implying a disk measurement that never happened.

The Settings → Status storage card ignored storage_partial, formatting a lower
bound as exact and deriving the limit percentage from it — so an unreadable
subtree could read as safely under the limit. It now carries the same `+`
marker as the sidebar and dashboard.

* fix(admin): stop rendering an absent measurement as zero usage (#1164)

Third review round, two findings.

The analytics storage bar coerced a null measurement to 0, drawing an empty
bar labelled "0% of limit" and suppressing the over-limit state — reading as
plenty of room at exactly the moment nothing is known. It now shows the
catalogued figure on S3, where that IS the available answer, and says "no
measurement available" rather than inventing a percentage when there is none.

/storage/info walked the filesystem before checking the backend and then threw
the result away on S3. The sidebar polls that endpoint, so a migrated install
still holding a large local tree paid a full stat-per-file traversal on every
cold cache for nothing. Gated before the walk, as the dashboard route already
was.

* fix(admin): tell "no disk to measure" apart from "the measurement failed" (#1164)

External review of the stable twin.

Both were reported as `storage_measurement: 'catalog'`, so a failed local walk
made the dashboard claim the objects live in S3. They are different things —
one is a fact about the install, the other is a fault — and there is now an
`unavailable` state for the second.

The analytics percentage could reach the billions. `safeSoftLimit` fell back to
`storageUsed || 1`, and on S3 that is null → 1, while the figure beside it came
from `catalogedBytes`. An editor or viewer holds `analytics.view` but not
`settings.view`, so `/storage/info` 403s for them and `storageInfo` is
undefined — which is exactly when that fallback fires. It now falls back to the
measured figure, and suppresses the percentage entirely when there is no real
limit rather than dividing usage by itself and always reading 100%.

Also lands the AnalyticsPage half of the previous round, which the commit
message claimed but the commit did not contain — only its backend counterpart
was staged. The stable twin has carried it since it was written, so this is the
parity gap in the unusual direction.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-26 08:53:39 +02:00
Paul NothaftandPaul Nothaft a7b74bcd87 fix(external-media): store external paths from the media root (#1163) (#1168)
* fix(external-media): store external paths from the media root (#1163)

Importing a second folder into an event silently invalidated every photo
already in it. photos.external_relpath was stored relative to
events.external_path, and every import overwrites that column — so the older
rows were rebased onto the new folder and their originals resolved to paths
that do not exist.

Nothing errored, and the grid still looked intact: thumbnails are written to
local storage during the import while the base path is still correct. Only
what needs the original broke — preview generation, the lightbox, downloads —
which presents as a gallery that looks slow rather than one that is broken.
The reporter had 7547 of 8004 rows pointing into the void and spent a while
chasing it as a CPU problem.

- external_relpath is now relative to EXTERNAL_MEDIA_ROOT, so a row is
  self-describing and nothing an admin does to the event afterwards can move
  an already-imported photo.
- migration 187 folds each event's base path into its rows. Where the current
  resolution is missing on disk it walks up the base path for an ancestor
  under which the file IS there — the already-rebased case — and where it
  finds nothing it leaves the row resolving exactly where it resolves today.
  Skipped entirely when the media root is unmounted, since every file looks
  missing then.
- the fold also runs after a .picpeak restore: knex_migrations is excluded
  from the archive, so a pre-#1163 backup would otherwise land base-relative
  rows on a migrated instance.
- drops the duplicate-leaf-segment guess in photoResolver. It papered over
  this same double-prefixing and actively corrupts a root-relative path whose
  first segment legitimately repeats (base 'Trip', row 'Trip/x.jpg').

* fix(external-media): verify provenance and fold atomically (#1163)

External review found four real defects in the fold.

Repair could adopt the wrong file. Existence alone was accepted as proof that
an ancestor candidate was the row's original — so a row whose file an admin
simply deleted would adopt any same-named file one directory up (base
`Trip/Sub`, relpath `photo.jpg`, an unrelated `Trip/photo.jpg`), and downloads
would then serve a different photo. Worse than a dead link. An ancestor must
now also match photos.size_bytes, which the import recorded from the very file
the row describes; rows carrying no size are never repaired from an ancestor.
The CURRENT base is still accepted on existence alone, because nothing is
being inferred there — that is where the row already resolves.

The fold was not atomic. Every UPDATE committed independently and the marker
came last, so a process killed mid-fold left converted and unconverted rows
with no marker — and the next run folded the converted ones a second time,
putting every original one directory deeper with no undo. Probing is now a
read-only first phase (so a slow cold NAS does not hold a write transaction
open), and every rewrite plus the marker commit together.

Failed rewrites certified a partial conversion. The per-row catch counted any
error as a collision, carried on, and wrote the marker anyway — leaving that
row in the old format for a resolver that now reads it differently. It also
could not tell a genuine duplicate from a SQLite lock or I/O fault. Target
collisions are now resolved in the planning phase, where they can be
identified honestly, and a write that fails rolls the whole fold back.

Restore ordering. The fold ran after the face requeue, with the worker live —
so a worker could claim an external row while it was still base-relative,
resolve it against the wrong path, and burn it to 'failed', a state only an
explicit Re-scan clears. The fold now runs first, for the same reason the
requeue already sat after restoreFiles.

* fix(external-media): close the fold's remaining stranding paths (#1163)

Second review round, three findings.

A collision loser was left stranded. When an event imported one file through
both `Trip` and `Trip/Sub`, two rows folded to the same path and the loser was
skipped — keeping a base-relative value that the root-only resolver then reads
as `<root>/<relpath>`, permanently wrong, with the marker claiming conversion
was complete. It is a duplicate by construction, so it now goes through
migration 186's deleteDuplicatePhotos, which reparents its feedback and marks
and reconciles the face clusters instead of orphaning them. This branch is
rebased onto #1162 for that helper.

The other restore path had the same face-ordering bug. restoreService queued
face scans in step 6, before step 7c runs pending migrations — so a pre-187
full or database restore handed the live worker rows whose paths were still
event-relative, and it burned them to 'failed', a state the later fold does
not clear. The requeue now happens after the migrations, where the files
already are.

A failed conversion was reported as a clean restore. The fold is
transactional, so a failure leaves every external path in the old format under
a resolver that reads from the media root — every original unreachable. It was
logged as a warning and the restore returned success. It now returns
externalPathsConverted/externalPathError, and suppresses the face requeue,
which would otherwise mark those photos failed on top.

* fix(external-media): make the fold safe against its own intermediate states (#1163)

Third review round, four findings.

A one-pass rewrite could collide with itself. Every FINAL path is distinct,
but a final value can equal another row's CURRENT one — `photo.jpg` repairing
to `Trip/photo.jpg` while the row already holding `Trip/photo.jpg` folds
deeper — so the update violated migration 186's unique index halfway through.
On Postgres that surfaces as 23505, which run-migrations-safe.js mistakes for
"schema already exists" and records 187 as applied after the rollback, leaving
every path unconverted with nothing to retry. Rows now park on a per-row
staging value first, and migration 187 re-throws without the driver's code so
the runner cannot misread it.

The bulk update targeted rows the plan never saw. Phase 1 probes outside the
transaction and can run for minutes; an import finishing in that window
inserts an already root-relative row, and `where event_id` prefixed it again
with the stale base. It now updates by the ids phase 1 captured.

The restore UI never showed a conversion failure. The API carried
externalPathsConverted, but PicpeakBackupCard neither declared nor read it and
showed a green success either way — so an admin whose external originals were
all unreachable was told the restore worked.

restoreService requeued faces even when the migrations failed. The step 7c
catch is deliberately non-fatal, so a pre-187 backup whose fold never ran
still handed the live worker event-relative paths to burn to 'failed'.

* fix(external-media): the fold's staging value must be storable on Postgres (#1163)

External review of the stable twin caught this, and it was on both branches.

The two-pass rewrite parks each row on a temporary value, and that value was
written with a leading NUL. SQLite stores NUL in TEXT without complaint;
Postgres rejects it outright — "invalid byte sequence for encoding UTF8: 0x00"
— so migration 187 rolled back on exactly the installs that need the two-pass
repair, and only on the engine most of them run. Restores hit the same wall
and reported the conversion as failed.

The prefix is ordinary text now. It still cannot collide with a real relative
path and is still obviously wrong if a crash leaves one behind.

Adds a gated Postgres test alongside the existing picpeakRestorePg one,
because a SQLite-only suite structurally cannot catch this class: restoring
the NUL makes exactly the two-pass repair case fail with that error, and
nothing else.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-26 08:36:45 +02:00
Luca e2844d1909 feat(gallery): colour labels for client proofing, and one global default per feedback type (#1044) (#1137)
Colour labels for client proofing, plus the photographer's own stars and colours in the admin grid.

- Guest colour labels alongside likes/reactions, opt-in per event (defaults off so live galleries do not change mid-proofing), with 'colors' and 'lightroom' keybind schemes.
- One global default per feedback type, replacing the per-type scatter.
- Admin marks live in their own table (photo_admin_marks) so they can never reach a guest-facing surface.
- XMP export prefers a real label, keeping the rating-derived mapping as a fallback.

Review: concurrent-write loss on the mark update path, migration index idempotency and error classification all fixed in 7139bcae; migrations renumbered to 182/183 in 8fecdfae after 180/181 were taken on main.

Merged with admin privileges: bypass-size-gate is a required check that fails on size alone for review-bypass authors and never re-evaluates on review, which is its designed behaviour once a maintainer has approved.
2026-08-23 11:15:01 +02:00
Paul Nothaft 3583c924da feat(faces): consolidate look-alike clusters after a scan, and suggest the rest (#1107)
consolidate() has existed since #1074 and described this exact symptom in its own
comment, but its only caller was recluster() — i.e. when an admin pressed
Re-group people. After a normal background scan the centroids converged and
nobody looked, so a gallery settled with 14 people that should have been 8.

It now runs when a scan drains. There is no scan-finished event to hook, so an
idle worker asks whether the events it touched have actually drained — 'a worker
went idle' is deliberately not treated as sufficient, because with concurrency
above one the others may still be working.

The uncertain band asks instead of acting: pairs between the assignment
threshold and the stricter auto-merge one surface as accept/dismiss suggestions,
with sticky dismissals. Nothing merges silently — a pass that merged anything
reports it and points at Split.

Review rounds hardened it against overruling explicit decisions: it no longer
absorbs ignored clusters (mergePeople ORs is_ignored onto the survivor, which
would have hidden a real person), no longer merges dismissed pairs, no longer
undoes a manual Split (which now records a separation), and no longer runs after
detection is switched off. The dismissal read fails closed, a failed pass is
retried with backoff rather than lost or hot-looped, and the new table follows
event_people out of exports and backups.

Name autocomplete needs no endpoint — the people list already open is the source,
and it is event-scoped on purpose.

Known limitation, tracked in #1132: separations are keyed on person ids, so a
full re-scan loses them.

Reported by @BraynArts.
2026-08-22 21:39:15 +02:00
Paul Nothaft 25fbefc703 docs(faces): link the face-recognition guidance from where people look (#1125)
Every other feature routes readers to docs.picpeak.app. Face recognition was the
one that either pointed somewhere else or pointed at nothing — poor placement
for the feature with the highest read-before-you-enable burden anything here
ships.

.env.example referenced docs/feature-face-recognition.md, which does not exist —
and creating it is not the fix, because .gitignore:89 ignores docs/feature-*.md
outright, so the file would be invisible to anyone who cloned. That was the only
pointer to legal guidance an operator got while editing the variables that turn
Art. 9 processing on.

Also: the README linked the sidecar's developer README for the feature name and
had no row in the documentation table, docs/single-container.md left readers who
wanted the feature nowhere to go, ml/README.md had no backlink, and the admin
consent callout had no link at all. It does now, inline at the end of the
obligation.

Reported by @Luca-Timo.
2026-08-22 21:38:25 +02:00
Paul Nothaft 24e11df299 fix(faces): dark-mode styling for the People surfaces (#1106) (#1126)
Every component added by #1074 was styled for light mode only. In the admin dark
theme the three toggle labels rendered invisible — including "Detect people in
this gallery", the switch that starts GDPR Art. 9 processing — and the
Manage-people modal rendered as a light panel over a dark page because its shell
was a hardcoded bg-white.

Admin surfaces pair each neutral with a dark: variant; guest surfaces read the
gallery theme tokens, because galleries carry their own dark themes that the
admin dark class knows nothing about.

Beyond the issue's inventory: the cover picker and face-in-context viewer that
landed after it was filed, the magnifier chip whose bg-white/90 would have
carried light glyphs, PeopleSheet's own hardcoded bg-white shell, the selected
avatar's white ring-offset halo, and both dismiss buttons whose hover darkened
into the background.

External review found one defect, fixed: the sheet's avatars ring against
--color-surface, not the page background.
2026-08-22 18:24:13 +02:00
Paul Nothaft 38c27d097c feat(faces): show a detected face in its source photo, outlined (#1120)
Phase 2 of #1096. Stacked on the phase-1 branch — it needs the Postgres fix
there, or the face list this reads comes back empty.

A 64px avatar answers "is this a person", not "is this the same person as that
other cluster". The reporter's revised use for this is the pre-merge decision:
who they were standing next to, what the occasion was. So the face opens in
its own photo with the detected box drawn, and prev/next walks that person's
other appearances without leaving the modal.

The box is positioned in PERCENTAGES of the original frame, not measured
pixels: the container carries the photo's aspect ratio, so the same four
numbers land correctly at any rendered size, with no resize listener. Verified
against real data before writing the component — bbox [221.9, 174.9, 294.5,
405.8] on a 750x750 frame resolves to left 29.6% / top 23.3% / width 39.3% /
height 54.1% and lands squarely on the face.

Preview rendition, never thumbnail, and that is load-bearing rather than a
quality preference: thumbnail_fit is seeded to 'cover' on every install, so a
thumbnail has had its edges cut off and ratios taken against the ORIGINAL land
nowhere on it. That was #1100, and it presented as a broken detector.

Not built on AdminPhotoViewer, deliberately. It wants full AdminPhoto objects
(this endpoint returns photo_id + bbox + dimensions), it carries delete and
category actions that are wrong for "who is this?", and there is no seam to
draw the box.

Three things review caught, all real:

- The container had a height cap but no width cap, so a panorama derived its
  width from the aspect ratio and overflowed the modal sideways, taking part
  of the outlined face off-screen.
- The per-tile affordance was hover-only, so on a tablet it was permanently
  invisible and there was no way to inspect a specific tile.
- The row action opened index 0, which is the TOP-SCORING face — the same
  thing as the cover only until someone uses phase 1 to pick a different one,
  at which point the row showed one face and opened another. It now resolves
  to the cover's own index.

Round 2 found three more, all real:

- The counter called a list truncated whenever it hit 500, so a person with
  exactly 500 faces was told their complete list was capped. It now compares
  against total_face_count.
- facesLoading goes false with an empty array on a zero-face person or a failed
  request, so the panel sat on a spinner that would never resolve.
- Five 32px actions plus a 64px avatar exceed a 320px row, and the name is what
  got pushed out. flex-wrap alone did not fix it — the toolbar still claimed
  its max-content width first — so its basis is capped at small sizes and the
  buttons wrap to a second line instead.

A cover that falls outside the capped list opens the first face instead. That
case implies the list IS capped, so the truncation note already explains it —
real pagination is a bigger change and is not in this.

Verified end to end: picked the 5th of 13 faces as cover, and the row action
opened at 5 / 13 rather than 1 / 13. Frontend suite 178 passing, build clean,
no new type errors.
2026-08-21 19:36:59 +02:00
Paul Nothaft bbce3cd2a2 feat(faces): let the photographer choose which photo represents a person (#1119)
Phase 1 of #1096.

Clustering picks the cover, and its idea of a good one and a human's do not
always agree. A cluster whose avatar is turned away or softer than the rest
stays that way in the guest-facing people strip too, and nothing in the UI
could change it.

A picker reachable from each person row, reusing the face list the split
dialog already loads — same query, same grid, different action on a click.

Making the choice actually stick took four changes
---------------------------------------------------------------------------
event_people.cover_face_id has existed since migration 177 and the PATCH
already accepted it, so the first version of this was frontend-only. It was
also a no-op:

- facePeopleService.listPeople SELECTED cover_face_id and then discarded it,
  recomputing the cover as the best-scoring VISIBLE face on every read. The
  picker saved, said so, and the avatar reverted immediately. It now prefers
  the stored pick whenever this audience can see it, and falls back to the
  score-ordered choice otherwise — so visibility scoping still wins, and a
  guest is never handed a crop of a photo they cannot open.
- recomputeCentroid overwrote cover_face_id unconditionally. It runs on
  rescan and on photo replacement, so any reprocessing silently undid a
  deliberate choice. It now keeps the chosen face while it is still a member
  of the cluster.
- The face list is cached per person, and split/merge move faces between
  people. Until now the only reader closed itself after acting, so nobody saw
  the stale copy; the picker is a second reader of the same key.
- cover_face_id meant two things. assignFaces seeded it with whichever face
  opened the cluster and recomputeCentroid overwrote it with the highest
  scoring one, so an automatic guess was indistinguishable from a deliberate
  choice — and honouring it would have pinned every UNCURATED person to that
  guess, which is worse than the fallback it replaced (the fallback is
  computed per audience and skips photos a guest cannot open). Both writers
  are gone, migration 179 clears the stored guesses, and the column now means
  one thing. That also removes the need to defend the choice against rescans:
  nothing overwrites it, and a dangling id self-heals to the derived cover.

Clearing existing values is safe rather than destructive: no install has ever
been able to SET a cover, so every stored value is an automatic guess by
construction.

Also fixes a PostgreSQL-only 500
---------------------------------------------------------------------------
GET /admin/events/:id/people/:personId/faces joined `photos` but did not
table-qualify its WHERE, and photo_faces and photos BOTH have an event_id:

  column reference "event_id" is ambiguous

Postgres refuses it, so the endpoint 500s and the Split dialog — its only
consumer until now — has been broken on every PostgreSQL install since the
join was added. SQLite resolves the ambiguity silently, which is why the suite
stayed green. Reproduced against a real Postgres before and after.

The query is now a named builder the route calls and the test imports, rather
than a copy: an earlier version of that test re-declared the query, so the
route could regress to the bare form while the assertions kept passing.

Merge and recluster preserve the choice as well. Both already carried labels
and privacy flags across; the chosen cover is human state of the same kind, so
it now rides along — through a merge when the target has none, and through a
recluster by following its FACE into whichever cluster ends up holding it,
rather than the majority-descendant rule the label uses.

The picker and the endpoint disagree past 500 faces, so the picker now says
when it is showing a capped list rather than presenting it as exhaustive.

Frontend suite 178 passing, backend 23 across the touched suites, build clean,
no new type errors. Mutation-checked twice: dropping the cover preference fails
the new listPeople test while the visibility-scoping test still passes, and
restoring the auto-seed in assignFaces fails it too.
2026-08-21 19:26:52 +02:00
LucaandPaul Nothaft 9431b9f094 feat(setup): configure the public address and SMTP in the wizard, not .env (#1104)
* feat(setup): configure the public address and SMTP in the wizard, not .env

A fresh install could not configure its own public address. `general_site_url`
and the `email_configs` row already existed as admin settings, but nothing
could reach them:

- docker-compose.yml injected FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
  and Dockerfile.aio baked in ENV FRONTEND_URL=http://localhost:3000, so
  getFrontendBaseUrl() returned on its first branch every time and the setting
  was never read. .env.example shipped the same value as an uncommented
  placeholder for FRONTEND_URL / ADMIN_URL / API_URL.
- the wizard never asked for the address at all, and skipped its whole config
  step unless a CRM-ish feature was selected — so a gallery-only install was
  also never offered SMTP, despite gallery links, guest invites and expiry
  warnings all going out through email_configs.
- eleven call sites read process.env.FRONTEND_URL directly rather than the
  resolver, three of them defaulting to placeholder hosts that reached real
  recipients: https://app.example.com in payment-reminder emails, localhost:3005
  in admin invitation emails, https://app.example.com in dev template previews.

Stop injecting a default anywhere, and resolve the origin instead:
FRONTEND_URL -> general_site_url -> the origin the request arrived on ->
whichever exists -> ''. A loopback candidate is treated as unconfigured so the
installs that already have http://localhost:3000 baked into their environment
self-heal; the same guard previously lived inline in routes/gallery.js for the
slideshow QR (#848) and is now shared. The empty return is preserved because
shareLinkService and the SSO redirects in routes/auth rely on it to emit
relative urls — callers needing an absolute url use getAbsoluteFrontendUrl(),
which still ends at http://localhost:3000.

The wizard now persists window.location.origin right after the admin account is
created, so an install that skips the rest still has a usable origin for
background jobs that have no request to derive one from, and offers it as an
editable "Public address" field. Settings -> General shows the field read-only
when FRONTEND_URL pins it, instead of silently ignoring edits.

Also drop the `|| 'mailhog'` fallback when seeding email_configs: that host only
exists in the dev compose profile (which does not even start by default), so a
fresh install came up with a live config pointing nowhere while the wizard
showed empty SMTP fields. With no row, blank fields are the truth and
emailProcessor logs "No email configuration found". Developers set
SMTP_HOST=mailhog explicitly.

backend/src/services/emailService.js is deleted: nothing in backend/ references
it, and it was the only consumer of the SMTP_* variables, which misrepresented
how mail is configured.

Refs #705

* fix(setup): keep FRONTEND_URL ahead of ADMIN_URL/APP_URL when resolving links

The previous commit routed two call sites through the resolver but put the
site-specific variable FIRST, silently reversing precedence:

  userManagementService  was: FRONTEND_URL || ADMIN_URL || localhost:3005
                    became: ADMIN_URL || resolver
  adminEvents/crud       was: FRONTEND_URL || APP_URL || ''
                    became: APP_URL || resolver

An install with both variables set would have flipped which one won. Call the
resolver first instead — it starts with FRONTEND_URL, so the original relative
order is preserved and only the final fallback changes: localhost:3005 (not
even the frontend's port) and '' (a relative link inside an email) both become
the resolved origin.

Refs #705

* fix(setup): unpin loopback FRONTEND_URL, keep ADMIN_URL/APP_URL reachable

Review feedback on #1104.

isEnvPinned() reported ANY FRONTEND_URL as authoritative, including the
loopback values getFrontendBaseUrl() deliberately demotes. An install
upgrading with the old compose default FRONTEND_URL=http://localhost:3000
therefore resolved its origin from general_site_url correctly, but got the
Site URL field rendered read-only in Settings and skipped by the wizard's
seeding - locking the exact operators this change exists to unblock out of
configuring a public address anywhere. The predicate now mirrors the
resolver, and the derived general_site_url_effective the General tab reads
comes from the same helper instead of re-normalising process.env inline.

APP_URL and ADMIN_URL had become dead code: getFrontendBaseUrl() only
returns falsy when NOTHING is configured, so `|| process.env.ADMIN_URL`
after it never ran once a site URL existed - which after this PR is the
normal case. A split-origin install pointing ADMIN_URL at a separate admin
host got invite links on the public gallery origin instead. They are now
passed as an explicit `override` that resolves directly below FRONTEND_URL,
preserving the historic FRONTEND_URL-before-ADMIN_URL order while beating
the database- and request-derived fallbacks.

general_site_url now feeds the CORS allowlist and the
Access-Control-Allow-Origin header, not just email links, so a schemeless
value is an allowlist entry no browser origin can match. Validate it
server-side in PUT /general (isURL with require_protocol, require_tld off
so LAN/NAS installs on http://nas:3000 still work) and client-side in both
surfaces that write it - type="url" never fires in either, since neither
input sits inside a form.

Two more wizard fixes: the General tab no longer reposts general_site_url
while it is env-pinned, because the field then holds the effective env
value rather than the stored one and the round-trip read as a change to a
protected key, 403ing a settings.edit-without-settings.domains admin on an
unrelated save. And SetupConfigStep validates the From address before
posting - /admin/email/config rejects a blank one, which used to surface as
a generic warning while the wizard advanced from its finally block anyway,
discarding every SMTP value the user had typed, password included. A failed
save now keeps them on the step.

* fix(setup): surface a rejected public address instead of swallowing it

Review round 2 follow-up on #1104, pushed onto the branch.

saveSiteUrl() caught and discarded every error. That was defensible before
round 2 added a server-side URL check, but PUT /general can now answer 400 —
and the two validators disagreed:

  http://my_nas.local      client: accepted   server: rejected
  http://foo_bar:3000      client: accepted   server: rejected

validate() let those through, the 400 was swallowed, `failed` stayed false and
onDone() ran. The operator finished the wizard believing the public address was
stored when nothing had been. That is the silent misconfiguration this whole
change exists to remove, landing on the LAN and NAS installs it targets.

Three parts:

- saveSiteUrl() throws. finish() resolves it before anything else is posted and
  puts the message on the address field rather than the generic "some settings
  could not be saved" warning. Skip for now still always leaves, by contract,
  but warns instead of dropping the value in silence.
- allow_underscores on the server check, for the same reason require_tld is
  off: browsers resolve http://my_nas.local and the client accepts it, so
  rejecting it server-side only produced the mismatch above. Both validators
  now agree across the LAN/NAS, IDN, bare-IP and scheme-less cases.
- LOOPBACK_BASE_RE anchors its host token. Bare prefix matching also demoted
  https://localhost-nas.example.com, and now that this predicate gates the
  whole resolver rather than just the slideshow QR, being demoted means a
  configured address is silently ignored. 127. stays a bare prefix on purpose:
  all of 127.0.0.0/8 is loopback.

Resolver suite 31 passing, up from 26. Mutation-checked: restoring the
unanchored regex fails the three new host-boundary cases.

* fix(settings): don't lock the General tab on a site URL nobody typed

Review follow-up on #1104, pushed onto the branch.

general_site_url was free-text until this PR added a server-side check, so an
upgraded install can hold something schemeless that predates it. The tab
flagged that on load, and `disabled={!!siteUrlError}` then killed Save for
EVERY General setting.

An admin holding settings.edit but not settings.domains could not clear it
either: correcting the address is a change to a protected key and 403s. The
tab has no permission gating, so that role was simply locked out of the tab
with no self-service way back.

That is the same role adminSettings.js:85-95 documents the no-op round-trip
allowance for. The allowance only helps if the request is made, and this
blocked it in the browser first.

Validation now waits until the field is actually edited, and an unchanged
value is dropped from the payload rather than reposted — matching what the
env-pinned case already does one line above, and for the same reason.

  stored value invalid, untouched   Save works, key not sent
  edited to something unusable      Save blocked
  edited to a usable absolute url   saved

Four tests, first coverage for this feature. Mutation-checked: removing the
dirty gate fails the untouched-value case.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-21 12:54:14 +02:00
Paul NothaftandPaul Nothaft b3a7ab27ea fix(faces): face avatars were cropped against a cropped rendition (#1100)
* fix(faces): face avatars were cropped against a cropped rendition

Found while triaging #1096, which reported the People manager showing
unusable cluster covers — a bare shoulder, the back of a head, a patch
of background — and asked for more sample faces to compensate. Most of
that is not a detector problem and not a UI limitation. It is a bug.

faceCropStyle positions an avatar by scaling the WHOLE frame and
offsetting so the face lands centre. That holds only while the rendition
shown is the entire image at a uniform scale. Thumbnails are not:

  imageProcessor.js:93   DEFAULT_THUMBNAIL_FIT = 'inside'
  migration 040:6        thumbnail_fit seeded to 'cover'
  imageProcessor.js:229  fit: settings.fit

The 'inside' constant is only a fallback for a missing settings row, and
the row is seeded on every install — so thumbnails are centre-cropped
essentially everywhere, and every face avatar rendered against one is
silently offset on any non-square photo. The reporter read the setting
as safe because of that constant, and the code comment at :87-92 says
the same thing; all three places disagree with what is actually stored.

It presents as a bad detector, which is why it survived: the boxes are
right, the frame they are drawn against is not.

All three surfaces — the admin manager and the guest-facing strip and
sheet — now read a preview, which uses fit: 'inside' and is therefore
the whole frame. At w=640: plenty for a 64px avatar at DPR 3, and small
enough that a strip of a dozen people does not pull a dozen 1920px
renditions. Face scanning already calls ensurePreviewImage for anything
it scans, so a preview exists for every photo that has a face.

Adds the admin preview route the manager needed; the gallery already had
one. Both whitelist ?w= the same way.

The first version of the call-site test passed with every surface still
reading thumbnail_url, because an import alone satisfied it. It now
matches inside the src={...} expression, and each of the three surfaces
was individually reverted to confirm the test fails.

* fix(faces): size the face tier by bbox, and keep admin_preview auth

The face half of the external review; the tier-key and long-edge fixes
live on the #1099 branch this is stacked on.

Face avatars used one fixed 640 tier. In a 6000px group shot a 200px
face is ~21px there, and faceCropStyle then blows that up ~9x to fill a
64px avatar at DPR 3 — mush, and indistinguishable from the
mis-positioning bug this PR exists to fix. The tier is now derived from
the bbox's share of the frame, so a face across a hall gets 1920 and a
close-up still gets 640.

The synthesized face URL also dropped admin_preview. verifyGalleryAccess
only accepts the admin cookie when admin_preview=1 is on the request
(middleware/gallery.js:28), and the preview flow deliberately mints no
gallery JWT — so every avatar 401'd in exactly the mode an admin uses to
check a gallery before sending it to a client.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-20 14:17:25 +02:00
Paul NothaftandPaul Nothaft 37a15e3d49 fix(faces): restore the :beta image tag and surface sidecar health (#1087)
* fix(faces): restore the :beta image tag and surface sidecar health

Both halves of what a user hit on discussions/1069: the People card sat
at "Scanning… 0 of 227" for 30 minutes with no explanation, because the
sidecar container could never have started.

docker-build.yml — republish `:beta`. It used to come for free via
`type=ref,event=branch` when the active development branch was literally
named `beta`; the rename to `main` silently retired it. backend:beta has
been frozen at 2026-06-29 (448da950) ever since while :main moved on, so
PICPEAK_CHANNEL=beta has been serving a seven-week-old build across every
image. The ml sidecar was added after the rename and so never had a
`:beta` at all, which left docker-compose.production.yml:158 unable to
resolve ghcr.io/picpeak/picpeak/ml for any documented channel — the
image simply does not exist as :beta or :stable, only as :main and
pinned versions. Tag added to all four merge jobs, gated on main.

`:stable` stays absent for ml on purpose: it is gated on refs/heads/stable
and the sidecar does not exist there. stable's docker-compose.production.yml
carries no picpeak-ml service, so nothing can reference the missing tag.

FaceRecognitionCard — show when the sidecar is unreachable. An
unreachable sidecar is not an error by design: faceQueue.js:132-136
releases the photo back to `pending` and retries forever so a restart
does not burn the queue. The cost was that a stopped container looked
exactly like a slow scan, indefinitely, and the only signal was a
backend log line rate-limited to once per five minutes.
/admin/events/faces/health already existed and nothing in the frontend
called it. It is now polled while a scan is in progress, and a failing
check replaces the spinner with the sidecar URL, the underlying error
(which distinguishes a stopped container from a token mismatch) and the
command to start it.

Health is only polled while a scan is running — an idle card has no
reason to care whether the sidecar is up.

* fix(faces): tell the three sidecar failure modes apart

Follow-up to the health surface in this branch, from an external review
pass. The original warning was right about "the sidecar is not working"
and wrong about almost everything after that.

faceClient.checkHealth now returns a `reason` rather than only a message,
because the caller has to know whether photos survive:
  - 'unauthorized' (401) and 'rejected' (any other 4xx) both become
    SidecarRejectedError in classify(), which workerLoop does NOT retry —
    every claimed photo is marked 'failed'. Telling the admin the scan
    resumes on its own was simply untrue there; both now say to fix the
    cause and Re-scan.
  - 'unreachable' (refused/DNS/timeout/5xx) is the retryable one.

The card also no longer cries wolf. /faces runs inference synchronously
inside an `async def`, so one slow photo blocks the event loop and stalls
/info past its 5s timeout — a healthy sidecar can fail a probe. Verified
with an isolated uvicorn repro: a blocking call in an async handler
stalled the sync /info endpoint to 5.01s. The warning now needs three
consecutive failures AND no drop in `pending`. Three because a single
/faces call may legitimately run to FACE_ML_TIMEOUT_MS (30s) and two
probes 15s apart both fit inside that window; `pending` rather than
`scanned` because scanned counts only 'done', so a run producing
skipped/failed photos is progress that counter misses.

A 4xx burns the queue with no backoff, so it can empty before anyone
opens the card — in_progress goes false and only "227 failed" is left.
The probe therefore also runs when a finished scan has failures, and the
notice renders under the counts instead of replacing them. It is worded
as present-tense service state, not as a claim about those specific
failures: a live probe cannot know whether they came from this
misconfiguration or from corrupt images earlier. Attributing them exactly
needs stored face_error rows, which is a bigger change than this.

Also adds the missing-token case to the unreachable text: FACE_ML_TOKEN
has no default and the container refuses to start without it, so the most
likely first run fails as a plain connection refusal that "just start it"
does not fix.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-19 16:49:47 +02:00
Paul NothaftandPaul Nothaft b69dd134d0 feat(faces): People in this gallery — face recognition via an optional ML sidecar (#1074) (#1075)
* feat(ml): optional face-detection sidecar, opt-in and inert by default (#1074)

First of four PRs for "People in this gallery". This one ships only the
sidecar, its wiring and its CI — no schema, no backend code, no UI. Nothing
in PicPeak calls it yet.

picpeak-ml is a single FastAPI + onnxruntime container: three endpoints
(/health, /info, /faces), no database, no volumes, no egress, no model
download at runtime. Clustering, person identity and every privacy decision
stay in the backend where the data already lives.

Models are YuNet (detection) + FaceNet-512 (embedding), both MIT, both
pinned by URL and SHA-256 and verified at build time. The licence analysis
is in ml/LICENSES.md: the more accurate InsightFace weights are
non-commercial-only and PicPeak's users are working photographers, so they
are never baked into an image we publish.

Two things worth review attention:

- Alignment uses a least-squares similarity transform (Umeyama), NOT
  cv2.estimateAffinePartial2D. RANSAC and LMEDS exist to reject outliers
  among many correspondences; given five landmarks and no outliers they fit
  a three-point subset exactly and let the rest drift. Measured on a real
  off-frontal portrait: eyes and nose pinned to 0.11px, mouth corners
  11.8px out on a 160px crop. Umeyama distributes it (max 6.5px, rms 5.1 vs
  7.4). The failure mode is silent — a bad warp still yields 512 confident
  floats — so tests/test_pipeline.py pins it numerically.

- FACENET_ONNX_URL has no default and the build fails loudly without it.
  deepface distributes FaceNet-512 as Keras .h5 only, so the ONNX is
  produced once by tools/convert_facenet.py and published as a release
  asset. Converting inside the build would drag TensorFlow through both
  architecture legs of every build to produce a byte-identical file. The CI
  jobs are gated on the FACENET_ONNX_URL repository variable and skip
  cleanly until it is set.

Off by default, twice over: the sidecar is behind the `faces` compose
profile, and the backend will gate on a `faces` feature flag that defaults
to false. FACE_ML_URL defaults to http://picpeak-ml:8000 so the standard
deployment needs no configuration — nothing dials that host while the flag
is off, which is why a non-resolving default is harmless.

Verified: 27 pytest tests green; YuNet loads and detects against a real
portrait with its landmark order matching the alignment template
index-for-index; both compose files validate and the faces profile is
correctly excluded from a default `up`; workflow YAML parses and the job
graph resolves.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(ml): pin the converter toolchain, verify parity, drop a false reproducibility claim (#1074)

Ran the FaceNet-512 conversion for real and corrected what the previous
commit assumed about it.

The conversion works: 23,497,424 parameters, 89.6 MB ONNX, and the converted
graph matches the Keras original to 2.086e-06 absolute / cosine
1.0000000000. That check is now part of the script rather than something I
did once by hand — a subtly wrong graph still returns 512 plausible floats,
so it refuses to leave the file on disk if parity fails.

Also ran the full pipeline against both real models end to end. The
embedding is L2-normalized to 1.000000, and the same face survives being
re-rendered: half scale 0.973, double scale 0.984, JPEG q40 0.987, rotated
8 degrees 0.984, brightness +40 0.988. Scale invariance in particular is
evidence the alignment warp is doing its job.

Corrected claim: the conversion is NOT byte-reproducible. Two runs with the
same pinned versions on the same machine gave different SHA-256s. The graphs
are functionally identical — same 336 nodes, same 271 initializers, every
weight matching to 0.000e+00 — but a few initializer names differ because
tf2onnx's traced-op naming is not deterministic (Keras layer naming is
deterministic; I checked). The previous commit message and README both
claimed byte-identical output. They were wrong, and it matters: anyone
re-running the conversion gets a different hash, and without this note that
reads like tampering. The build-time SHA-256 pins one published artifact so
its URL cannot start serving different bytes; validating a fresh conversion
is the parity check's job.

requirements-convert.txt now pins the exact set that produced the artifact,
including transitive keras/protobuf/numpy, and documents that the converter
needs Python 3.11 while the image runs 3.12.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): schema, queue, clustering and API for People in this gallery (#1074)

Backend half of the feature. Migration 177, a face-detection queue, the
clustering engine, the gallery and admin APIs, and the privacy wiring. No UI
yet; nothing is reachable until the `faces` feature flag is on, which
defaults to false.

The flag is the gate, not FACE_ML_URL. That variable now has a working
default (the compose service name), so its presence proves nothing about
intent — if it were the gate, every install would poll a hostname that does
not resolve. faceQueue re-checks the flag every tick, so turning it off stops
the workers without a restart.

Visibility scoping is the part worth reviewing closely. Face rows have no
concept of photo visibility, but guests are restricted to
photos.visibility='visible'. A raw count leaks how many hidden photos someone
appears in, and an unscoped cover face renders a crop of a photo the guest
may not open — with the best-scoring face being the likeliest pick, so it
would happen often rather than rarely. facePeopleService recomputes both per
request against the caller's own scope, and event_people.face_count_total is
named to be conspicuous in a guest path. Six tests cover it, including the
case where a person's photos are ALL hidden and they must vanish entirely.

Face data is excluded from backups and .picpeak exports, per the decision in
the thread: it is derived, so a restore re-scans rather than carrying
biometrics between operators. Three separate mechanisms, because the engines
cannot be filtered alike — EXCLUDED_TABLES for export, --exclude-table-data
(not --exclude-table; the CREATE TABLE must survive or restore breaks on the
first query) for Postgres, and DELETE + VACUUM on the temp copy for SQLite,
which has no way to exclude a table from a whole-file .backup. The VACUUM is
not cosmetic: without it the pages stay in the file and the claim is false on
disk.

Archiving now purges face data explicitly. photo_faces cascades off photos,
but archive deletes neither the photo rows nor the event, so without this an
archived gallery kept its biometrics indefinitely.

Other decisions: clustering keeps names across a re-cluster by majority
inheritance (without it, one button click silently discards every name the
photographer typed); consolidation refuses to merge two people who were named
differently; assignment never compares across model_version, since embeddings
from two pipelines are not comparable; low-quality faces are stored but left
unassigned so they show in "this photo contains" without spawning junk people.

Migration is 177, not 174 — 174/175/176 landed on main while this branch was
open.

29 tests green: 7 migration (idempotency, down(), cascade, and that
installing it enqueues NOTHING), 11 clustering, 11 privacy/visibility. Lint
clean; the pre-existing error counts in databaseBackup.js and server.js are
unchanged.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): People strip, face filter and admin controls (#1074)

Frontend half. Renders nothing anywhere unless the `faces` feature flag is
on AND the photographer enabled detection for the gallery — the whole guest
surface hangs off one boolean, event.people_enabled, which the server
computes from the flag, the per-event toggle and the show-to-guests toggle
together.

Guest side: a People strip between the filter bar and the grid, circular
crops from each person's cover face, an active-filter chip row, and a "Show
all" bottom sheet. The face filter composes with category, search, media
type and the liked/saved/rated filters in the same useMemo rather than
replacing them, so "photos of Anna that I liked" works. Two people selected
means AND by default — that is what picking a second face almost always
asks for — with a toggle to OR that appears only once a second person is
picked.

Unnamed people show a photo count and never "Person 7". A number is honest
about what the system knows; an invented name is not. There is a test
asserting we don't do it.

The strip renders nothing below two people, collapses to one line when
dismissed (persisted per slug, so dismissing one gallery says nothing about
the next), and appears mid-backfill with a progress line rather than
blocking the gallery behind a spinner. Avatar crops are computed in ratios
of the source dimensions so they survive whatever rendition the browser
gets; without width/height they fall back to an uncropped thumbnail, since
a wrongly-offset crop is worse than no crop.

No new download endpoint: "download these N" rides the existing photoIds
path, which already enforces access level and per-category permissions
server-side. Adding a person_id selector would have been a second thing to
authorize for no gain.

Guest-facing copy never says "biometric" or "recognition" — those words
describe our implementation, not the guest's experience. The sheet's
footnote answers the first question every guest has (where does this go?)
inline. The admin card, by contrast, is explicit: it states the controller
obligation next to the toggle, and warns that scanning materializes the
preview tier on galleries that never generated one, which is real CPU and
disk an admin should know about before a 2,000-photo backfill.

EN + DE translations. 140 frontend tests green (8 new), tsc and eslint clean.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): measured match threshold, working build defaults, 89MB smaller image (#1074)

Ran the Phase 0 spike that had been outstanding, published the model, and
fixed what both turned up.

THRESHOLD IS NOW MEASURED, NOT GUESSED. LFW's standard 1000-pair protocol
run through this exact pipeline (YuNet -> Umeyama alignment -> FaceNet-512
ONNX), 100% detection on 2000 images:

  same person  cosine 0.6958 +/- 0.1415
  diff person  cosine 0.0849 +/- 0.1674   separation 0.6109
  peak accuracy 96.60% @ 0.405

So the pipeline separates people well — the thing I could not previously
claim, since every earlier number was the same face re-rendered.

Default moves 0.62 -> 0.50. The old value was a placeholder and a bad one:
it gave 0% false merges but 22.4% false splits, i.e. roughly one in four
same-person pairs failing to join, which fragments a gallery badly. 0.50
gives 1.0% false merge / 8.2% false split. Peak accuracy (0.405) is
deliberately NOT chosen: for clustering the two errors do not cost the same.
A false split is a duplicate row the photographer can merge away; a false
merge puts a stranger into someone's "download my photos" — and until the
Phase 2 merge/split UI ships, there is no way to undo one. So this sits on
the conservative side of the optimum.

The spike is committed as ml/tools/benchmark_threshold.py rather than
thrown away, so "why 0.50?" has an answer in six months and a re-tune is one
command.

BUILD DEFAULTS. FACENET_ONNX_URL/_SHA256 now default to the published
ml-models-v1 release asset, so `docker build ml/` and
`docker compose --profile faces up` work with no arguments. Blanking either
still fails loudly — a URL without a checksum is never acceptable, since the
checksum is what makes the URL safe to trust. Found by running compose for
real: it failed exactly as designed, which was correct behaviour and a bad
out-of-box experience now that a canonical artifact exists.

IMAGE SIZE. 389MB -> 300MB single-arch. `chown -R` after COPY rewrote every
copied file into a fresh layer, duplicating the 90MB model for nothing; the
user is now created before the copies and ownership set via COPY --chown.
Also drops pip/setuptools from the runtime image. Measured RSS is 186MiB
idle, and the container answers /faces end-to-end in well under the
80-150ms/photo the issue budgeted.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): threshold 0.50 -> 0.60 from real clustering, theme-aware People strip (#1074)

Both fixes come from running the feature on an actual gallery — 61 photos,
5 real identities — rather than reasoning about it.

THRESHOLD. The LFW pairwise sweep in the previous commit said 0.50, and it
was wrong. On a real gallery at 0.50, three of six visible clusters were
contaminated: two different people merged into one strip entry, which is the
exact failure that puts a stranger into someone's "download my photos".

Pairwise error rates do not predict cluster purity. Greedy assignment
compounds — one wrong face drags the centroid toward the midpoint between two
identities, making the next wrong face likelier. A 1% pairwise false-merge
rate is not a 1% chance of a clean gallery, and no amount of staring at an
ROC curve would have shown that.

Sweep against ground truth (5 identities):

    0.50 -> 6 clusters, 3 contaminated
    0.56 -> 6 clusters, 0 contaminated
    0.60 -> 5 clusters, 0 contaminated   <- exactly right
    0.64 -> 5 clusters, 0 contaminated, fewer faces assigned

0.60 recovers the right number of people with no contamination; higher only
loses coverage. Migration 177 carries the full reasoning so the next person
to touch this knows why the obvious pairwise answer is the wrong one.

THEME. The People strip hardcoded `text-neutral-800` for named people. On a
dark gallery — which the screenshot immediately showed — that renders a
named person's label almost invisibly, while UNNAMED people stayed legible.
Exactly backwards. Labels, headings, the collapsed summary, the scan line
and the filter chip row now read the gallery's own theme tokens
(--color-text / --color-muted-text / --color-accent / --color-surface-border)
like the rest of the gallery surface.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): keep the mobile filter row inside the viewport (#1074)

At 390px the photo count and Clear link were pushed against the right edge
by ml-auto and clipped. Only apply it from the sm breakpoint up, where
there is room; below that they flow after the chips.

Found by screenshotting the real thing on an iPhone-sized viewport.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): complete Phase 1, add People management and auto-categories (#1074)

Closes the two Phase 1 gaps, then builds Phase 2 and Phase 3.

PHASE 1 GAPS. "Download these N" was specified, described as done in an
earlier summary, and never actually built — I had verified the backend needed
no new endpoint and let that stand as if the button existed. It now hands the
filtered photo ids to the same path as a manual selection, so the server
re-applies access level and per-category permissions on the way through.
Photos in a downloads-disabled category are excluded client-side too, so the
number on the button is the number the guest receives. Hidden entirely when
downloads are off for the gallery.

Lightbox person chips ("In this photo: Anna") are the second way into the
face filter — a guest looking at a photo of themselves can act on it without
scrolling back to the strip. Tapping one closes the lightbox and filters the
grid behind it.

PHASE 2. A People management modal over the endpoints that already existed
and were already tested: rename inline, merge (multi-select, first pick is
the target so the name a photographer typed survives), split via a face
picker, hide, ignore. This matters more than it sounds — clustering
deliberately errs toward splitting because a wrong merge puts a stranger into
someone's download, and that trade only works if merging is easy.

PHASE 3. Rule engine over face_count plus face-area ratio: 0 -> Details,
1 large -> Portraits, 2-5 -> Small groups, >5 -> Groups. The area ratio is
what separates "a portrait of someone" from "someone is in this landscape".
Three guarantees, all tested: it only ever fills an EMPTY category (enforced
in the query AND re-checked in the UPDATE, so a photographer setting one
mid-run still wins), everything it touches is marked auto_categorized so undo
is exact, and it is a no-op unless separately enabled. Migration 178 adds the
column — separate from 177, which has already run wherever this branch is
deployed.

Verified on the real gallery: 61 photos -> 48 portraits + 13 small groups,
undo cleared exactly 61 and left the manual ones alone. Merge moved faces and
removed the source. Both confirmed against the database, not just the UI.

TWO BUGS THE BROWSER CAUGHT, both invisible to tsc:

- The lightbox destructure never landed — my patch targeted a line that has a
  default value, matched nothing, and failed silently. `people` resolved to
  something else entirely and the chips would never have rendered. eslint's
  "outer scope value" warning is what surfaced it.

- Admin face thumbnails 403'd because <AuthenticatedImage> attaches whatever
  gallery token is in session storage; an admin who has also opened one of
  their own galleries sends a type:"gallery" bearer to an admin route. Admin
  routes authenticate from the httpOnly cookie, which a plain same-origin
  <img> sends by itself. Worth noting AdminPhotoGrid has the same latent
  shape; not touched here.

Also: the admin card now reports "N people (M shown to guests)" when those
differ, so the settings page and the gallery stop disagreeing without
explanation.

45 backend tests (8 new) and 140 frontend tests green; tsc and eslint clean.
EN + DE for every new string.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* perf(faces): batch migration DDL and drop the face stack from server.js import (#1074)

CI's backend job timed out at 10 minutes on the first run of this branch.
Nothing failed — 132 of 182 suites passed and the wall clock ran out. Main
does the same 182 in 124s, and where main has 12 suites slow enough for jest
to print a duration, this branch had 77.

Two changes, both worth making regardless of how much of the gap they close:

- Migration 177 added its columns one ALTER TABLE at a time (four on photos,
  three on events, plus a separate index statement) and seeded settings with
  a SELECT and an INSERT per key. It now uses one alterTable per table and
  one SELECT plus one bulk INSERT. 178 folds its index into the same
  statement as its column. That chain replays in ~90 suites, so statement
  count there is multiplied by 90.

- server.js required faceQueue at module scope, which pulls in axios and —
  through imageProcessor — sharp. Every supertest suite that imports
  server.js was paying for a module graph it never uses. Now required inside
  the startup block, next to the call that needs it.

Honest about the evidence: locally the migration delta measures at zero
(1.15s vs 1.13s for the same suite, three runs each), so batching alone does
not explain an eight-minute regression. A fast local disk and many cores mask
per-statement and per-import costs that a two-core runner with a shared disk
does not. These are the two real costs this branch added to a path that runs
in almost every suite; whether they are sufficient is a question for CI, not
for another round of local speculation.

37 face tests still green after the change.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* i18n(faces): complete EN and DE coverage for the face feature (#1074)

The admin card and the Features toggle were rendering entirely from inline
English `defaultValue` fallbacks — 22 keys existed in no locale file at all,
so a German admin saw an English consent notice, English toggles and English
buttons. The gallery side was already translated; the admin side was not,
and nothing in the toolchain flags this because a `defaultValue` always
renders something.

Adds the missing `admin.faces.*` (19), `settings.features.faces.*` (2) and
shared `common.clear/saved/saveFailed` in both languages. Existing keys are
left alone (setdefault, not overwrite), so the shared `common` strings other
features rely on are untouched.

Committed the audit as frontend/scripts/i18n-faces-audit.py rather than
throwing it away: it extracts every t() key the face components actually use
and diffs it against each locale, and it also reports German values that are
byte-identical to English, which is the usual shape of an untranslated
copy-paste. Currently: 69 keys in use, EN complete, DE complete, no
identical pairs.

Verified in the browser, not just in the JSON — the German card reads
"61 / 61 Fotos durchsucht · 16 Personen (5 für Gäste sichtbar)" end to end.
Also checked the components for hardcoded user-facing text (JSX nodes,
title/aria-label/placeholder attributes) outside t(); there is none.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): 13 defects from external review — coordinates, counts, erasure, races (#1074)

Codex reviewed the branch against main. Thirteen findings, nine P1. I checked
every one against the code and could not dismiss a single one as a false
positive, so all thirteen are fixed here.

THE WORST ONE: bounding boxes were stored in the wrong coordinate system.
The sidecar reports coordinates in the space of the image it was HANDED —
which is the ≤1920px preview, not the original — while every consumer
compares them against photos.width/height, the original dimensions. A 6000px
photo therefore produced boxes ~3x too small and areas ~9x too small: avatar
crops landed in the wrong place and the Portraits rule could never fire. It
is invisible on any photo already under 1920px, which is exactly why the
demo gallery and every screenshot looked correct. Now scaled once in
faceProcessor so everything downstream can assume original-image coordinates.

ERASURE. The FK cascade on photo_faces is decorative on SQLite: PicPeak never
enables `PRAGMA foreign_keys`, so deleting a photo left its embeddings
behind. I first enabled the pragma globally and reverted it — six unrelated
suites immediately failed on pre-existing dangling references, and switching
it on would start rejecting inserts on every existing install. That is a real
change worth making, but it is its own PR, not a rider on this one. Instead
deletion purges explicitly: purgePhotoFaces in the photo paths (single, bulk,
service) and photo_faces/event_people in deleteEventCascade. Tests assert
this with the pragma explicitly OFF, so they can only pass if the code does
the work.

COUNTS. A re-scan deleted the old face rows without undoing their
contribution to event_people, so counts inflated on every re-scan and ghost
people survived. Now the affected people are recomputed before the
replacements are assigned. My own "must not double its faces" test only
checked photo_faces rows, which is why it passed throughout.

RACES. A worker that finished after an admin purged the event committed its
rows anyway — erasure reported success and the data reappeared. The commit is
now conditional on the row still being 'processing'. And assignFaces is
read-modify-write over an event's people, so two workers lost each other's
updates; it is now serialised per event with an in-process mutex plus a
Postgres advisory lock for the multi-pod case the queue advertises.

METADATA LOSS. Merging discarded the source's name and suppression flags, so
a merge could erase a typed name or un-hide someone. Reclustering remembered
only people with a label, so an unnamed-but-hidden bystander came back
guest-visible after one "Re-group people" — and suppression now propagates to
every descendant cluster, not just the majority one.

Also: export reset face_status so a restored gallery re-scans instead of
claiming to be scanned forever; manual category edits clear auto_categorized
so "undo automatic" cannot delete a photographer's own choice; external
photos are skipped rather than failed (resolvePhotoStorageKey returns null
for them by design); the gallery refetches photo memberships as a scan
progresses so filtering is not stale; a failed VACUUM now fails the backup
rather than publishing one that may retain biometric pages; and the ML
Dockerfile's `|| true` is scoped to the uninstall — as written it was
`(install && uninstall) || true`, so a failed dependency install produced a
green layer and an image with no onnxruntime.

Four new regression tests. Full backend suite failure set verified identical
to origin/main; frontend 140 green; tsc and eslint clean.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): 12 more defects from review round 2 — cross-event purge, leaks, lifecycle (#1074)

Second Codex round on the same diff, now including round 1's fixes. Twelve
findings, seven P1. Again none were false positives.

SECURITY, AND MINE FROM ROUND 1: the bulk-delete face purge iterated the raw
`photoIds` from the request instead of the event-scoped `photos` rows the
handler had already validated. purgePhotoFaces has no event scope of its own,
so an editor could pass another gallery's photo id and delete its face data —
even though the photo deletion right below it was correctly scoped. Fixing
one thing and introducing another is exactly why the second round was worth
running.

ANOTHER VISIBILITY LEAK, same class as the one round 1 fixed: /people returns
scan progress, and getScanStatus counted every photo with a face_status —
including hidden ones. Guests could read the hidden-photo count off the
progress bar while the people list and covers beside it were properly scoped.
Now scoped by the same predicate, with the caller passing its audience.

RECLUSTER, ROUND 1'S FIX WAS INCOMPLETE. I made suppression follow every
descendant but still copied the flags from the majority ANCESTOR. When
reclustering merges a visible named person with a hidden one, the majority
ancestor is often the visible one — republishing the hidden person's photos.
Suppression is now OR-ed across every ancestor contributing faces. The name
also now goes to the genuine largest descendant; the previous code took
whichever cluster came first in map order, which the comment already claimed
it did not.

LIFECYCLE. Face data is excluded from backups and exports, but photos.
face_status came across intact, so a restored install claimed every photo was
scanned while holding no faces — and the worker only claims 'pending', so it
stayed that way forever. Now: the SQLite backup requeues in the dump, restore
requeues after the pool reinit (the Postgres path cannot rewrite rows inside
pg_dump), the portable importer purges LOCAL face tables (they were excluded
from the replace list, so another instance's embeddings survived an import
with FK checks suspended) and requeues, and archiving disables detection so a
restored archive is honestly off rather than enabled-and-empty.

WRITE PATHS. Only processPhoto enqueued. The synchronous upload path
(chunked-upload completion, watch-folder) left photos unscanned, and
replacePhoto kept the OLD image's faces on a row now pointing at a different
picture — stale identities shown on the new photo.

FRONTEND. PeopleSheet and the admin manager rendered centred thumbnails and
ignored the bbox, so on group photos the avatar showed whoever stood in the
middle and two people from one photo were indistinguishable — in the manager
whose entire job is telling faces apart. The crop maths is now one shared
helper (faceCrop.ts) so the three surfaces cannot drift again. Full-page
layouts (gallery-premium, gallery-story) render their own lightbox and never
received the people props.

Backend failure set verified identical to origin/main; frontend 140 green;
tsc and eslint clean.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): round 3 — five of round 2's fixes were wrong or no-ops (#1074)

Third and final Codex round. Eight findings, four P1 — and the important part
is that FIVE of them are defects in round 2's fixes, not in the original code.

- The sync-upload enqueue I added was a silent no-op. It queried through
  `trx` after the transaction had already been committed, which throws
  "Transaction query already complete" straight into the catch I had wrapped
  it in. Chunked uploads and watch-folder imports were still never scanned,
  and the code read as though they were. Uses `db` now.

- The post-restore requeue ran BEFORE the files were restored, in both the
  portable importer and the native restore. The face worker is live during a
  restore, so it could claim those rows and scan the previous instance's
  files, or fail them for originals not yet on disk — with nothing to requeue
  them afterwards. Both now run after file restoration; the native one is
  extracted into requeueFaceScans() and called from the full and
  database-only paths.

- The admin face crop mixed coordinate spaces: an original-pixel bbox scaled
  against the THUMBNAIL's natural size. The API now returns the source
  dimensions alongside the box, so there is one space to reason about.

- Forwarding people props through layoutProps did not make them work — the
  full-page layouts never destructured them. GalleryStoryLayout now threads
  them to its own lightbox.

Genuinely new findings, all in the same class as ones already fixed:

- releaseToPending updated unconditionally, so a photo purged while its
  sidecar request was in flight came back as 'pending' and was rescanned —
  biometric rows reappearing after the purge reported success. Round 2 fixed
  exactly this on the COMMIT path and I did not carry it to the retry path.
  Now guarded on 'processing'.

- purgePhotoFaces left face_status alone, so a worker mid-scan still
  satisfied its commit guard and could write fresh faces into a photo being
  deleted — orphans, since the FK cascade is inert on SQLite. It now clears
  the claim as part of the purge.

- Phase 3 was unreachable: the migration seeds face_auto_categorize_enabled
  false and nothing could ever write it, so the rule engine and its undo
  endpoint returned "disabled" in every real flow. Added GET/PUT and a toggle
  on the admin card, EN + DE.

NOT fixed, deliberately: GalleryPremiumLayout uses yet-another-react-lightbox
rather than the shared PhotoLightbox, so person chips there are a real port
rather than a prop forward. Recorded as open rather than bodged.

Backend failure set identical to origin/main; 41 face tests and 140 frontend
tests green; i18n audit reports EN and DE complete at 71 keys.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): block face recognition on the all-in-one image (#1074, #1042)

The single-container image cannot run this feature, so it is refused there
rather than left to degrade.

WHY, since the reason is not obvious from the code: the AIO image runs the
backend, the frontend, SQLite and every background worker inside one
container aimed at "one photographer plus guests browsing". It has no Redis,
SQLite gives it a single writer, and it contains no ML sidecar to talk to.
Face detection would add a second image-processing pipeline competing with
Sharp for the same CPU and memory. That failure is not loud — the install
just becomes slow and looks broken, which is the worst possible shape for a
deployment whose whole promise is one container and no decisions.

Gated on an explicit PICPEAK_SINGLE_CONTAINER marker, NOT inferred from
SERVE_FRONTEND or a SQLite path: plenty of legitimate multi-container setups
serve the frontend from the backend or run SQLite, and none of them should
lose the feature by accident.

Three layers, because the first is the only one that enforces:

- faceSettings.isFeatureEnabled() returns false before consulting the flag,
  so a database restored from a full deployment with `faces` enabled still
  cannot switch it on here.
- The feature-flag API forces `faces: false` in both directions, so the admin
  UI reflects reality instead of offering a switch that refuses to stay on.
- The Features tab renders the card disabled with a plain-language reason,
  read from a new `single_container` field on /admin/system/version (an
  endpoint the admin UI already calls).

Documented in ml/README.md and .env.example. Three tests pin the behaviour,
including that the marker only accepts explicit truthy values.

NOTE FOR PR #1068: this expects `Dockerfile.aio` to set
`ENV PICPEAK_SINGLE_CONTAINER=true`. That one line lives on that branch and
is not in this commit — until it lands, an AIO build would still offer the
feature. Worth adding alongside the `Limits` section of docs/single-container.md.

44 face tests green; EN + DE complete at 72 keys.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* test(faces): pin the bbox coordinate space with a real scale factor (#1074)

The coordinate-space bug — boxes stored in preview space while every consumer
reads them as original-image pixels — had no test, and could not have been
caught by the ones that existed: every photo in the demo gallery is 750px, so
the scale factor was always exactly 1.0 and the correction never executed.

Verified by hand first, on a real 4000x3000 upload with the face placed
off-centre so a wrong crop would be unmistakable. Before the fix the stored
box was 1493,204 (preview space, face actually at x≈2850-3618); after, 3110,426
— a factor of 2.083, exactly 4000/1920, landing inside the face. The admin
crop then resolved to left=-395px/top=-46px on a 64px window, which is the
face centred.

That verification is now a test rather than a memory. Three cases: a 4000px
photo must scale by 4000/1920, a 1920px photo must NOT change (the case that
hid the bug), and a row with no width must fall back to unscaled rather than
storing NaN.

Note for anyone extending these: jest hoists mock factories above the file,
so anything they close over has to be `mock`-prefixed. Getting that wrong
fails at transform time with a message that does not name the variable.

47 face tests green.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-18 22:37:28 +02:00
LucaandPaul Nothaft 8809564aad feat(backup): open sqlite → pg .picpeak restore as the supported upgrade direction (#1041) (#1043)
Reshaped onto main after #1039 landed the coercion engine
(typedColumnsFor / epochToIso / coerceForTargetEngine) — this PR is now
only the policy delta on top of it:

- validateManifest: replace the CLI-only allowEngineSwitch opt-in with a
  direction rule — sqlite → pg allowed (upload UI and CLI alike),
  pg → sqlite refused with a message naming the supported direction
- importFromPicpeak: derive crossEngine from the manifest's engine
  (absent field = target engine, the exact pre-change behavior), log it,
  return it; route passes it through
- scripts/migrate-sqlite-to-postgres.js: rely on the shared gate, drop
  the flag
- restore card: direction stated in the intro, cross-engine notice after
  a converting restore; both strings in en.json + de.json; removed the
  orphaned settings.backup.picpeak locale node (unreferenced, stale copy)
- picpeakCrossEngine.test.js: direction policy, epochToIso (ms, seconds,
  numeric strings), coerceForTargetEngine units, plus
  PICPEAK_PG_TEST_URL-gated real-Postgres stored-value assertions

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-16 19:01:36 +02:00
Luca b118695474 feat(permissions): granular permission gating + role editor & presets (#747, phase 1 of #743) (#1045)
* feat(permissions): granular permission gating + role editor & presets

Make every admin feature permission-gateable so multi-user studios can
split capability across roles (#747, and phase 1 of #743).

- Split the catch-all settings.edit into dedicated dangerous-config perms
  (banking / domains / security / integrations / features): a team member
  can no longer change IBAN, domains, SSO, webhooks, API tokens or feature
  flags. Reads keep an OR with settings.view so existing roles keep
  visibility. The site-URL write inside /general is change-gated on
  settings.domains.
- Add dedicated perms for admin surfaces miscategorised under settings.*
  (whatsapp, event_types, image_security, notifications, system) plus
  roles.manage and vat_codes.view; gate the previously-ungated VAT read.
- Boot self-heal (_permissionsBoot.js): super_admin always holds every
  permission (tracks-all) so new perms never need a compensation
  migration; all other roles stay frozen (no silent escalation on upgrade).
- Seed two presets: Solo Photographer (full operator) and Team
  Photographer (contributor — view events + manage photos + read-only CRM;
  no settings/users/billing edits, no events.edit).
- Role editor: adminRoles CRUD (create/edit/clone/delete + permission
  matrix; system roles protected, super_admin immutable) and a Roles tab
  with a category-grouped matrix and preset cloning.
- Settings page tabs are permission-gated with snap-back; i18n en/de.

Migration 174. Backward-compatible: admin/editor/viewer unchanged.

* feat(permissions): hide in-page action buttons a role can't use

Wrap mutating controls on the surfaces restricted roles actually reach
(Events list, Archives, gallery photo grid, event detail) in
PermissionGate so they are HIDDEN when the user lacks the permission,
rather than shown-then-403:

- Events list: create / bulk archive / bulk delete / row archive /
  row delete / download-archive.
- Archives: restore / download / delete.
- Photo grid: single + bulk delete (photos.delete), per-photo download
  (photos.download), bulk move/hide/show (photos.edit).
- Event detail: edit / rename / publish (events.edit), duplicate
  (events.create), archive (events.archive), create-invoice
  (bills.manage); the Actions card is hidden entirely for view-only roles.
- Photos tab: upload / external import (photos.upload), export menu
  (photos.download).

Backend already enforces these with 403; this is the matching UX so a
Team Photographer never sees delete/settings controls.

* fix(permissions): close settings-split bypass via generic settings writers

Security review found the settings.edit split was bypassable: the generic
settings writers (/general, /analytics, /seo, /security) upsert arbitrary
setting_keys, so a role holding only settings.edit (or settings.security)
could write keys owned by a narrower permission — repointing the public
site URL (settings.domains), security policy (settings.security) or
VAT/accounting config (settings.banking) via the wrong endpoint.

Add stripUnauthorizedProtectedKeys(): before every generic upsert, drop
any protected key the caller isn't permitted to write (general_site_url →
settings.domains, security_* → settings.security, accounting_* →
settings.banking). Dedicated routes still work because their caller holds
the matching perm. Replaces the narrower in-handler site-URL guard.

Also fix two tests affected by the RBAC changes:
- authzPermissionGaps: API-token management moved to settings.integrations,
  so grant that (not settings.edit) to exercise the ownership 404.
- AdminPhotoGrid.viewToggle: stub PermissionGate (its buttons are now gated
  and the test renders without a PermissionsProvider).

* fix(permissions): address upstream review (#1045)

- Renumber migration 174 -> 175 (174 now taken by 174_sqlite_nullable_event_dates
  from #1035; the collision made picpeakImportService's forward-only restore
  guard treat both as order 174 and accept a newer .picpeak onto an older schema).
- Contain the roles.manage blast radius (delegation, not root escalation): a
  non-super_admin can no longer edit their own role, nor grant any permission
  their own role doesn't already hold (createRole + updateRole).
- Protected-key denial now 403s (naming the keys + required perms) instead of
  silently stripping and reporting "saved" (adminSettings generic writers).
- Reserve team_photographer so a custom role can't squat the preset name.
- Boot self-heal: per-step try/catch so a role_permissions insert race on one
  replica doesn't skip preset seeding.
- Forward-project the feature .manage perms that also replaced settings.edit
  gates (whatsapp/event_types/image_security/notifications/system), matching the
  settings.* split projection so the pattern is symmetric for phase-2.
- Guard exports.down's roles/admin_users queries with hasTable.

* fix(permissions): change-detection on protected-key 403 + commit guard tests (#1045)

Round-2 review:
- The protected-key 403 fired on key PRESENCE. The General tab re-posts
  general_site_url on every save, so a settings.edit-only role (the office
  manager this PR enables) got 403'd on every General save even when the URL
  was unchanged. Restore change-detection: compare the incoming value against
  the stored one and 403 only on an actual change; unchanged protected keys are
  dropped so the rest of the save proceeds. Only /general is affected.
- Commit the self-amplification guard test (was run locally, never staged):
  adminRolesGuards.test.js — non-super can't grant perms it lacks, can't edit
  its own role, can't escalate another role; super_admin bypasses;
  team_photographer name reserved.
- Add adminSettingsProtectedKeys.test.js pinning the change-detection: an
  unchanged general_site_url saves, an actual change 403s, super_admin changes it.
2026-08-16 14:59:52 +02:00
Paul Nothaft 8e3573788b feat(downloads): per-gallery download resolutions (#858) (#1022)
Clients who need smaller files no longer make the photographer re-export. Two capabilities, both off by default.

STANDARD RESOLUTION — the size a gallery hands out for every ordinary download (single, selected, download-all). Global default in Settings, overridable per gallery with the NULL=inherit tri-state. The pre-built download-all zip is built AT the standard resolution, so changing it invalidates those archives, including a fan-out to inheriting galleries.

RESOLUTION PICKER — opt-in modal letting guests choose a different size. Custom archives are built as a DB-backed job the client polls, never cached. The picker never offers a size above the standard, and Original reappears only when the admin explicitly allows it.

Resize is fit:'inside' + withoutEnlargement — aspect preserved, never upscaled — applied before the watermark, since the mark is sized relative to its input.

Three rounds of external review hardened this: job archives are bound to the requester's visibility scope and re-validated at delivery, the streamed download-all path applies the cap, queue admission is bounded, and rejected resolutions no longer inflate download stats.

Closes #858.
2026-08-11 09:46:46 +02:00
LucaandLuca-Timo 2e495d7c48 feat(transfers): add PicTransfer — cross-event file transfers (#998)
Closes #997.

Send original files from any event as a token-protected download link, with an
optional client-upload channel. Strictly opt-in behind a new `transfers`
feature flag, default OFF.

Migrations 170-172 (transfers, transfer_files, transfer_extra_files,
transfer_uploads, transfer_recipients, transfer_downloads, default settings and
two email templates) — all hasTable/hasColumn-guarded and idempotent, with
destructive statements confined to down().

Backend: transferService (CRUD, 256-bit download token, 6-char upload token,
cross-event ZIP streaming of originals), admin CRUD routes, and two public
token routes. transferCleanupService runs an hourly retention sweep; source-event
photos are never touched. All three routers fail closed via
requireFeatureFlag('transfers').

Review closed two ownership blockers, both the same root cause — permissions
used where ownership was needed:

- photoIds arrived from the request body and were validated only for existence,
  so a scoped admin could bundle any event's originals and hand them out through
  the public download token. filterOwnedPhotoIds now resolves ids to their events
  and gates them through filterOwnedEventIds, on both the create and add-files
  paths.
- The transfer list was unscoped and carried each row's download token, so any
  admin with events.view could read another's token and fetch their originals.
  The list is now scoped by created_by, the token/url fields are stripped from
  the list payload, and a single router.use('/:id', requireTransferOwnership)
  covers all twelve /:id routes, 404ing foreign and missing alike.

The admin photo picker filters its event list to the same rule, so the UI stops
offering picks the API would discard.

Fork-PR workflows had not been approved since the fix commits, so the PR's green
checks were stale against the pre-fix head. Verified by dispatching tests.yml
against the actual head: backend and frontend both green.

Follow-up: neither ownership guard has a regression test yet.

Co-authored-by: Luca-Timo <[email protected]>
2026-08-09 13:40:03 +02:00
Paul Nothaft 137a42f259 feat(admin): surface the registry move through the update check (#993)
Relates to #985 — does NOT close it.

Adds registryMigrationRequired to the update-check payload (stable channel below
3.45.0) and an amber block in UpdateNotification explaining that the retired
registry path still responds, so `docker compose pull` appears to succeed while
serving the same frozen build.

Known limitation, established in review and merged deliberately: this cannot
reach the operators #985 describes. PicPeak is self-hosted, so the update-check
code runs inside the operator's own image — a v3.44.0 install runs v3.44.0's
backend forever, and the only external call returns release metadata, not logic.
Every build containing this predicate is >= 3.45.0, where it is false by
definition. The release-notes fallback fails too: the changelog modal shipped
2026-05-29, two days after the freeze.

Correct for any future rename, no runtime cost, but #985 stays open — the
population it describes still has no in-app channel. Viable routes are external
(retired GHCR package description, repo README, docs).

'0.0.0' is excluded from the predicate: that is getCurrentVersion's fallback for
an unreadable package.json, i.e. a broken install, not a pre-rename one.
2026-08-04 16:36:50 +02:00
Paul Nothaft 4b53b64277 fix(accounting): gate cross-add counters on the permission their endpoint checks (#984)
Closes #983.

The two cross-add counter queries added in #979 were enabled on customers.edit,
but neither endpoint checks that permission:

  HoursSection      -> GET /expenses/inbound/by-customer/:id  needs accounting.view
  CustomerCrmPanels -> GET /customers/:id/hour-entries        needs customers.view

An admin holding customers.edit but not the corresponding read permission fired
a guaranteed 403 on every customer-detail render. It degraded safely — the count
stayed at its 0 default so the cross-add was never offered, which is the right
outcome for that role — so this was request noise rather than broken behaviour.

Each guard now requires both: the read permission to fetch the count, and the
write permission because there is no point offering the cross-add to someone who
cannot create the combined invoice.

No seeded role is affected: migration 123 grants accounting.view and
accounting.manage together, and customers.edit projects forward from
customers.create, which migration 090 always grants alongside customers.view.
2026-08-04 14:17:34 +02:00
Luca 165cebdb5c feat(accounting): re-bill proof attachment, CRM panel & hours↔re-bills cross-add (#979)
Closes #866.

Three features, all behind the `incomingInvoices` feature flag:

1. Attach the stored supplier proof PDF to the client-invoice email when a
   captured invoice is re-billed/passed through, as a SEPARATE attachment so
   invoice immutability holds. Global default (off), per-customer tri-state
   override, and per-file selection in a new Send dialog. A missing proof at
   issue time stamps inbound_documents.proof_attach_error rather than silently
   dropping, and never blocks the send. Proof filename is a configurable
   template with {INVOICE} {SUPPLIER} {YEAR} {MONTH} {SEQ}/{SEQ:0Nd} tokens.

2. Re-bills & passthrough panel under CRM → Customer, grouped Open/Sent/Paid
   with status derived from the linked invoice lifecycle rather than a
   duplicated column.

3. Cross-add dialog rolling open hours and open re-bills into one invoice,
   symmetric from both entry points. The two stay distinct, contiguous line
   groups — never merged into shared line items.

Migration 169 is additive, hasColumn-guarded and idempotent.

Review (two rounds) closed two concerns:

- Storno stranding: nothing cleared inbound_documents.billed_invoice_id when a
  covering invoice was cancelled, so a Storno'd re-bill showed as Open in the
  new panel while every billing path filters on that column being NULL — the
  supplier cost could never be re-billed. releaseRebillsForCancelledInvoice now
  detaches the linkage on both invoice-cancel paths, with a regression test on
  the issued-cancel path.

- Permission gating: the new controls rendered on data presence alone while
  their endpoints require accounting.view / accounting.manage / customers.edit.
  Now gated at both the query and render layers.

Known follow-up: two cross-add counter queries are gated on a permission their
endpoint does not check (HoursSection.tsx:174, CustomerCrmPanels.tsx:270) —
degrades safely, one line each.
2026-08-03 22:03:31 +02:00
Paul Nothaft a2e723413e fix(backup): make backup settings actually apply (#871) (#874)
* fix(backup): make backup settings actually apply (#871)

- Wire the What-to-Backup toggles into the walker: honor
  backup_include_thumbnails / backup_include_photos (opt-out,
  default ON) and accept the UI's backup_include_archives spelling
  for the archived gate (the engine expected _archived, so the
  Archives checkbox silently never worked).
- Fix the 167.6 TB dashboard size: file_size_bytes is a bigint that
  node-postgres returns as a string, and the S3 path concatenated it
  onto the byte counter; coerce to Number at the source.
- Compute the real next scheduled run (cron-parser) and return it as
  nextBackup; the UI read a field the API never sent and rendered a
  hardcoded 'Not scheduled'. A named schedule label now beats the
  stray default cron the UI always sent, which silently turned
  weekly schedules into daily 03:00 runs.
- Never back up filesystem noise (.nfs* silly-renames, .DS_Store,
  Thumbs.db) and honor backup_exclude_patterns in the walker
  (previously rsync-only).
- Remove the compression/encryption toggles from the configuration
  UI: no backend implementation exists, and collecting an encryption
  passphrase while uploading plaintext is a false promise.

Closes #871

* fix(backup): close the review gaps in the settings wiring

- The UI's backup_include_archives now beats the migration-seeded
  backup_include_archived: every install has the singular key seeded
  true, so the alias-only-when-absent lookup made unchecking Archives
  a no-op.
- rsync destinations now receive the de-selected What-to-Backup paths
  and the noise filters as anchored --exclude args; previously rsync
  synced the whole storage root and the walker's selection only shaped
  the manifest, which then misreported what was actually transferred.
- Escape regex metacharacters in the walker's glob matcher: '.nfs*'
  compiled to /^.nfs.*$/ whose leading dot matched any character, so
  files like anfs-photo.jpg were silently dropped from backups.
- The Backup Coverage report now uses the same gate as the walker
  (new 'skipped-by-setting' status) instead of re-implementing it
  without the opt-out toggles and the archives alias.

* fix(backup): make the coverage diagnostics agree with the walker

- The coverage table shows the alias-aware flag value the gate actually
  used, instead of the seeded backup_include_archived shadowed by the
  UI's plural key (true next to a 'Gated off' badge).
- skipped-by-setting paths are now counted in the coverage summary
  (backend, TS contract, summary card, EN/DE locales) so the totals
  reconcile again when Photos or Thumbnails is unchecked.
- The form's thumbnail default now matches the backend's never-saved
  fallback (include): the checkbox no longer shows 'off' while
  thumbnails are being backed up, and saving an unrelated setting no
  longer flips the backup scope.

* fix(backup): keep custom crons, exclude disabled rows from rsync, normalize flag display

- Saving a named schedule no longer wipes the stored custom cron: the
  backend already prefers the label, so the cron field stays inert for
  named schedules and is preserved for switching back to Custom. A
  custom schedule now validates the 5-field expression before saving
  (the backend silently fell back to daily 02:00 on a blank value).
- resolveExcludedBackupPaths now also returns rows disabled via
  include_in_default, so rsync excludes them; the enabled-only loader
  hid them and rsync transferred their contents anyway.
- The coverage table normalizes flag values like the walker does —
  Boolean('false') displayed true beside a gated-off badge.
2026-07-27 09:06:38 +02:00
Paul NothaftandPaul Nothaft 3d6c9848dc feat(feedback): emoji reactions on photos (#839) (#855)
* feat(feedback): emoji reactions on photos (#839)

Per-photo emoji reactions from a fixed curated set (❤️ 😂 😍 👏 🎉),
one reaction per guest per photo — same emoji toggles off, another
switches in place. Stored as feedback_type='reaction' rows with per-guest
scoping identical to likes (guest_id when present, device hash otherwise).

- migration 164: allow_reactions toggle (default on, still gated by the
  opt-in feedback_enabled master switch), photo_feedback.reaction value
  column, denormalized photos.reaction_count
- emoji whitelist enforced in the route validator AND the service
  (shared constants/reactions.js, mirrored in the frontend)
- per-emoji tallies + my_feedback.reaction in the photo feedback
  endpoint; hidden-by-moderator reactions leave all counts
- reactions ride the existing rate limiting (like-tier), guest identity
  modes, and moderation actions; long + pivot exports carry the emoji
- gallery: reaction bar in the photo feedback panel (grid lightbox);
  admin: allow_reactions toggle next to likes, analytics tile,
  create/duplicate event paths
- i18n for all 8 locales; 9 service-level tests

* fix(feedback): reach reactions without comments; numeric analytics totals (#839)

- the lightbox feedback-panel toggle was gated on allow_comments only —
  with comments off the new reaction bar was unreachable; the gate now
  opens for comments OR reactions
- the analytics summary now coerces Postgres string counts to numbers:
  total_feedback concatenated instead of adding ("00006")

* fix(feedback): harden reactions from review round 1 (#839)

- per-emoji tallies are gated on show_feedback_to_guests — with sharing
  off a guest sees only their own selection, no aggregate counts
- reaction toggle/switch operate on the guest-scoped row SET, so rows
  duplicated by the (like-parity) check-then-insert race collapse on the
  next interaction instead of counting twice
- rate-limit defaults merge UNDER the persisted settings object —
  stored rows predating the reaction key otherwise dropped it to the
  generic 100/h fallback
- optimistic revert uses the pre-mutation value via mutation context;
  the onError closure sees the post-optimistic render, so the old
  revert froze the wrong state on failed toggles

* fix(feedback): review round 2 — hide reaction_count with sharing off, admin list shows emoji (#839)

- summary.reaction_count is gated on show_feedback_to_guests like the
  per-emoji map, keeping the "no aggregates while sharing is off"
  promise consistent
- the admin feedback list renders the reaction emoji on reaction rows
  and the type filter gains a Reactions option (7 locales; es has no
  types block and falls back to EN defaults)

* fix(feedback): register reaction activity types with translated labels (#839)

photo_reaction / guest_feedback_reaction are logged by the submission
paths but were absent from the frontend activity-type union and the
admin.activities label maps — the recent-activity feed would have shown
the raw identifiers. All 8 locales.

* feat(feedback): reactions in guest CRM and the premium gallery layout (#839)

- guest CRM: per-guest reaction counts in the list aggregation and a
  Reacted tab (photo grid with emoji badges) + stats card in the guest
  detail modal; picks/aggregate/exports stay selection-only by design
- premium layout: its own yet-another-react-lightbox now gets a fixed
  reaction-bar overlay (per-photo fetch, optimistic switch) — reactions
  were otherwise unreachable in this layout since it bypasses the
  shared PhotoLightbox
- allowReactions threaded through the layout feedbackOptions; guest
  i18n keys for the 7 locales that carry the guests block

* fix(feedback): portal the premium reaction bar to document.body (#839)

Inside the layout tree an ancestor stacking context (framer-motion
transforms) painted the bar under yarl's body-level portal — visible
but unclickable, every tap landed on the slide image. As a direct body
child the z-index 10000 genuinely wins over yarl's 9999. Verified by
clicking through in the running app.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-07-22 20:59:43 +02:00
Paul Nothaft cb5b319f10 feat(notifications): surface guest activity in the admin bell (#849)
* feat(notifications): surface guest activity in the admin bell (#746)

Favorites already reached activity_logs (feedbackService), but gallery
opens and downloads only landed in access_logs — invisible in the
notification bell. Now:

- gallery_opened on the guest photo-list route, debounced in-memory to
  one notification per event per 6h (the endpoint fires per page load;
  per-hit notifications would spam the bell). Slideshow traffic stays
  excluded, matching the analytics exclusion.
- gallery_downloaded on all four download paths (streamed + pre-zipped +
  presigned download-all, download-selected) with scope metadata.
- Frontend: locale entries for galleryOpened/galleryDownloaded (and
  photoFavorite, which previously fell through to the generic 'system
  activity' line) in all 8 languages — resolved via the existing smart
  camelCase fallback, no switch cases needed. Distinct bell icons per
  type.

* fix(notifications): single-photo download activity + render per-type bell icons (codex review of #849)

- The per-photo Save route (GET /:slug/download/:photoId) only wrote to
  access_logs — the most common download path never reached the bell.
  Now emits gallery_downloaded with scope 'single', debounced to one
  notification per event per hour: a guest saving 30 photos is one
  signal, not thirty (exact counts stay in access_logs/analytics).
- getNotificationStyle's icon names were dead — AdminHeader hard-coded
  <Bell> for every row. Added an icon map so gallery opens (Eye),
  downloads (Download), favorites (Heart) and the pre-existing style
  names render their intended icons.

* fix(notifications): notify after successful delivery, complete the icon map (codex review of #849, round 2)

- Single-photo notification now fires on res 'finish' with status < 400:
  emitting up-front logged downloads that then 404ed/failed AND burned
  the 1h debounce window against the next real download.
- Icon map completed over every name getNotificationStyle returns
  (grep-verified) — settings/user/mail/etc. styles render their declared
  icons instead of falling back to Bell.

Deliberately NOT taken from the review: DB-backed debounce state for
multi-worker deployments. The backend's current deployment contract is
single-process (no PM2 cluster in-repo; multi-replica explicitly parked
in #799 — chunked-upload/session state is process-local for the same
reason). Worst case under a future multi-worker setup is N notifications
per window, which degrades, not breaks; a shared-store debounce belongs
to the #799 phase-3 work.

* fix(notifications): attribute client sessions, log cached-ZIP after finish, add Trash2 icon (codex review of #849, round 3)

- gallery_opened/gallery_downloaded now carry the real actor: client
  sessions (accessLevel 'client') are recorded as 'customer' instead of
  being mislabeled 'guest' — #746 explicitly covers client activity, so
  they are attributed, not excluded.
- Cached-ZIP streaming path logs on res 'finish' (< 400) like the
  single-photo path — piping is not delivery. The presigned-redirect
  and on-the-fly-archiver paths keep their existing timing (redirect
  handoff / post-finalize).
- Trash2 added to the icon map (customer_erased, bulk_delete_completed
  no longer fall back to Bell — the grep that built the map missed the
  digit in the name).

* fix(notifications): dashboard formatting, portal dedup, actor-aware wording, archiver finish-hooks (codex review of #849, confirmation round)

- activity_logs feed TWO surfaces: the dashboard's Recent Activity used
  admin.activities.<type> keys that didn't exist, rendering raw
  identifiers — added gallery_opened/gallery_downloaded entries in all
  8 locales.
- Customer-portal opens already log customer_event_access at the
  access-token mint; the ensuing /photos call no longer double-notifies
  (client sessions surface via downloads only).
- gallery_downloaded formatting is actor-aware: customer sessions render
  'Customer downloaded…' (new galleryDownloadedCustomer key ×8) instead
  of 'A guest…'.
- Both on-the-fly ZIP paths (download-all fallback + download-selected)
  notify on res 'finish' < 400 — archive.finalize() ends Archiver's
  input, not the HTTP transfer.

* fix(notifications): key customer dedup/attribution on portal provenance, neutral favorite wording (codex review of #849, final round)

The previous dedup was inverted: portal-minted tokens carry
via:'customer' but NO accessLevel (they run as guest), while PIN-client
logins carry accessLevel:'client' and log nothing else. So PIN clients'
only open signal was suppressed while portal opens still double-
notified and portal downloads read as guest activity.

verifyGalleryAccess now surfaces req.viaCustomer; gallery_opened dedups
on THAT (portal only), and galleryActor treats via-customer OR
accessLevel-client as 'customer'. photoFavorite wording is actor-neutral
across all 8 locales — feedbackService logs favorites without an actor,
so claiming 'a guest' was wrong for customer favorites.
2026-07-19 22:36:23 +02:00
Paul Nothaft e8dad4b40d feat(slideshow): guest-scannable share-link QR overlay (#848)
* feat(slideshow): guest-scannable share-link QR overlay (#837)

- Global settings (Settings → Slideshow): slideshow_qr_enabled/position/
  opacity/size — same option shape and cascade as the watermark.
- Per-event tri-state show_qr (migration 163): NULL inherits the global,
  true/false force on/off; editable in the per-event slideshow card.
- State endpoint ships the QR as a PNG data URI (cached per share URL —
  the 3s projector poll never re-encodes), so the kiosk needs no QR lib
  and no extra authenticated request.
- Kiosk renders the QR in a white padded corner box so it stays
  scannable on any photo.
- i18n: en + de (the slideshow namespace has no other locales yet).

* fix(slideshow): persist per-event QR override, show QR on empty shows, bound the QR cache (codex review of #848)

- OverviewTab never passed event.show_qr into the settings card (and the
  Event type lacked the field), so a stored true/false override always
  displayed as 'inherit' and the next save silently reset it to NULL.
- The QR overlay was nested inside the photos.length > 0 branch — an
  empty or category-filtered live gallery showed only 'Waiting for
  photos', exactly when 'scan to add the first photos' matters most.
  Now rendered for any running show.
- slideshowQrCache: insertion-order eviction at 50 entries — rotated
  tokens and past events no longer accumulate base64 PNGs forever.

* fix(slideshow): derive the QR origin from the kiosk request when the base is loopback (codex review of #848, round 2)

With the compose-default FRONTEND_URL=http://localhost:3000 (or no base
configured) the overlay QR sent scanning phones to their own localhost.
The state poll comes from the kiosk browser itself, so its Host header +
protocol (trust proxy is configured) are exactly the public origin
guests can reach — used whenever the configured base is missing or
loopback. Mirrors the ?origin= fallback #847 uses for the admin-side
QR downloads.

* fix(slideshow): kiosk passes its origin for the QR fallback (codex review of #848, round 3)

req.get('host') is not the browser origin behind the standard proxies —
frontend/nginx.conf forwards $host with the port stripped, so a compose
LAN deployment on :3000 encoded port 80. The kiosk now sends
window.location.origin with the session/state calls (validated
server-side, same pattern as #847's admin downloads); the Host-derived
origin remains as second fallback.

* fix(slideshow): reject loopback kiosk origins, throttle QR regeneration per event (codex review of #848, confirmation round)

- A loopback window.location.origin from the kiosk is no more
  guest-reachable than the loopback base it would replace — rejected;
  when no reachable URL remains the overlay is suppressed entirely (no
  QR beats a QR that sends phones to their own localhost). New test
  pins the suppression.
- The QR cache is keyed by event id with a 60s regeneration throttle:
  the origin is caller-influenced when the base is loopback, so
  URL-keyed caching let a slideshow-link holder force a fresh
  QRCode.toDataURL per request via unique origins — a cheap CPU
  exhaustion path. Encode rate is now bounded per event regardless of
  input. QR margin also raised to the 4-module spec quiet zone,
  matching #847.

* fix(slideshow): never serve a mismatched cached QR + single-flight encoding (codex review of #848, final round)

- A slideshow-token holder could poison the projector's QR: an
  attacker-origin entry cached per event was served to the legitimate
  kiosk for the rest of the throttle window. A cached artifact is now
  only served when its URL matches the request; mismatches inside the
  window suppress the overlay briefly instead of showing foreign
  content.
- Cold-cache stampede closed: concurrent polls share one in-flight
  encode promise instead of each scheduling a 512px render.

Rejected from the same round (false positive, verified empirically):
the loopback regex claim — /^https?:\/\/(localhost|127\.)/ matches
'http://localhost:3000' and '127.0.0.1:port' just fine (no trailing
slash required), and the suppression test runs green.
2026-07-19 22:36:03 +02:00
Paul Nothaft c9b64d9c1a fix(uploads): register HEIC/HEIF with the file validator + fix admin format hint (codex review of #832)
Two findings from the Codex review:

- validateFileType requires an ALLOWED_MEDIA_TYPES entry, which had neither
  image/heic nor image/heif — so HEIC was rejected before sharp ever saw it,
  despite the EXTENSION_TO_MIME additions. Added both with a single 'ftyp'
  (offset 4) magic number (the check is .every, so alternatives can't be
  separate entries).

- Changing the shared upload.fileRequirements string to interpolate {{formats}}
  left the admin PhotoUpload caller passing only { limit }, rendering the
  placeholder literally (it was also already dropping {{sizeLimit}} from #823).
  The admin caller now passes formats + sizeLimit + limit, from the admin
  settings it already loads.
2026-07-17 22:06:40 +02:00
Paul Nothaft 38fd41aad3 fix(security): harden .picpeak restore operator-preservation (GHSA-qxfx follow-up)
The req.admin.id fix activated reinjectCurrentAdmin(); hardening its preservation
logic (found across Codex review rounds of #811):

- MFA hijack: reinject wrote back only password_hash/is_active/
  must_change_password, leaving a crafted backup's two_factor_* on the
  operator's row — it could strip or replace their second factor. The email-
  matched row is now updated with the operator's full AUTH set (login identity,
  password, and all two_factor_* columns). Relationship/audit FKs (role_id,
  created_by) are deliberately NOT forced from the snapshot: on a cross-instance
  restore those pre-restore ids may be absent from the backup and would dangle
  the FK (SQLite rolls back at commit); the restored row keeps its own valid
  values.

- Cross-instance restore rollback / FK safety: reinject matched only by email,
  so a backup shipping a different admin with the default `admin` username hit
  UNIQUE(username) and rolled the whole restore back; email and username could
  even collide on two different rows. Reconciliation is now non-destructive:
  the email-matching row is updated in place (id preserved → restored FKs like
  events.created_by stay valid); any different row holding the operator's
  username is RENAMED, not deleted (deletion would fire ON DELETE actions /
  dangle references); only when no row has the operator's email is a fresh row
  inserted, with created_by nulled and an explicit max(id)+1 id (batchInsert
  left the Postgres identity sequence unadvanced, so a sequence-based insert
  could collide).

- Stale session after restore: admin_users ids shift on restore, but the
  operator's live JWT is bound only to decoded.id (IP logged not enforced; the
  backup controls password_changed_at). The route now revokes the token (result
  checked and logged) and clears the admin cookie; the client redirects to a
  fresh login via a sessionInvalidated flag. Cookie clear is the unconditional
  guarantee.

Adds SQLite-backed reinject regression tests (in-place login/MFA restore with id
and FK columns preserved, username-only rename, email+username on different rows,
clean insert with created_by nulled) and the frontend redirect on
sessionInvalidated.

Deferred (design decisions / pre-existing, need a Postgres test env — see PR
discussion): global "invalidate all pre-restore sessions" cutoff; preserving the
operator's ROLE semantics across an RBAC-table replace; and resyncing Postgres
identity sequences after any restore (batchInsert leaves them behind max(id) —
pre-existing, affects every restored table).
2026-07-16 12:27:59 +02:00