* 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.
Implements the three restore-hardening items deferred from the #811 Codex
review (all validated against a real Postgres, see __tests__/integration/
picpeakRestorePg.test.js). Backend-only; targets main (feature, not a backport).
1. Global session cutoff (utils/sessionCutoff.js). A restore reassigns admin/
customer/event ids, so ANY pre-restore JWT can rebind to a different restored
principal. Revoking just the importing token wasn't enough. importFromPicpeak
now stamps a unix-second cutoff in app_settings after the restore commits, and
adminAuth / galleryAuth / verifyGalleryAccess / customerAuth reject any token
whose iat predates it (cached 30s → one in-memory compare on the hot path).
The operator's forced re-login mints a token past the cutoff, so it passes.
2. Role preservation across an RBAC replace (captureOperatorRole /
preserveOperatorRole). The operator's role + granted permission NAMES are
captured before the wipe; after roles/role_permissions are replaced the role
is resolved by NAME against the restored data, and re-created with its grants
if the backup omits it — so a crafted or cross-instance backup can't silently
downgrade or lock out the operator. reinjectCurrentAdmin now returns the
operator's id so the row can be re-pointed at the resolved role.
3. Postgres identity-sequence resync (resyncSequences). batchInsert writes
explicit ids without advancing the sequences, so the next natural insert into
any restored table collided on the PK. Runs AFTER commit (setval isn't
transactional) and guards every table with a column-existence check —
pg_get_serial_sequence RAISES on id-less tables like role_permissions.
No-op on SQLite.
Tests: SQLite unit tests for the cutoff and role preservation; a gated Postgres
integration suite (npm run test:pg with PICPEAK_PG_TEST_URL) covering sequence
resync, the id-less-table guard, explicit-id reinject, role re-creation, and a
full cross-instance replaceAllTables run asserting operator preservation, role
re-establishment, FK integrity, and collision-free post-restore inserts.
Stacks on #811 (shares the reinject hardening); merge after it.
Two pre-existing bugs surfaced while reviewing #806 (kept separate per
scope policy — no OIDC code here):
- backup_s3_secret_key and backup_rsync_ssh_key (an SSH PRIVATE KEY)
were returned in PLAINTEXT by GET /admin/backup/config and by the
generic settings reads (GET /admin/settings and /admin/settings/:type
— which mask the recaptcha/umami/rybbit keys but not these). All
three now mask with the established bullet sentinel, and
PUT /admin/backup/config skips the sentinel on write so the edit form
round-trips without clobbering stored credentials (same pattern as
the email/WhatsApp config endpoints)
- /api/auth/admin/login/mfa was missing from the maintenance-mode
allowlist: the first login step passed, the second factor got a 503 —
any MFA-enrolled admin was locked out exactly while maintenance mode
was on
Regression tests: masking on all three read paths, sentinel round-trip
preserves stored values, real rotation still writes.
Round 1:
- bind SSO identities to (external_issuer, external_subject): OIDC only
guarantees sub uniqueness within an issuer, so a sub-only lookup let a
newly configured IdP's user inherit an old IdP's admin account on
subject collision; migration 162 gains external_issuer + composite
unique index (unmerged migration, edited in place)
- fetch UserInfo (with sub cross-check) when the ID token carries no
email — spec-compliant providers may serve email/profile claims only
there; ID-token claims win on merge
- allowlist /admin/sso/login + /callback in maintenance mode, or
SSO-only (JIT) admins are locked out exactly when they need in
- strip reserved keys (oidc_client_secret, setup_token) from BOTH
generic settings reads (GET / and GET /:type)
Round 2:
- redirect_uri prefers API_URL (the API's public origin — where the
state cookie lives); final redirects absolute to the frontend base;
login button builds its URL via buildResourceUrl — split-origin
deployments (absolute VITE_API_URL) work end to end
- PUT /sso validates the MERGED resulting state (partial update cannot
blank issuer/client while enabled=true survives; enabling requires a
derivable redirect URI)
- openid scope forced into oidc_scopes on save
- discovery-cache key includes a secret fingerprint (multi-worker
secret rotation)
- email→admin linking claims the row atomically (conditional update on
external_subject IS NULL) — concurrent first-time callbacks with the
same verified email but different subjects can't both authenticate
Tests: mock IdP gains userinfo endpoint + email-via-userinfo-only mode;
new cases pin the userinfo merge and issuer-collision non-inheritance;
redirect assertions updated for absolute URLs. 13/13.
Auth/access-control audit fixes (all pre-existing on main; none are
regressions). Verified end-to-end where noted.
HIGH
- Thumbnail enumeration: photoAuth granted any gallery token access to any
flat /thumbnails/thumb_* file, so a visitor to one gallery could
enumerate another (password-protected) gallery's entire thumbnail set.
Scope thumbnail access to the token's event via photos.thumbnail_path.
Live-verified: cross-event fetch now 404s, own-event still 200s.
- Bulk ownership bypass: bulk-archive/bulk-delete acted on body-supplied
event ids with no owner filter (single-event routes enforce
requireEventOwnership), letting admin/editor archive or cascade-delete
any event. Add filterOwnedEventIds; also guard rename + import-external;
tighten photo-retry to scope admin (not just editor). Fix misleading
bulk-delete comment.
MED
- verifyGalleryAccess never checked decoded.type — assert 'gallery'
instead of relying on other token types incidentally lacking eventId.
- secure-images generate-token/secure-download missing denySlideshowToken
(#646 bypass): a leaked slideshow token could download originals.
- Frontend: AuthenticatedImage + api.ts attached the gallery bearer token
to absolute/external URLs — only attach to relative same-app paths.
LOW hardening
- Pin algorithms:['HS256'] on all auth-boundary jwt.verify calls.
- crypto.timingSafeEqual for share-token + HMAC compares (utils/timingSafe).
- Remove dead photoAuth import in galleryFeedback.
Tests: new regression suites for thumbnail scoping + filterOwnedEventIds;
fixed verifyGalleryAccess.customerRevoke fixture (real customer tokens
carry type:'gallery'). Full backend suite at the pre-existing baseline
(5 suites/27 tests fail on main too), zero new failures.
- eslint --fix on branch-changed backend files (indent shift from the
module-wrapper nesting in decomposed files); backend lint now 904
errors vs 1,315 on main
- useMutationWithToast forwards all four TanStack v5 callback args
(tsc -b strict build flagged the 3-arg passthrough)
The slideshow JWT reuses type:'gallery', so verifyGalleryAccess accepts it on
every gallery route — a leaked projector link could download (single/all/
selected), upload (when allow_user_uploads), or post feedback for up to ~12h,
beyond its display-only contract. Add a `denySlideshowToken` middleware (403
when req.accessLevel==='slideshow') after verifyGalleryAccess on those 5 routes.
The photo-display routes (/photos, photo/thumbnail/preview/hero) stay open — the
kiosk needs them. +4 tests mint a real slideshow JWT and assert 403. Docs note
that Regenerate/Disable isn't instant revocation (~12h) and the feature flag is
the hard cut-off.
1. requireFeatureFlag now caches each flag for 10s (the accounting area is 10+
gated endpoints); PUT /admin/feature-flags invalidates the cache so toggles
still take effect immediately.
2. Customer routes (/quotes, /invoices, /contracts + their PDFs) now gate via
getEffectiveFeaturesForCustomer — the global MASTER flag AND the per-customer
override — instead of the per-customer column alone, via a shared
customerFeatureAllowed() helper. Admin disabling a feature globally is now
honoured for customers too.
4. Tax-report VAT-payable: when accounting_vat_registered is UNSET, stop guessing
from grandTotalVat>0 (a zero-output-VAT quarter silently flipped to "not
registered" and hid the reclaim). Treat null as "not configured":
vatPayableMinor=null + vatRegistrationConfigured=false; the UI renders "—" and
a "configure VAT registration" warning. Tests updated.
5. Shared upsertAppSetting() in utils/appSettings — the two adminSettings upsert
loops use it, so the app_settings created_at class can't be re-introduced.
6. PDF rasterise per-file bound: getRenderedPagePath refuses pages beyond
MAX_RENDERABLE_PAGES (200); page_count is capped to match at ingest, so a
hostile high-page PDF can't drive an unbounded pager.
7. (no code) original_filename is only rendered via auto-escaped JSX; the two
dangerouslySetInnerHTML sites are admin-authored content — paranoia pass clean.
Concerns 3 (foreign-VAT reclaim-country) and 8 (imap_pass plaintext) are PR-reply
/ doc items, addressed in the PR response, not code.
A sweep of every CRM/accounting toggle found surfaces still reachable
with their flag OFF. Adds a shared requireFeatureFlag middleware (the two
existing per-file copies predate it) and closes the gaps:
- Hours logging: only createEntry checked the flag — edit/delete/bill and
the list/summary routes were permission-only. Gate all six
/hour-entries routes on the hoursLogging master so a disabled feature
can't be read, mutated, or invoiced via a direct API hit.
- Installment plans: PUT /deals/:uuid/installment-plan mutates invoices
but wasn't bills-gated; add requireFeatureFlag('bills').
- Customer invoice PDF: /invoices/:id/pdf lacked the feature_bills check
the list + quotes routes have. Also fixes the quotes-PDF gate, which
read req.customer.feature_quotes (never populated → silent no-op).
- Customer contracts: /contracts + /contracts/:id/pdf were gated by
neither the master nor a per-customer column.
Per-customer contracts override (the missing counterpart):
- Migration 131 adds customer_accounts.feature_contracts, default TRUE so
existing customers keep their Contracts tab (preserve-visuals).
- Effective resolver now contractsMaster AND feature_contracts; admin
detail page gains the toggle; service/validator/serializer wired.
Cleanups:
- Drop stale `taxReport` from the sidebar's Clients-reveal list (Tax moved
to Accounting); add the missing `projects` so it mirrors the context
derivation.
- SettingsPage tab-snap effect now depends on flags.accounting.
- Fix stale taxReport "forced off when bills off" comment (it's accounting).
Turning on maintenance mode locked out every admin — including ones already
logged in — with no way back in from the browser. Two causes:
1. Backend (middleware/maintenance.js): the skipPaths allow-list pointed at
/api/admin/login and /api/admin/auth/login, but the real admin auth routes
live under /api/auth (POST /api/auth/admin/login, GET /api/auth/session).
So during maintenance both the login POST and the session check 503'd. The
503 on /auth/session made the frontend read every admin as logged-out, and
also tripped the axios interceptor that force-enables maintenance globally.
Fixed the allow-list to the actual endpoints.
2. Frontend (MaintenanceWrapper.tsx): the maintenance screen rendered over
every /admin/* route unless an admin session already existed — covering the
/admin/login page itself. A logged-out admin could never reach the form to
get a session (catch-22). /admin/login is now always allowed through.
With both: a logged-in admin keeps working (session check passes), and a
logged-out admin can reach /admin/login and sign back in, all while
maintenance mode correctly blocks customers.
Serve uploaded SVGs (admin logos etc.) with a restrictive
Content-Security-Policy (default-src 'none'; style-src 'unsafe-inline';
img-src 'self' data:) + X-Content-Type-Options: nosniff in secureStatic.
The browser still renders the vector, but any embedded <script>/on*
handler can't execute if the SVG is opened directly — keeps real SVGs
(scalable) instead of rasterising them. Applies to all secureStatic
mounts (uploads/photos/thumbnails/fonts); only SVGs get the header.
The 5-minute session-sweep interval at sessionTimeout.js:17 fired at
module-load time without .unref(), so every jest worker that
transitively required this module (server.js → middleware → most
of the route layer) kept the event loop alive forever. The worker
then got force-killed on shutdown, surfacing as the longstanding
"worker failed to exit gracefully" warning at the end of every CI
run on upstream/beta.
Under enough I/O / memory pressure on a CI runner, the force-kill
could land MID-test rather than after the suite finished, taking
out whatever else was running on that worker — most visibly
integration/storageBackend.test.js on PR #555's runs.
.unref() makes the timer not keep the loop alive on its own.
Production behaviour is unchanged: the timer still fires every
5 min as long as anything else is holding the loop open (the HTTP
server, always).
The trigger: PR #458 mounted requireCustomerPortalEnabled which
410'd every /api/customer/* + /api/admin/customers/* request when
the master toggle was off. Some browsers cached that 410 (no
Cache-Control header was set, so heuristic freshness applied —
the wrong default for an authenticated/sensitive surface).
PR #470 reverted the middleware, but a customer whose tab cached
the 410 still saw 410s until they hard-refreshed.
Add noStoreCache middleware and mount it in front of both route
groups. Every response (200, 4xx, 5xx) now carries
`Cache-Control: no-store, no-cache, must-revalidate, private`
plus the HTTP/1.0 Pragma + Expires fallbacks. Any future
transient error from these endpoints can no longer get pinned in
browser or proxy caches and outlive its cause.
Cost is one setHeader per request; applied per route group rather
than globally so static assets + galleries keep their own caching
strategy unchanged.
Includes a dedicated unit test pinning the header set so a future
cleanup pass can't quietly drop it and re-introduce the bug.
Implements the recurring-customer login surface from
the-luap/picpeak#354 plugged into the maintainer's
new feature-flag infrastructure (PR #443) instead of
a parallel toggle.
* New `customerPortal` feature flag (foundation flag for the
not-yet-built calendar/quotes/bills/messaging customer
surfaces). Defaults FALSE on fresh installs, TRUE on existing
installs (events > 0) via migration 095 so live customer
accounts don't disappear mid-deployment.
* Foundation schema: customer_accounts, customer_invitations,
event_customer_assignments, customer_password_resets, plus
RBAC permissions customers.view / .create / .delete granted
to super_admin + admin system roles.
* Backend: /api/admin/customers (invite, list, search, assign,
deactivate, reset password) + /api/customer/auth/* +
/api/customer/* (login, dashboard, accept-invite, reset).
Customer JWT bypass minted via
/api/customer/events/:slug/access-token so existing gallery
middleware stays untouched.
* Frontend: /customer/* route tree gated by RequireFeature flag
customerPortal, with login / dashboard / accept-invite /
reset pages and a customer-side sidebar layout.
/admin/customers and /admin/customers/:id gated identically.
* Settings → Features grows a "Customers" section with a
Customer portal card. The maintainer's Features tab stays the
single source of truth — no parallel Advanced features tab.
* CustomerAccountPicker on event create/edit forms hides itself
when the flag is off; backend ignores customer_account_ids in
that case instead of erroring the whole event save.
Translations: en + de hand-translated. nl/pt/ru fall through to
en — flagged here as needing native review.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Third loop fix in the same /admin/login → /admin/dashboard → /admin/login
pattern as #355 and #363. Reported on v3.39.1-beta.0 — the loop returns
after a server restart or after an idle gap longer than the configured
session timeout.
## Root cause (server)
`sessionTimeoutMiddleware` is mounted on `/api/admin` (server.js:411). It
rejects with `401 SESSION_TIMEOUT` when either:
- the in-memory `lastActivity` for the token is older than the timeout, or
- this is the first request with this token AND the token's `iat` is
older than the timeout (post-restart guard).
`/auth/session` lives under `/api/auth/session`, NOT under `/api/admin`,
so the middleware never runs for it. Result: an idle/old-iat admin token
returns `valid: true` from `/auth/session` while every protected
endpoint immediately rejects it with `401 SESSION_TIMEOUT`. Frontend's
401 interceptor hard-redirects to `/admin/login`, `/auth/session` says
valid again, loop closes — exact same shape as the previous two
asymmetries the symmetry pass missed.
Fix: add a non-mutating `isSessionExpired(token, decoded)` helper to
`middleware/sessionTimeout.js` that reads the same in-memory map and
applies the same lastActivity / iat-vs-timeout logic as the middleware,
without updating the map (the middleware is the only place that records
activity; `/auth/session` is read-only by design). `/auth/session`
calls the helper for `decoded.type === 'admin'` after the existing
admin-existence and password-change checks. Same try/catch fall-through
pattern as the prior fixes so a missing/broken helper doesn't fail-closed
during early bootstrap or in test stubs.
## Root cause (client race amplifying the loop)
Even with the server fix, the previous `useSessionTimeout` hook called
`AdminAuthContext.logout()` which dispatches `POST /auth/logout`
fire-and-forget AND has its own `finally { window.location.href }`,
then immediately set `window.location.href = '/admin/login?session=expired'`
on top. Two consequences:
- The cookie wasn't reliably cleared before the new page loaded —
if any /auth/session asymmetry slipped through, the loop replayed
inside the same tab. New-tab and "refresh several times" "fixes"
were just the logout request eventually completing.
- Two redirects raced; sometimes the `?session=expired` query was
dropped, breaking the login-page toast.
Fix: rewrite the hook to (a) await `POST /auth/logout` so the cookie
is guaranteed cleared, (b) clear `sessionStorage.admin_user` directly
instead of going through AdminAuthContext.logout (which has the
side-effect redirect we don't want), and (c) navigate exactly once
with the `?session=expired` query.
## Tests
- `__tests__/routes/authSession.symmetry.test.js` — 4 new cases under
a `session-timeout symmetry` describe block: helper says expired →
valid:false; helper says active → valid:true; helper not called for
gallery tokens; helper throws → fall through to valid:true (defensive).
Existing 9 tests still pass (mock now includes
`isSessionExpired: jest.fn(() => Promise.resolve(false))` as the
default).
- `__tests__/middleware/sessionTimeout.isSessionExpired.test.js` — 7
new unit tests for the helper itself: fresh token / old-iat /
recently-active / null-input / no-mutation / 60-min default
boundary cases.
20 cases total, all green. Lint clean on every touched file.
Adds a long-lived bearer-token mechanism + scoped REST surface designed
for n8n-style automation: create a gallery, upload photos, fetch the
share URL — all via documented HTTPS endpoints instead of poking at the
admin UI's internal routes.
API
- Migration 081 adds `api_tokens` (hashed_token, scopes, owner FK,
last_used/expires/revoked timestamps).
- New apiTokenAuth middleware: parses `Authorization: Bearer pp_live_…`,
resolves to the owner admin user, attaches `req.admin` so existing
permission decorators (events.create etc.) still work. Token-level
scope check (read/write/admin) layers on top as defence in depth —
a leaked read-only token cannot mutate even if its owner is super_admin.
- adminApiTokens route exposes list/create/revoke for admins (cookie-
authed). Plaintext token is returned exactly once on creation.
- v1 surface mounted at /api/v1: POST/GET /events, GET /events/:id,
POST /events/:id/photos (multipart, single file), GET
/events/:id/share-link. Each endpoint annotated with @openapi JSDoc.
Documentation
- swagger-jsdoc + swagger-ui-express produce a live spec at
/api/openapi.json and a Swagger UI at /api/docs (admin-gated).
- backend/scripts/generate-openapi.js writes docs/openapi.{json,yaml}
to the repo so the spec is versioned.
- scripts/sync-api-docs.sh runs in pre-push: regenerates the spec and
copies it into the picpeak-docs Nextra site at app/api/. Writes only,
never commits or pushes the docs repo (PUSH_SKIP_DOCS=1 to bypass).
Frontend
- New Settings → API Tokens tab: generate, list, revoke. Plaintext
tokens are shown once with a copy-to-clipboard control.
JWT `iat` has 1-second resolution; `password_changed_at` is stored with
sub-second precision. The previous comparison rejected tokens whose iat
fell in the same wall-clock second as a password change — e.g. a token
issued by an immediate re-login after a password reset, or by any
script-driven flow that resets and logs in in quick succession. Floor
the stored timestamp to whole seconds before comparing.
Caught while wiring up the local E2E suite: the seeder needed a
"set password_changed_at 10 s in the past" hack to avoid this race;
with the fix in place that hack is gone and the suite is naturally
deterministic.
Introduces a new "Per-guest selections" identity mode for event
feedback, letting each visitor register under their own name so their
likes/favorites/comments/ratings are tracked independently. Includes
admin insights (list, per-guest detail, aggregate view, export) and
advanced identity features (forget-me, email recovery, invite tokens,
merge).
New event-level setting
- event_feedback_settings.identity_mode = 'simple' | 'guest' (default
'simple' → zero behavior change for existing events).
- Admin UI radio under Feedback Settings to toggle per event.
Root cause of the previous "all guests share state" bug
- generateGuestIdentifier() was sha256(ip + userAgent), so every visitor
on the same WiFi + similar device collided into one identity.
- Now: when a verified guest JWT is present (x-guest-token header),
req.guest.identifier takes precedence — per-person rate limits and
per-person deduplication.
Phase 1 — identity layer
- Migration 078: new gallery_guests, guest_invites, guest_verification_
codes tables; identity_mode column + check constraint; nullable
guest_id FK on photo_feedback.
- New guest JWT type scoped to (eventId, guestId).
- New middleware guestAuth.resolveGuest (non-blocking) + requireGuest.
- POST /gallery/:slug/guest, GET /guest/me, DELETE /guest/me.
- Gallery feedback route enforces guest identity in guest mode and
reads name/email from the verified token (never from the body).
- Frontend GuestIdentityContext + GuestNamePromptModal; axios
interceptor injects x-guest-token on gallery API calls.
- Feedback-only blocking: gallery opens freely, prompt only on first
interactive feedback action.
- Admin "Guests" tab (conditional on identity_mode='guest') with the
AdminGuestsList component.
Phase 2 — admin insights
- GET /admin/events/:eventId/guests list + aggregated counts.
- GET /admin/events/:eventId/guests/:guestId detail with per-type
groupings; AdminGuestDetail modal with thumbnail grid + tabs.
- GET /admin/events/:eventId/guests/aggregate sorted by distinct guest
pick count; GuestSelectionsAggregate component.
- Per-guest export (txt/csv/json) and bulk export-all ZIP.
Phase 3 — polish
- 3.1 Self-service forget-me link in gallery footer.
- 3.2 Email-based identity recovery: POST /guest/recover sends a
6-digit code via the existing emailProcessor, POST /guest/verify
exchanges it for a token (rate-limited, enumeration-safe).
- 3.3 Admin invite tokens: pre-mint identities, share URLs with
?invite=, single-use redemption stripping the param from history.
- 3.4 Admin merge endpoint reassigns feedback + soft-deletes sources.
Shared helper
- useGalleryFeedbackAction hook wraps the identity-check logic for
inline like buttons across Masonry/Grid/Justified/Mosaic/Carousel/
Timeline/Premium layouts.
Backwards compatibility
- Existing events default to 'simple' after migration; behavior
unchanged.
- Legacy photo_feedback rows keep guest_id NULL; admin shows them in
the generic feedback moderation view as before.
- feedback_count denormalized stat now uses COALESCE(guest_id,
guest_identifier) so per-guest counts are accurate without touching
legacy rows.
Verified end-to-end against local Docker
- Migration clean on existing data.
- Simple mode unchanged (no prompt, legacy flow).
- Guest mode: Alice registers on click, tokens persist in
sessionStorage, feedback rows carry guest_id.
- Carol via invite link auto-redeems, sees Alice's "1 likes" badge.
- Admin Guests tab shows both with correct counts; detail modal
displays thumbnail grid with badges; aggregate view sorts by picker
count (photo 227 = 2, others = 1); CSV/JSON export matches DB.
- Merge Carol into Alice: feedback reassigned, Carol soft-deleted,
Alice count = 4.
Draft Mode:
- Events are created as drafts by default — no email sent until published
- Add "Publish & Notify Client" button with confirmation dialog
- Draft banner with yellow styling on event details page
- Draft filter tab in events list
- Gallery middleware blocks public access to draft events
- Migration 076 adds is_draft column to events table
Admin Draft Preview:
- Admins can preview draft galleries via JWT preview token (?preview=)
- "View Gallery" link on drafts auto-appends preview token
Admin & Login Page Branding:
- Admin header uses configured company logo/name from branding settings
- Login page shows configured logo instead of hardcoded PicPeak
- Respects logo_display_mode (logo_only, text_only, logo_and_text)
OG Tag Branding:
- DynamicFavicon component updates OG meta tags and page title from
branding settings
Editable Client Email:
- Customer email is now editable after event creation in edit mode
Branding Inheritance:
- New events inherit hero logo settings (visibility, size, position)
from global branding configuration
Share Link Full Domain URL:
- New getFrontendBaseUrl() utility with DB fallback to general_site_url
- Used in email processor and share link service
- Set password_changed_at when changing password via adminAuth route so
existing JWT tokens are rejected by the auth middleware check
- Enforce session timeout on first request with unseen tokens by checking
token iat against configured timeout (prevents bypass after server restart)
- Convert camelCase roleId/isActive to snake_case role_id/is_active in
frontend updateUser service (fixes silent role update failures)
Resolves GHSA-rqg3-47p5-vgwg
Add global settings to make event_date and expiration optional when
creating galleries. This supports non-event use cases like portraits,
corporate shoots, etc.
New features:
- Settings toggles in Settings → Event Creation tab
- "Require event date" checkbox with warning about random URL identifiers
- "Require expiration date" checkbox with warning about manual archiving
- Galleries without date use random hex suffix in slug (e.g. portrait-smith-a1b2c3)
- Galleries without expiration never expire (stay active until archived)
Backend changes:
- New migration for settings and nullable columns
- Conditional validation based on settings
- Updated slug generation with random suffix fallback
- Updated expiration checker to skip null expires_at
- Updated gallery access control for null expiration
Frontend changes:
- New checkboxes in EventsTab with warnings
- Conditional event date field (shows optional label)
- No Expiration message when expiration disabled
- Updated types for nullable event_date and expires_at
Closes#118
## Changes
### CSS Template System
- Added CSS class hooks to gallery components for custom template targeting
- Gallery sidebar, header, footer, and photo cards can now be styled via CSS templates
- CSS variables on :root allow themes to override colors, effects, and spacing
### Gallery Component CSS Classes Added
- `.gallery-page` - Main gallery container
- `.gallery-header` - Top header bar
- `.gallery-sidebar` - Filter/download sidebar
- `.gallery-sidebar-header`, `.gallery-sidebar-title`, `.gallery-sidebar-close`
- `.gallery-sidebar-content`, `.gallery-sidebar-section`
- `.gallery-sidebar-search-input`, `.gallery-sidebar-search-icon`
- `.gallery-sidebar-backdrop` - Mobile overlay
- `.gallery-btn`, `.gallery-btn-download` - Sidebar buttons
- `.gallery-footer` - Footer section
- `.photo-card`, `.photo-grid` - Photo display elements
### CSS Templates (Database)
- Elegant Dark (id=1): Dark navy theme with light text and red accents
- Liquid Glass Light (id=2): iOS 26 frosted glass effect with gradient background
### Bug Fixes
- Fixed CSS variables not inheriting (moved from .gallery-page to :root)
- Fixed sidebar position breaking layout (removed position: relative override)
- Fixed Elegant Dark sidebar text visibility (white on white issue)
### Other Changes
- Settings page refactoring and cleanup
- i18n locale updates for new gallery features
- Vite proxy port configuration fix
- Admin auth route improvements
- CSS templates service updates
- Remove all console.log/debug statements from production code
- Add NODE_ENV checks for development-only logging
- Remove test scripts (test-feedback, test-image-security, test-backup-*, test-restore)
- Remove one-time fix scripts (fix-temp-photos, fix-migration-state, mark-migration-applied)
- Remove sensitive files (.env.backup, ADMIN_CREDENTIALS.txt)
- Update package.json to remove references to deleted scripts
- Replace console statements with logger utility in backend
- Secure error boundaries to not expose stack traces in production
This makes the codebase production-ready with no debug output or test scripts.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <[email protected]>
- Disabled duplicate email service (emailService.js) that was creating redundant connections
- Increased connection pool size from 10 to 25 for production environment
- Extended session timeout cache from 5 to 30 minutes to reduce DB queries
- Added connection retry logic with exponential backoff for transient failures
- Fixed password validation to use retry wrapper and correct setting key
- Updated public settings and gallery middleware to handle connection failures gracefully
These changes address the "Connection terminated unexpectedly" errors in production by:
1. Reducing unnecessary database connections
2. Increasing available connection pool capacity
3. Implementing automatic retry for transient connection failures
4. Caching frequently accessed data for longer periods
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <[email protected]>
Original: feat: enhance security logging and ensure rate limit blocks are properly tracked
- Add comprehensive logging for rate limit blocks with full request details
- IP address (with proper proxy detection), user agent, headers, timestamps
- Rate limit info (current count, limit, remaining, reset time)
- Separate tracking for auth vs general endpoints
- Enhance authentication failure logging
- JWT validation failures with detailed error info
- Admin auth attempts without token
- Failed token validation with user context
- All events include IP, path, method, user agent
- Improve Winston logger configuration for production
- Add automatic log rotation (10MB errors, 50MB combined)
- Create separate security.log for auth/rate limit events
- Ensure logs directory exists automatically
- Add structured JSON format for log aggregation
- Support container logging with LOG_TO_CONSOLE env var
- Create comprehensive documentation
- Security logging guide with examples
- Monitoring recommendations
- Configuration reference
- Add test script to verify logging functionality
All rate limit settings remain configurable via admin panel:
- Window duration, max requests, auth limits
- Skip authenticated requests option
- Public endpoints only option
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <[email protected]>