ea2852dcb0a0f00318a2c8067405630bcff4eee6
409
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ab501459a4 |
feat(analytics): pluggable trackers — Umami + Rybbit + Custom (#663 Phase 1)
Implements the hybrid scope agreed on in #663: two native adapters (Umami + Rybbit) for trackers we'd keep maintained, plus a Custom script-paste mode for everyone else (Plausible, Matomo, Pirsch, GA4, GoatCounter, Fathom, Cloudflare Web Analytics). Phase 2 (Plausible native, deeper metrics) explicitly deferred until someone asks. ## Architecture **Backend `services/trackers/`**: - `TrackerAdapter` shape (single method): `fetchDeviceBreakdown` → `{ desktop, mobile, tablet } | null`. Null = route falls back to access_logs heuristic. - `umamiAdapter.js` — extracted from the `services/umamiClient.js` that landed in #662. Same 10 test contract preserved. - `rybbitAdapter.js` — new. Hits `/api/site/{id}/breakdown?dimension= device` with Bearer auth, accepts both bare-array and `{data:[...]}` envelope variants, tolerates `sessions`/`visitors`/`value`/`count` metric keys. - `customScriptSanitiser.js` — sanitize-html with a tracker-tight allowlist (`<script>` / `<noscript>` / `<link rel=preconnect| dns-prefetch>` / `<meta>`). Strips event-handler attributes, `javascript:` and `data:` URLs. - `index.js` factory: `resolveAdapter()` reads `analytics_tracker_provider` setting → dispatches. Back-compat: when provider is unset, infers `umami` from the legacy `analytics_umami_enabled` flag so #662 installs keep working without an admin touching settings. **Backend routes**: - `adminDashboard.js /analytics`: now goes through `resolveAdapter()`. Old `fetchUmamiDeviceBreakdown` direct import removed; both `umamiClient.js` and its test file deleted (replaced by the adapter shape). - `adminSettings.js PUT /analytics`: validates the new `analytics_tracker_provider` enum, sanitises any incoming `analytics_custom_head_html` on save via the sanitiser. Masks the new `analytics_rybbit_api_key` on every GET — same pattern as Umami's API key and recaptcha secret. - `publicSettings.js`: emits `analytics_tracker_provider`, `rybbit_url`/`rybbit_website_id` (only when provider=rybbit), and the pre-sanitised `analytics_custom_head_html` (only when provider=custom). Legacy `umami_*` fields stay for back-compat. **Frontend**: - `analytics.service.ts` reworked into a provider-aware shape. `initialize({provider, ...config})` dispatches to Umami / Rybbit / Custom / None. `track()` calls dispatch to `window.umami.track` / `window.rybbit.event` / no-op based on the loaded provider. - `App.tsx` `AnalyticsBootstrap` reads `analytics_tracker_provider` from public-settings and routes to the right `initialize` call. Legacy `umami_enabled`-based path preserved as fallback when the new field is missing. - `AnalyticsTab.tsx` (Settings → Analytics) reworked with a "Provider" dropdown switching between None / Umami / Rybbit / Custom panels. Each panel renders its own config fields; Custom panel surfaces an explicit CSP-reminder banner. - `useSettingsState.ts` shape extended with `tracker_provider`, `rybbit_url`/`rybbit_website_id`/`rybbit_api_key`, `custom_head_html`. Save mutation keeps `umami_enabled` in sync with `tracker_provider==='umami'` for back-compat with downstream consumers (publicSettings shape, embedded iframe). - `publicSettings.service.ts` type extended. **i18n**: EN + DE for the provider heading + description + dropdown options + Rybbit fields + Custom HTML field + CSP warning. ## Custom mode — script execution caveat When the gallery `<head>` receives the custom HTML, simply assigning innerHTML to a container element wouldn't execute the embedded `<script>` tags (per the HTML spec, dynamically-inserted scripts via innerHTML are non-running). `analytics.service.ts:120-130` re-creates each `<script>` element manually so the browser actually evaluates it. Non-script nodes (link, meta, noscript) move in directly. ## Tests **Backend** (42 cases, all pass locally): - `umamiAdapter.test.js` (10) — pinned from the original `umamiClient.test.js`: missing-config / URL shape / encoding / payload normalisation / `laptop`→`desktop` / unknown buckets / empty / non-2xx / invalid JSON / network error. - `rybbitAdapter.test.js` (9) — same shape adapted for Rybbit: bare-array + envelope payload, `sessions`/`visitors`/`dimension` key tolerance, encoding, failure modes. - `trackerFactory.test.js` (6) — resolves null for `none`/`custom`, correct adapter for `umami`/`rybbit`, back-compat path via legacy `analytics_umami_enabled`, garbage-provider defensive null. - `customScriptSanitiser.test.js` (12) — Plausible-style passthrough, Umami-style passthrough, inline body passthrough, `<noscript>` allowed, `<link rel="preconnect|dns-prefetch">` allowed, `<link rel="stylesheet">` stripped, disallowed tags stripped, `javascript:`/`data:` URLs stripped, `on*` event handlers stripped, defensive on malformed input. - `analyticsDateMerge.test.js` (5) — preserved from #662. **Frontend**: full 84-case vitest suite green; tsc + eslint clean on changed files. Adapter changes are narrow refactors of code covered by backend tests; no new analytics-page unit test added. ## End-to-end smoke (dockerised backend + my changes mounted) ``` test 1 (back-compat: no provider, umami_enabled=true) → factory returns umami adapter, /analytics returns devicesSource:access_logs (umami fetch to fake host fails gracefully). ✓ test 2 (invalid provider value) → 400 "analytics_tracker_provider must be one of: none, umami, rybbit, custom" ✓ test 3 (save custom HTML with XSS payload) → stored sanitised: `<script>alert(1)</script>evil<script async defer data-domain="x.com" src="https://plausible.io/js/script.js"></script>` (<div> stripped; script tags survive but CSP `script-src 'self'` still blocks inline + non-allowlisted external at runtime) ✓ test 4 (public-settings exposes the provider switch) → `analytics_tracker_provider: 'custom'`, `analytics_custom_head_html: '<sanitised>'` ✓ ``` ## Out of scope (next discussions) - **Plausible native** — covered via Custom mode for now; native is Phase 2 if someone explicitly asks. - **CSP "trusted domains" admin input** — Phase 1.5. For now operators add their tracker domain to nginx/proxy CSP manually; the new CSP-reminder banner in the Custom panel makes that clear. - **Refactor `(window as any).umami.track(...)` direct calls** in PhotoLightbox/PhotoGrid to go through `analyticsService.track()` so events fire on the right tracker. Currently a no-op when Umami isn't loaded; functional but not optimal. Closes #663 Phase 1. |
||
|
|
7534447b6c |
fix(analytics): admin dashboard reads correct fields + Umami device API (#661)
Reporter @alexvaltchev hit three independent bugs on the Analytics
Dashboard. All three fixed in one PR; pluggable-tracker support
(Rybbit, Plausible, etc.) left for a separate discussion.
## Bug A — Summary cards showed 0
Two layers, both fixed.
**Frontend** (`AnalyticsPage.tsx:142-149`): the cards summed
`chartData[].views/uniqueVisitors/downloads`. The backend now (and
already) emits a dedicated `totals` object computed via separate
COUNT queries, which is what the cards should read. Postgres returns
counts as strings, so coerce via `Number()`.
**Backend** (`adminDashboard.js:268-282`): the chartData merge used
`dateObj.date === row.date`. On Postgres, pg's driver auto-converts
`DATE(timestamp)` to a JS Date object — the string-equality match
failed silently and `chartData` stayed all-zero on every Postgres
install with traffic. Added a `normaliseDateKey()` helper that
returns YYYY-MM-DD regardless of driver shape, plus `Number()`
coercion on the counts. SQLite path unchanged.
## Bug B — "Umami Not Configured" banner despite valid config
`AnalyticsPage.tsx:90` did `settings.reduce(...)` on the
`/admin/settings` response. That endpoint returns a
key/value **object** (verified at `adminSettings.js:108-149`), not
an array, so `.reduce` threw `data.reduce is not a function` and
the catch silently rendered the "Not Configured" banner even on
perfectly-configured installs. Read the umami keys directly off the
response object.
## Bug C — Device breakdown 0/0/0
Two-pronged fix.
**Primary path — Umami device API** (`services/umamiClient.js`,
wired into `adminDashboard.js`). When the admin provides an Umami
v2 API key (new setting `analytics_umami_api_key`), the backend
fetches the per-period device breakdown from Umami's
`/api/websites/:id/metrics?type=device` endpoint. Umami tracks
devices natively — far more accurate than our coarse user-agent
heuristic. The new `devicesSource` field in the response lets the
UI hint at where the numbers came from.
**Fallback hardening — local heuristic** (`adminDashboard.js:296-320`).
The existing access_logs `LIKE '%Mobile%' / '%Tablet%'` query stays
in place as a fallback for installs without Umami. Hardened with:
`whereNotNull('user_agent')` skips rows we never captured a UA on,
`Number()` coercion on COUNT results (pg returns strings), and a
guard against divide-by-zero when access_logs is empty.
## API key handling
Mirrors the existing recaptcha-secret pattern: stored plaintext in
`app_settings`, masked as `••••••••` on every GET via the existing
`adminSettings.js` GET handlers, and the frontend save mutation
silently drops the masked sentinel so re-saving without typing a
new key preserves the stored value.
## End-to-end smoke (dockerised backend with my fixes applied)
```
chartData total views: 27 ← previously 0 (date merge broken on PG)
totals: {'views': '27', 'downloads': '3', 'uniqueVisitors': '1'}
devices: {'desktop': 100, 'mobile': 0, 'tablet': 0} ← was 0/0/0
devicesSource: access_logs ← falls back correctly
analytics_umami_api_key (GET /settings/analytics): ••••••••
```
## Tests
**Backend** (15 new cases):
- `umamiClient.test.js` (10): missing-config → null, URL shape +
`x-umami-api-key` header, websiteId URL-encoding, `{x,y}` →
percentages, `laptop` → `desktop` mapping, unknown buckets
dropped, empty payload → null, non-2xx → null, invalid JSON →
null, network error → null.
- `analyticsDateMerge.test.js` (5): YYYY-MM-DD string pass-through,
ISO timestamp slice, JS Date (pg shape) → YYYY-MM-DD, null/empty
→ null, coercion for unexpected types.
**Frontend**: full 84-case vitest suite still green (no analytics
unit tests existed before; not adding any here — the changes are
narrow and the unit-level confidence comes from the type system +
the backend smoke above).
Closes #661 (bugs A + B + C). Rybbit / pluggable tracker support is
the next conversation per the issue author's follow-up.
|
||
|
|
f2814e4a4c |
feat(feedback): per-guest favorite + like caps with mobile-friendly limit modal (#655)
Reporter @Duecki1 wants to stop telling guests "pick only 5 photos" by
hand. Per-event cap, enforced server-side, with a clear popup when the
11th click would exceed the limit. Per-guest scope matches the "every
couple picks their top 10" mental model; per-gallery aggregate is
explicitly NOT in scope (creates weird "first 10 visitors use up all
slots" race conditions).
## Schema (migration 141)
Two nullable columns on `event_feedback_settings`:
- `max_favorites_per_guest`
- `max_likes_per_guest`
null / 0 = unlimited (preserves current behaviour for every existing
install — operator must opt in). Both shipped together because the
code path is identical; photographers can cap either, both, or neither.
## Backend
- `feedbackService.submitFeedback` cap check on the INSERT branch only.
Toggle-off (un-favoriting) is always allowed, so a guest at 10/10
can free a slot by un-clicking an existing favorite.
- New `countGuestFeedback(eventId, type, guestId, guestIdentifier)` —
matches the exact same guest-key shape the existing duplicate-check
uses (guest_id when present, fallback to guest_identifier in simple
identity mode).
- Limit reduction grandfathers: admin lowering 20→10 keeps existing
rows in place; new adds blocked until the guest removes some.
- Route layer (`galleryFeedback.js` POST) translates a `limit_reached`
service-return into a structured 403 with `code:
'FAVORITE_LIMIT_REACHED'` / `'LIKE_LIMIT_REACHED'`, `limit`, and
`current_count`. Stable UI contract.
- `feedback-settings` GET exposes the caps so the gallery UI can
optionally render a counter near the heart icon (UI extension TBD;
the modal alone is the contract this PR commits to).
- `feedbackValidation`: range guard `0..10000`, null allowed,
per-field error messages.
## Frontend — the popup
New `FeedbackLimitReachedModal` component renders via a `createPortal`
to `document.body` so it escapes any lightbox / sticky parent stacking
context and reliably sits above everything else.
Mobile-first responsive:
- `items-end sm:items-center` — slides up from the bottom on phones
(native action-sheet feel), centers on desktop (familiar modal).
- `w-full sm:max-w-md` — full-width on phones, clamps to 420px on
desktop.
- `rounded-2xl sm:rounded-xl` — more rounded on phones for the
sheet feel.
- `pb-[env(safe-area-inset-bottom)]` — respects the iOS home indicator
and Android gesture bar.
- `z-[60]` — above the lightbox's z-50.
Title + body + "8 of 10 used" pill + "Got it" button. Backdrop click +
Escape both dismiss. Focus management lands on the OK button so
keyboard / screen-reader users can dismiss immediately.
New `useFeedbackLimitModal()` hook is the shared API: components
on every submit-feedback site wire `onError: (err) => handleError(err)`
and render `{limitModal}` in their JSX. Returns `true` from
`handleError` when the error is a structured cap-reached 403 (so the
caller can skip its generic error toast). PhotoFavorites + PhotoLikes
+ PhotoLightbox all wire through the hook — every favorite/like submit
path is covered, including the lightbox's three different submit
sites (guest mode, simple mode, post-identity-modal-confirm).
## Admin UI
`FeedbackSettings` card gets a new "Per-guest limits" section that
only renders when at least one of `allow_favorites` / `allow_likes` is
on. Two numeric inputs (0 / empty = unlimited) side-by-side on
desktop, stacked on mobile. Hint text covers the limit-reduction
grandfathering semantics so admins aren't surprised.
## i18n
EN + DE for:
- Modal title + body (parameterized with `{{limit}}`)
- Counter pill (parameterized with `{{current}}` / `{{limit}}`)
- OK button label
- Admin field labels + hints + section header + grandfathering note
## Tests
**Backend** (`__tests__/utils/feedbackPerGuestLimit.test.js`, 8 cases):
- null cap → unlimited (back-compat)
- 0 cap → unlimited (UI convenience)
- cap=10: rows 1-10 succeed, 11 returns limit_reached
- toggle-off frees a slot at the cap
- limit reduction grandfathers existing rows
- per-guest scope: guest A's cap doesn't affect guest B
- favorite cap doesn't block likes (per-type)
- like cap returns LIKE_LIMIT_REACHED-shaped payload
**Frontend** (`__tests__/useFeedbackLimitModal.test.ts`, 7 cases):
- Non-axios errors → null
- Non-403 axios errors → null
- 403 with wrong code → null
- FAVORITE_LIMIT_REACHED parsed
- LIKE_LIMIT_REACHED parsed
- Falls back to code-implied type when feedback_type missing
- Missing numeric fields → 0 (not NaN)
All 15 pass. tsc --noEmit clean. eslint clean on changed files.
Closes #655.
|
||
|
|
80e8ec5bc7 |
Merge pull request #650 from the-luap/fix/whatsapp-template-params-647-followup
feat(whatsapp): admin-selectable template parameters + reorder (#647 follow-up) |
||
|
|
cde028e919 |
Merge pull request #649 from the-luap/fix/branding-customcss-preset-drop-645
fix(branding+whatsapp): preserve customCss through preset switches (#645) + admin-pinned WhatsApp template language (#647) |
||
|
|
16055cdc41 |
feat(whatsapp): admin-selectable template parameters + reorder (#647 follow-up)
Reporter @Rekoo-PS confirmed the language fix unblocked sending, then
hit a second gap: their template uses only `{{1}} = event_name` +
`{{2}} = gallery_link`, but the legacy `buildComponents` hardcoded all
5 positional values from the `gallery_ready` shape (customer_name,
event_name, gallery_link, password_line, expiry_date). Meta rejected
with a parameter-count mismatch even after the language matched.
This adds a per-config slot list — which built-in values to send, and
in what positional order — so admins can match templates of any shape
without code changes.
## Schema (migration 138)
Additive `template_params` TEXT column on `whatsapp_configs` (default
empty string = legacy 5-slot behaviour for existing installs). Stored
as a JSON-serialized array of slot keys: `customer_name`, `event_name`,
`gallery_link`, `password_line`, `expiry_date`. Unknown / duplicate /
non-string entries are sanitized out at read time.
## Processor
- `parseTemplateParams(raw)` — defensive parser; falls back to the
5-slot default on empty / malformed / all-invalid input.
- `buildComponents(data, metaLang, params)` — emits ONLY the listed
slots in the listed order, computed via a small switch on slot key.
The password line still receives the locale-specific 🔒 label and
the empty-when-no-real-password sentinel handling.
- Processor reads `config.template_params` once per cycle and passes
the parsed array to `buildComponents` per message.
## Admin route
- GET surfaces `template_params` as the parsed array (default 5-slot
when null/empty).
- PUT round-trips the incoming array through `parseTemplateParams`
before persisting, so the stored value is always the canonical
sanitized JSON.
- Test send rebuilt to use the same `buildComponents` path so the
admin's test message matches their configured slot shape — a
reporter who configures 2 slots gets a 2-parameter test send, not
the legacy 5-parameter payload.
## UI
- `WhatsAppTab` gets a checkbox + up/down list under the Template
language field. Each slot shows its current `{{N}}` position when
checked, an em-dash when unchecked. Live preview below the list:
"Your template will receive: {{1}} = event_name, {{2}} = gallery_link".
- EN + DE i18n for the field labels, hint, preview, and per-slot
human-readable names.
## Tests
- 17 unit tests in `__tests__/utils/whatsappBuildComponents.test.js`
covering: parseTemplateParams sanitization (unknown keys, duplicates,
non-strings, malformed JSON, all-invalid fallback, pre-parsed array
acceptance) and buildComponents shape (reporter's 2-slot case,
reorder, empty list, locale-specific password label, password
sentinel handling, expiry omission).
- All 17 + the 34 existing networkValidation tests pass.
## Migration numbering
Sits at 138 on top of PR #649's migration 137. If #646 (Live Slideshow)
merges before this, #646's own 137 + 138 take precedence and this
needs renumbering to 139. Coordinated via PR #646's review thread.
## Honest caveat
Still no Meta Business API account on my side. Spec-built, sanitizer +
shape unit-tested, lint + tsc clean. End-to-end against Meta needs the
reporter (or a maintainer with an account) to verify. If a real
round-trip surfaces a mismatch, drop it in #647 and I'll iterate.
|
||
|
|
e6655f613b |
chore(slideshow): renumber migrations to 138/139 (after whatsapp #649's 137)
PR #649 takes migration 137 (add_whatsapp_template_language). Renumber the slideshow migrations to slot in after it: - 137_add_slideshow_share.js -> 138_add_slideshow_share.js - 138_add_slideshow_styling.js -> 139_add_slideshow_styling.js and update the slideshow migration-number references in comments/types. No content change — both are additive + addColumnIfNotExists-guarded, so re-running under the new filename on an already-migrated DB is a safe no-op. |
||
|
|
a995131f42 |
perf(slideshow): cache global settings to cut /state DB reads (PR #646 review)
Each /state poll fired ~10 getAppSetting reads to resolve the watermark/fit; a leaked link x N tabs amplified that linearly (review concern 2). Add a 5s-TTL cached bundle (utils/slideshowGlobals) for the global slideshow_* + branding-logo settings, invalidated on PUT /admin/settings/slideshow so admin live-edit stays instant. slideshowSettings now does ~2 reads per poll (event row + photo count) on a cache hit. Also documents the frontend optimistic-default nit. |
||
|
|
e36b3309ca |
fix(slideshow): deny display-only token on download/upload/feedback (PR #646 review)
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. |
||
|
|
4fd7709596 |
fix(whatsapp): admin-pinned template language + Arabic locale support (#647)
Reporter @Rekoo-PS hit three independent gaps trying to deliver an Arabic Meta template. Bundled here because they fan out from the same root cause (no first-class language config on the WhatsApp tab) and the review surfaces are tightly coupled. **1. Test send hardcoded `en_US` (`adminWhatsapp.js:141`).** Smoking gun for "I can't make it work" — Meta returned template_not_found_in_language (132001) on every test send for non-English templates, no matter what else the admin configured. Replaced with `config.template_language || 'en_US'`. **2. No `template_language` field on `whatsapp_configs`.** The only priors were per-message `data.language` (always null from our callers in `adminEvents.js:854,1188`) and `app_settings.general_default_language` (the *system UI* language, not the *template's* language registered with Meta). Migration 137 adds the column; GET + PUT surface it; the processor uses it as the highest-priority default when message_data doesn't override. Resolution order in `whatsappProcessor.processWhatsAppQueue` is now: 1. message_data.language (per-event override — caller path TBD) 2. config.template_language (admin-pinned template language) 3. app_settings.general_default_language (system fallback) 4. en_US (hardcoded last resort) **3. `LANGUAGE_MAP` + `PASSWORD_LABELS` didn't cover Arabic.** Added `ar` (Meta's single-code form per RFC; no region variant). For any language we don't enumerate (e.g. Turkish `tr_TR`, Chinese `zh_CN`, Hebrew `he_IL`), `resolveLanguageCode` now pass-throughs valid-shape codes (lowercase-language + optional underscore + uppercase-region) and forwards them to Meta as-is. If they don't match a registered template Meta returns 132001, which the test route already surfaces back to the admin via `error.message` — fail-loud, no silent fallback. Validation: - Unit smoke on `resolveLanguageCode` across 18 representative inputs (in-map, pass-through, canonicalization, rejection) — all behaviours correct. - Lint clean on all 7 changed files. - Frontend `tsc --noEmit` clean. - Migration `node -c` syntax-checked; additive + `hasColumn`-guarded so re-running is safe. Frontend: free-text input on the WhatsApp tab with EN + DE i18n. Pointing at Meta's supported-languages docs via the hint text — Meta's list grows; a hardcoded dropdown would rot. Closes #647. |
||
|
|
5f1f4c2b3d |
refactor(slideshow): replace per-event-type preset with a picpeak-wide one
The slideshow display preset (transition / interval / speed / color filter) was
set PER EVENT TYPE in the Edit Event Type dialog. Replace it with a single
picpeak-wide default in Settings -> Slideshow ("Default style for new
slideshows"). New events seed their show_* columns from this global preset
(was: from the event type's slideshow_preset); the per-event override is
unchanged.
- Removed event_types.slideshow_preset usage everywhere (EventTypeModal section,
eventTypes.service types, eventTypeService whitelist, adminEventTypes
validators/POST). The DB column from migration 138 is left inert.
- Global preset stored in app_settings (slideshow_interval_ms/transition/
transition_ms/colorfilter), saved via PUT /admin/settings/slideshow.
- adminEvents create-seeding now reads the global preset (getAppSetting) instead
of the event type.
- en/de: presetTitle + presetHint.
|
||
|
|
b5c73e05bd |
feat(slideshow): add image fit setting (fill vs black bars)
object-fit was hardcoded to 'cover', which crops portrait photos heavily. Add a global `slideshow_fit` setting (Settings -> Slideshow): 'cover' fills + crops, 'contain' shows the whole image with black bars (no crop). Default 'cover' (unchanged). Stored in app_settings (no migration), resolved server-side into the slideshow settings + /state poll so a running projector picks it up live. |
||
|
|
759784a4d1 |
fix(slideshow): feature flag is a master kill-switch, not just admin UI
Disabling the `slideshow` feature previously only hid the admin UI — the public
/show/:token route ignored the flag, so already-minted links kept working. Gate
resolveSlideshow on isFeatureEnabled('slideshow') so every /session and /state
404s when the feature is off: clicking Start shows "link not active" and a
running projector stops within one /state poll. Belt-and-braces: also gate the
admin generate + settings PATCH endpoints with requireFeatureFlag so links can't
be minted/changed while off (disable stays open so stale tokens can be cleared).
|
||
|
|
0166dc9658 |
refactor(slideshow): watermark look lives only in global settings (+ size)
The watermark look (logo / position / opacity / style) was configurable in three
places — the global Settings tab, the per-event-type preset, and the per-event
card. Consolidate it to ONE: the global Settings -> Slideshow tab. Per-event and
per-event-type now carry only the watermark MODE (inherit / on / off) — the
override structure — and render with the global look.
- New global "Size (% of screen)" control (slideshow_watermark_size, vmin-based)
so the logo can be scaled; resolved server-side into the watermark payload and
applied to the kiosk <img>.
- Backend slideshowSettings resolves the whole look from app_settings always;
per-event show_watermark only toggles enabled. adminEvents PATCH + type-preset
seeding no longer accept/seed per-event look fields; unused enums removed.
- Frontend SlideshowStyle drops the look fields (mode only); SlideshowStyleFields
watermark section is a single mode select with a "configured under Settings"
hint; SlideshowSettingsCard + Event type cleaned up.
- en/de: watermarkSizeLabel + watermarkModeHint.
(events.show_watermark_{source,position,opacity,style} columns from migration
138 are left in place but inert — the look is global now.)
|
||
|
|
69367b45be |
feat(slideshow): gate behind a feature flag + move globals to a Settings tab
- New `slideshow` feature flag (backend KNOWN_FLAGS/DEFAULT_FLAGS, frontend FeatureKey + context default, a toggle card under Settings -> Features -> Core). Default off; strictly opt-in. - Move the global watermark defaults off the Event Types page into a dedicated Settings -> Slideshow tab (new SlideshowSettingsPage), shown only when the flag is on. - Gate the per-event Live Slideshow card and the per-event-type preset section behind the flag too (and stop writing a type preset when it's off). - en/de strings for the feature card + settings tab. |
||
|
|
0f4388d68a |
fix(slideshow): read globals from app_settings, not the missing settings table
slideshowSettings used settingsService.getSetting, which queries db('settings')
- a table that does not exist in this app (globals live in app_settings). Every
GET /gallery/:slug/show/:token/session and /state therefore threw and returned
500 INTERNAL_ERROR once a valid token resolved. Switch to getAppSetting
(utils/appSettings), which reads app_settings where the slideshow_watermark_*
and branding_* values are actually written.
|
||
|
|
1e40f8296c |
fix(slideshow): drop updated_at from event writes
The events table has no updated_at column (only created_at, and no migration adds one), so the slideshow generate/disable/settings endpoints 500'd with 'column "updated_at" does not exist'. Write only the show_* columns, and guard the settings PATCH against an empty update. |
||
|
|
dea5e0f8a6 |
feat(slideshow): backend api for live slideshow
- public GET /gallery/:slug/show/:token/session (validates token, mints a slideshow-scoped gallery JWT + sets the per-slug cookie so <img> requests authorize) and /state (cheap settings + photo-count poll). Reuse /photos for the list; skip the view-log for slideshow access so the kiosk does not pollute visitor analytics. - admin slideshow link generate/disable + live style PATCH on events. - event-type slideshow_preset whitelisted in CRUD; create-event seeds the new event's show_* columns from the type preset. - global watermark defaults via PUT /admin/settings/slideshow; watermark cascade (global default -> per-event override) resolving the light/dark/favicon/event logo url. |
||
|
|
b8211e9944 |
fix(security): close BOLA on photo-export + NAT64 SSRF in URL guard
Two security advisories landed against the open #641 branch — bundling both because they touch independent surfaces and PR #641 is the next beta ship vehicle. **GHSA-9v4w-jrhx-g5wr (BOLA on /admin/photo-export/:eventId/*)** — the three /:eventId-scoped routes in `adminPhotoExport.js` (filtered, filter-summary, export) ran `adminAuth + requirePermission(...)` but not `requireEventOwnership`, so any non-super-admin admin/editor with photos.view (or photos.download) could enumerate + export the photos of events created by other admins — leaking `original_filename`, which routinely encodes client identity. Sibling `adminPhotos.js` applies the middleware on every :eventId route; this file was the single drift. Reporter: Wernerina. **GHSA-wmjx-pc37-272r (NAT64 SSRF in `isPrivateIPv6`)** — the old implementation did naive string-prefix checks (`startsWith('fc')`, `startsWith('fe80')`) and had zero coverage for NAT64 (`64:ff9b::/96` per RFC 6052, `64:ff9b:1::/48` per RFC 8215). On instances with NAT64/DNS64 egress, a webhook URL like `http://[64:ff9b:1::a9fe:a9fe]/` translated through the gateway and reached 169.254.169.254 — exfiltrating cloud metadata (IAM creds) into `webhook_deliveries.response_body`. Rewrote `isPrivateIPv6` to expand the address to its canonical 8-group form, block both NAT64 prefixes, decode embedded IPv4 from IPv4-mapped (`::ffff:0:0/96`) and deprecated IPv4-compatible (`::/96`) forms and re-check via `isPrivateIPv4`, and fail closed on any parse failure. Reporter: tonghuaroot. Added 34 unit tests covering: both NAT64 prefixes in hex + mixed dotted-quad notation, IPv4-mapped IPv6 hex + mixed, deprecated ::IPv4 form, legacy fc00::/fd00::/fe80::/::1/:: cases stay blocked, and public IPv6 (Google/Cloudflare/Google IPv6) negative controls stay allowed. Refs: GHSA-9v4w-jrhx-g5wr, GHSA-wmjx-pc37-272r |
||
|
|
fabd67aecd |
feat(feedback): export shape toggle — per-action vs per-guest pivot (#640 part E)
Ports 8digit/picpeak@ed7943b as a TOGGLE rather than a replacement. The current per-action shape (one row per favourite/like/rating/comment) stays the default for backward compat with any external scripts consuming the export; the new pivot shape (one row per (photo, guest_identifier) with boolean is_favorited/is_liked + star_rating + comment) is opt-in via a ?shape=pivot query param and a dropdown in the admin feedback page. Pivot wins for "which guests engaged with which photos" analysis in Sheets / Excel pivot tables. Long wins for engagement timeline analysis and re-importing into another tool. Different products, both valid. ### Backend - `feedbackService.exportEventFeedbackPivoted(eventId)`: new method. LEFT-of-Map approach, pure JS pivot so PG / SQLite behave identically. Key is `(filename, guest_identifier)` — anonymous guests with no identifier get a synthetic per-row key so two anonymous comments on the same photo don't collapse. Comments: most recent wins (history dropped in exchange for "current state" semantics). Hidden-by-moderator rows excluded — the pivot represents what we want to surface, not the raw event log. - `adminFeedback.js` export route: accepts `?shape=pivot|long` (default `long`). CSV filename now carries the shape (e.g. `feedback-pivot-{id}.csv`) so repeated exports don't overwrite. - `convertToCSV` helper in `adminFeedback.js` gains the three escaping improvements that 8digit's commit also shipped: booleans → `yes`/`no`, null/undefined → empty, escape strings containing newlines (\n/\r) as well as commas/quotes. Comments with line breaks were silently breaking CSV row counts before this. Improvements are pure wins regardless of shape; archives' own `convertToCSV` copy left untouched (separate surface, no behaviour drift risk). ### Frontend - `feedback.service.ts` `exportEventFeedback()` gains optional `shape` parameter, default 'long'. - `EventFeedbackPage.tsx`: new shape dropdown next to the CSV / JSON buttons (defaults to 'long'). Selected shape flows through to the API request AND the downloaded filename. ### i18n 3 new EN + DE entries (`feedback.exportShapeLabel`, `feedback.exportShapeLong`, `feedback.exportShapePivot`). ### Notes - Pivot shape is **per-guest current state**, not history. A guest who rated a photo, then changed their mind and removed the rating, would show the final state in the pivot but BOTH actions in the long form. Acceptable trade-off: pivot users care about the snapshot, long users want the trail. - `latest_at` column in pivot gives a "most recent activity" timestamp per row, useful for sorting/filtering recent engagement. ### Test plan - [x] Backend syntax + TS check + lint clean (no new warnings; existing `catch (error)` warning was pre-existing) - [ ] Manual: feedback page → select Per-guest (pivot) → Export CSV → verify one row per (filename, guest) with is_favorited='yes'/'no', latest_at column populated - [ ] Manual: long shape default still produces the same per-action output as before (no regression for existing consumers) - [ ] Manual: comment containing a newline → pivot CSV escapes correctly, row count matches data length + 1 header - [ ] Manual: archive a published event with feedback → archive's `feedback_data.csv` still uses the long shape (archive surface unchanged on purpose) |
||
|
|
78c8e9d9f9 |
feat(whatsapp): WhatsApp Business API notification channel (#640 part D)
Ports filpgame/picpeak's WhatsApp integration with substantial adaptation
to fit our codebase patterns. Deliver the gallery-ready notification via
Meta Graph API in addition to (or instead of) email — useful where the
customer base expects WhatsApp by default. Strictly opt-in behind the new
`whatsapp` feature flag.
### Backend
- **Migration 136** (`whatsapp_configs` + `whatsapp_queue`). Loose-FK on
`event_id` matching our `inbound_documents` / `expenses` pattern (NOT
filpgame's hard FK — deleting an event shouldn't RESTRICT on stale queue
rows). Composite index on `(status, retry_count, created_at)` covers the
poll path.
- **`whatsappService.js`**: thin Meta Graph client. Meta API version bumped
v19 → v20 (filpgame's v19 deprecates Q3 2026); configurable via
`WHATSAPP_META_API_VERSION` env var. Timeout dropped 10s → 8s for
processor budget. Errors surface the Meta `error.code` so the processor
can tell retryable from permanent.
- **`whatsappProcessor.js`**: queue processor polling every 30s (configurable
via `WHATSAPP_QUEUE_POLL_MS`), 10 messages per cycle, 3 retries before
marking `failed`. Default language sourced from
`app_settings.general_default_language` (matches our email-language
resolution pattern); replaces filpgame's hardcoded `pt_BR` fallback.
Falls back to `en_US` if nothing is configured. No-ops gracefully when
the `whatsapp` flag is off, the config row is missing, or the access
token isn't set.
- **`adminWhatsapp.js`**: three routes (GET/PUT config, POST test). Gated
by `requireFeatureFlag('whatsapp')` so operators who haven't enabled it
can't see the surface. Access token masked as `'********'` on GET;
masked values silently preserve the stored token on PUT. Enabling with
no Phone Number ID, template name, or token (and none stored) fails at
the validator.
- **Two hook points** in `adminEvents.js`:
- **Create-and-publish-in-one-step**: queues immediately after the
`gallery_created` email when `!isDraft && customerPhone &&
waConfig.enabled`. Password from `req.body` is still in scope.
- **Publish-from-draft** (`POST /:id/publish`): queues with the password
the admin re-typed via PR #627's `PublishGalleryDialog`. When no
password was typed (legacy API consumers without dialog), passes empty
string so the password line renders blank rather than leaking the
`(set at creation)` sentinel.
- **`server.js`**: starts `whatsappQueueProcessor` at boot. Non-fatal if it
fails to start (logged as warning).
- **`feature_flags`**: new `whatsapp` flag in `KNOWN_FLAGS` and
`DEFAULT_FLAGS` (default false).
### Frontend
- **`featureFlags.service.ts`**: `'whatsapp'` added to `FeatureKey` union.
- **`FeaturesTab.tsx`**: WhatsApp card in the Communication section
(between Incoming mail and Messaging). Smartphone icon, "new" status,
sidebar-hidden (no sidebar entry — config lives under Settings).
- **`whatsapp.service.ts`** (new): typed client for the three admin routes.
- **`WhatsAppTab.tsx`** (new): Settings tab. Form for Phone Number ID,
WABA ID, access token (masked toggle), template name, and enabled flag.
Separate card below for a static test send. Token masking matches the
server's `'********'` sentinel — admin can edit other fields without
re-entering the token.
- **`SettingsPage.tsx`**: WhatsApp tab nav item gated on `flags.whatsapp`
(so it shows only when the feature is enabled); render block wires
`<WhatsAppTab />`.
### i18n
22 new EN + 22 new DE entries covering the Settings tab form, the
Features-tab card, plus `admin.activities.whatsapp_config_updated` +
`admin.notificationMessages.whatsappConfigUpdated` for the bell /
dashboard surfaces from PR #637.
### Deliberately NOT included
- filpgame's **password-encryption-at-rest** layer
(`password_encrypted`/`password_iv`/`password_key_version` columns).
Our publish-from-draft password recovery uses the admin re-type flow
from #627 (PublishGalleryDialog) — no plaintext at rest.
### Setup notes for operators
1. Create a Meta Business Account + WhatsApp Business App.
2. Register a phone number and obtain `phone_number_id` + `waba_id`.
3. Create a system-user access token (long-lived recommended).
4. Submit a message template for approval. The default `gallery_ready`
expects 5 body parameters: customer name, event name, gallery link,
password line, expiry date.
5. Enable the `whatsapp` feature flag.
6. Enter credentials under Settings → WhatsApp, send a test, then enable
delivery.
### Test plan
- [x] Backend `node -c` on all new/changed files clean
- [x] `tsc --noEmit` on frontend clean
- [x] Backend dev container restart picks up new files, /health OK
- [ ] Manual: enable `whatsapp` flag → Settings → WhatsApp tab appears
- [ ] Manual: save config with masked-only token (existing token preserved)
- [ ] Manual: enable=true without phone_number_id rejected at PUT
- [ ] Manual: enable=true without stored or new token rejected at PUT
- [ ] Manual: create-and-publish event with customer_phone → queue row
inserts with message_type='gallery_created'
- [ ] Manual: publish-from-draft via PublishGalleryDialog with password →
queue row uses the admin-typed password in the {{4}} line
- [ ] Manual: test send to a real phone with valid Meta config + approved
template → Meta returns messages[0].id, toast shows the id
- [ ] Manual: bell renders "WhatsApp configuration updated" in DE when
the config_updated activity fires (via PR #637 smart default)
|
||
|
|
820f4835f1 |
feat(categories): per-category download permissions (#640 part B)
Adds an `allow_downloads` boolean to `photo_categories` so admins can have different download policies per category — e.g. preview categories public, originals client-only. AND's with the event-level `allow_downloads`, so disabling at either level blocks downloads for that category's photos. Defaults to true so categories created before migration 135 keep working without admin intervention. Credit: 8digit/picpeak@928164b + @751ec75. ### Backend - **Migration 135**: additive `allow_downloads BOOLEAN NOT NULL DEFAULT true` on `photo_categories`, hasColumn-guarded + sane down. - **`adminCategories.js`**: PUT /:id accepts optional `allow_downloads` patch. - **`gallery.js`**: - `GET /:slug/photos` returns `allow_downloads` per category AND `category_allow_downloads` per photo. - `GET /:slug/download/:photoId` returns 403 when the photo's category disables downloads. - `GET /:slug/download-all` LEFT JOINs `photo_categories` and filters `whereNull(category_id) OR allow_downloads=true OR allow_downloads IS NULL`. The null check covers pre-migration-135 rows during the upgrade window. - `POST /:slug/download-selected` same filter pattern. ### Frontend - **`categories.service.ts`**: `updateCategory()` gains an optional `patch` argument carrying `{ allow_downloads }`. PhotoCategory interface gains the optional field. - **`EventCategoryManager.tsx`**: new toggle button next to the delete X. Green DownloadCloud icon when downloads are on, plain Download icon when off. Click toggles via the new mutation; toast confirms. - **`PhotoLightbox.tsx`**: `photoAllowsDownload = allowDownloads && currentPhoto?.category_allow_downloads !== false`. Hides the download button + blocks the 'D' keyboard shortcut + early-returns from handleDownload. - **Types**: Photo interface gains `category_allow_downloads`. - **i18n**: 5 new EN + DE entries for the toggle button toast + tooltip. No global-category surface change yet — global categories don't currently have a UI for the toggle. Admins can still flip the column directly via SQL or via a future global-categories editor. ### Test plan - [x] Backend syntax + TS check clean - [x] ESLint: no new warnings - [ ] Manual: admin → event detail → categories panel → click DownloadCloud icon → category flips, toast confirms - [ ] Manual: gallery (guest) → photo in disabled category → lightbox shows no download button, 'D' shortcut is a no-op - [ ] Manual: download-all on a gallery with one disabled category → ZIP excludes that category's photos - [ ] Manual: download-selected including a disabled-category photo → 404 (filtered out) and the response carries only the allowed selection - [ ] Manual: pre-migration-135 category (legacy row with NULL allow_downloads) → downloads still work (defaults true via fallback) |
||
|
|
e4e79a0b3a |
fix(archives): stream-extract restore for >2 GiB + preserve original_filename via manifest (#640)
Two related backup-integrity fixes from 8digit's fork (issue #640 items #3 + #4), bundled because they touch the same two files and ship better together than apart. ### Stream-extract restore for >2 GiB archives `adminArchives.js:170` was using `adm-zip`, which loads the entire ZIP into a Node Buffer before extracting. Node has a hard 2 GiB Buffer cap, so any restore over that limit fails with `ERR_FS_FILE_TOO_LARGE` — and since the frontend `onError` toast is the generic "Something went wrong", the cause stays invisible. Real-world wedding archives routinely cross 2 GiB; affected restores have likely been silent failures. Swapped `adm-zip` for `node-stream-zip` which streams each entry to disk as it's processed — no full-file Buffer, no 2 GiB ceiling. API shape: ```js const zip = new StreamZip.async({ file: archivePath }); const entries = Object.values(await zip.entries()); await zip.extract(null, eventDir); await zip.close(); ``` Re-import logic (photos, categories, sizes) unchanged; only field rename `entry.entryName` → `entry.name`. Credit: 8digit/picpeak@69033c6. ### Preserve `original_filename` via photos manifest Archive → restore round-trip currently loses `original_filename` (the post-#508 column tracking the camera-side name) because the gallery filenames are renamed on upload and can't be derived from the extracted files. This matters now that the Lightroom export (#623) depends on `original_filename` — a restored event lost that signal. - **`archiveService.js`**: writes `photos_manifest.json` into the archive containing per-photo `{filename, original_filename, type, uploaded_at, category_name}`. Non-fatal: a manifest write failure falls through to legacy behaviour (filename used as original_filename, same as before). - **`adminArchives.js`**: reads the manifest on restore, builds a `Map<filename → manifest>`, and assigns `original_filename = manifest?.original_filename || filename`. Archives produced before this lands have no manifest — restore logs a one-shot notice and falls back to filename, preserving backward compat. Credit: 8digit/picpeak@eb018aa. ### Deps - Removed `adm-zip ^0.5.16` - Added `node-stream-zip ^1.15.0` ### What's NOT in this PR 8digit's commit also fixed the production compose healthcheck (`curl` isn't in our Alpine image); that's already been addressed upstream in the meantime. The frontend `onError` swallow on the restore toast is a separate small follow-up. ### Test plan - [x] `node -c` on both files clean - [x] `node-stream-zip` async API verified at load time - [ ] Manual: archive a multi-GB event → restore → confirm photos re-import with original_filename preserved - [ ] Manual: restore an archive produced before this lands → confirm fallback to filename works (no manifest path crashes) - [ ] Manual: confirm the new photos_manifest.json is inside the generated archive (`unzip -l <archive>.zip | grep manifest`) |
||
|
|
b5279155ea |
Merge pull request #636 from Luca-Timo/feat/accounting-inbound-invoices
feat(accounting): incoming-invoice workflow v2 + VAT/financial settings consolidation |
||
|
|
db9e41d198 |
fix(accounting): tax-report storno totals + hours-line date on Postgres
Two pre-existing HIGH bugs surfaced by the codebase audit (accounting surface): - taxReportService: income totals excluded only `status='cancelled'`, never `kind='storno'`. A Storno (status='sent', amounts stored negative) netted into the totals on top of the already-excluded cancelled original → double-subtract, so a cancel-and-reissue read as 0 income instead of the reissued amount. Now exclude storno rows from grandTotal*/byRate (kept visible in the row list). Regression test reproduces the real cancel→storno→reissue 3-row flow. - customerHoursService.buildLineItemFromEntry: `String(entry.entry_date).slice(0,10)` on a `date` column → Postgres returns a JS Date, baking "Wed Apr 06" into the invoice line + PDF (SQLite returns the bare string, so SQLite-only tests pass). Normalise via the Date branch like every other date read. |
||
|
|
707c5d0277 |
fix(accounting): address the-luap PR #636 review
- #1 resolveTaxTreatment: an unconfigured (empty) reclaim-countries list no longer auto-classifies every supplier — incl. the admin's own domestic one — as foreign; defer auto-classification until the setting is set (+ test). - #2 pending re-bills on customer erase: eraseCustomer now returns the customer's not-yet-billed inbound docs to the inbox (null customer + unsorted) so they aren't billable to an anonymized account. (NB: picpeak has no hard customer delete — erase anonymizes in place — so the orphan/404 premise can't occur; this is hardening.) - #4 VatRateSelect: when >1 configured code shares the same rate, fall through to the legacy "(not configured)" option instead of silently picking the first. - #5 unwindBilledLine: delete the (mutable, never-issued) invoice when the unwound re-bill was its only line, instead of leaving a net-zero survivor. - #6 isInvoiceMutable: clarify in a comment that invoices have no 'draft' status (the editable state is 'scheduled' w/o send-at) — no behaviour change. - nit: collapse normalizeCurrency's tautological ternary. - Fix VAT picker i18n: t('vat.legacyRate') → 'ledger.vat.legacyRate' (the key's real home), so the legacy label localizes instead of always showing English. - Remove dead i18n keys left by the settings refactor (businessProfile.field VAT /hourly + profileFields.title/savedToast). |
||
|
|
267b121d66 |
feat(accounting): supplier-country tax default + configurable default output VAT code
VAT supplier-country reclaim default: - Migration 134 adds inbound_documents.supplier_country. - categorizeInbound auto-derives tax_treatment via resolveTaxTreatment: explicit treatment wins; else country in the reclaim list → domestic, outside it → foreign_vat_non_reclaimable, unknown → domestic. Consumes the previously-stored-but-unused accounting_vat_reclaim_countries. - Triage modal gains a Supplier country dropdown (saved via updateInbound). +5 unit tests for resolveTaxTreatment. Configurable default output VAT code for new invoices: - New accounting_default_output_vat_code setting (PUT wired; getSettings/type). - Settings → Accounting dropdown to pick it. - Invoice + quote editors seed their VAT picker (rate + code) from it on a blank new document — skipping edits/conversions, never clobbering a touched value. New docs no longer silently start at 0%. i18n en + de. |
||
|
|
348955b261 |
fix(hours): move logActivity out of the entry transactions (SQLite deadlock)
logActivity writes via the global db; called inside a db.transaction it deadlocks against the held write lock on a SQLite-backed install (a second write connection blocks). Stage the audit info inside each transaction and fire it AFTER commit in createEntry / updateEntry / deleteEntry / billUnbilledEntries — same fix already applied to expenseService. Return shapes unchanged. (The monthly/billing paths still route through createInvoice, whose own internal logActivity remains the shared root limitation — tracked in feedback_sqlite_global_write_in_transaction.) |
||
|
|
51837c3a88 |
feat(accounting): invoices force-enable the Accounting master
Invoice VAT config (codes + label) and the hourly rate now live under
Settings → Accounting, so an install with Invoices must have Accounting
available.
- applyDependencyRules (backend adminFeatureFlags.js + frontend
FeatureFlagsContext.tsx): bills on → accounting on, before the
accounting→children rule so the sub-features keep their own state.
- Migration 133 corrects existing installs: set the STORED accounting=true
where bills is on. requireFeatureFlag('accounting') reads the raw row, so
without this an upgraded install (invoices on, accounting off) would show
the tab but 403 its endpoints. Idempotent; only flips on; no down.
- Features tab: the Accounting card shows locked-on (disabled + hint) while
Invoices is enabled.
Also includes the i18n keys (en/de) for the VAT/financial settings move.
|
||
|
|
dc7b87bb87 |
feat(accounting): consolidate VAT/financial config into Settings → Accounting
- Remove the orphaned "Default VAT rate %" from Business profile; the rates are the Accounting VAT codes. The invoice/quote VAT picker (VatRateSelect) is now code-only — options are exactly the Accounting output codes, no free-text custom rate. Off-list legacy values on existing invoices are preserved as a read-only "(not configured)" option so issued documents aren't silently changed. - Move VAT label + default hourly rate to the Accounting tab (new AccountingProfileFields card; storage stays on business_profile, own save). Wire vat_label onto the PDF VAT-line label via the issuer block (covers invoices + quotes), falling back to the locale default when blank. - Default currency stays on Business profile but becomes a normalizing dropdown (an old free-text "chf" auto-selects "CHF"; unknown values preserved). Add a moved-note callout. Strip the moved fields from the Business-profile save so it can't clobber an Accounting-tab edit. |
||
|
|
315d15afd4 |
test(accounting): incoming-invoice integration test + fix vat_code reload & SQLite logActivity deadlock
- Add backend/__tests__/integration/incomingInvoiceRebill.test.js (8 tests): disposition state machine, per-event PENDING pool, passthrough-no-markup, unwindBilledLine recompute, INVOICE_LOCKED on an issued invoice, and re-categorisation transitions. The invoice-MINTING paths can't run inside an outer transaction on SQLite (createInvoice's sequence claim deadlocks on the held write lock) — covered by buildInboundLineItem unit tests + discountLineItems instead; documented in the test. - Move logActivity out of the categorize/rebill/bundle transactions. It writes via the global db; inside a transaction a second write connection deadlocks on a SQLite-backed install (also affected SQLite-prod, not just tests). - Fix bill-editor vat_code reload: transformInvoice (adminInvoices.js) dropped vatCode, so the editor fell back to rate-matching and lost a custom-rate code on edit. Now returns vatCode: i.vat_code. - Rewrite docs/accounting-inbound-invoices.md to the current implementation (IR-vs-Expenses split, re-categorise + unwind, cadence-aware re-bill / pending pool, passthrough-at-cost, migrations 122-132, rasterised preview, tax/ledger/VAT). |
||
|
|
9a023c0197 |
feat(accounting): explain dispositions inline, drop markup from pass-through
- Add a per-disposition info line under the Disposition dropdown so re-bill vs pass-through vs company expense is clear in-context (en + de). - Markup is a re-bill concept only: the control now renders solely for rebill, and a pass-through always bills at cost. Enforced server-side too (categorizeInbound applies markup only when disposition === 'rebill'). - Clarify "Book to" with a hint — it attributes the supplier cost to an event in the tax report / ledger export, separate from who you re-bill to. |
||
|
|
36a8e42f90 |
feat(accounting): re-categorize incoming invoices, note field, pending re-bill pool
Address three incoming-invoice issues: 1. Re-categorization: a categorized invoice can now be changed again (e.g. passthrough → company expense). New "Re-categorize" button pre-fills the triage modal from the existing disposition/customer/markup/note. categorizeInbound is re-runnable — it unwinds any prior re-bill line (removes the invoice line + recomputes totals) before applying the new disposition, and refuses (INVOICE_LOCKED) when the re-bill is on an already-issued invoice. 2. Note field: new `note` column (migration 132 — 126 is already on beta) captured in triage and shown in the read-only view. 3. Re-bill like hours: rebill/passthrough now persist customer_account_id. Per-event customers accumulate as PENDING items, surfaced in a new "Pending re-bills" card and bundled into one invoice via "Bill these" (mirrors unbilled-hours billing). Monthly/manual customers keep auto-consolidating onto their running draft. Passthrough (durchlaufend) can now also attach to a customer with optional markup. Adds backend unit tests for buildInboundLineItem + isInvoiceMutable and en/de translations (other locales fall back to English defaults). |
||
|
|
e985d25207 |
feat(events): duplicate-gallery action (#626)
Daniel asked for a way to re-use a good gallery configuration without re-entering every setting. Two of his three suggested workflows are covered by this PR; the third (per-event-type behaviour defaults) is partially shipped already via event_types.theme_preset + theme_config and is left as a follow-up if the duplicate workflow doesn't cover it. Backend — POST /admin/events/:id/duplicate. Validates a new event_name (required) + event_date (optional) + customer_name/email (optional); copies branding (color_theme, css_template_id, header/hero/divider/anchor), behaviour toggles (allow_downloads, watermark_*, allow_user_uploads, require_password, etc.), photo_cap, welcome_message, default_photo_sort, admin_email, and feedback settings + per-event photo categories. Mints a fresh slug + share_token + random-placeholder password_hash (admin sets the real one via the publish dialog shipped in #627). Recomputes expires_at = new_event_date + (source.expires_at - source.event_date) so the duplicate keeps the same active window; defaults to 30 days if either source field was null. is_draft is always true. Deliberately NOT carried over: photos, hero_photo_id, client_access secrets, og_image_share opt-in, customer_phone, sent_at flags, archive state, customer-account assignments. Frontend — new DuplicateEventDialog (matches the PublishGalleryDialog pattern), wired into the Actions card on EventDetailsPage. Visible in both draft and live mode since admins typically duplicate from a published gallery. On success the page navigates to the new draft so the admin can finish customising + publish. I18n: EN + DE entries for the dialog + button label. Backend logs an event_duplicated activity with the source event id/name so the trail is auditable. Frontend service: eventsService.duplicateEvent(eventId, data). |
||
|
|
714a9f6fb1 |
fix(upload): auto-throttle on low-memory hosts + correct documented RAM minimum (#628)
The README claimed 2GB RAM as the minimum, but two background-processor worker loops × sharp.concurrency(2) means up to four libvips threads can decode full-resolution images in parallel — peak RSS lands at 1.5GB+ on a batch of 20MP+ photos. Add Postgres + Redis + Node baseline and one heavy batch on a 2GB VPS OOM-kills the backend, surfacing as 503s on thumbnails until restart:unless-stopped brings it back. Reported in #602, filed as #628. Three changes, smallest-surface-area each: 1. backgroundProcessor.js — on startup, when UPLOAD_PROCESSOR_CONCURRENCY is NOT set and os.totalmem() reports < 3GB, default to 1 instead of 2 and log a one-shot warning naming the override env var. Explicit env-var setters keep their value. os.totalmem() reports container memory under cgroup v2 so this works in Docker / k8s as well as bare metal. 2. README.md — bumped the documented minimum from 2GB to 4GB, kept 2GB only as a "Low-memory hosts" recipe pointing at UPLOAD_PROCESSOR_CONCURRENCY=1 with the throughput trade-off spelled out. Added the 503-on-OOM symptom so the next reporter finds it via search. 3. docker-compose.production.yml — commented mem_limit / memswap_limit example on the backend service. Off by default (don't surprise existing deployments) but visible to operators thinking about shared/multi-tenant hosts. restart:unless-stopped already on every service. No code path for memory-aware runtime throttling (Luca's option 4) — out of scope for a bug fix; tracked separately if #1-#3 don't close the case. |
||
|
|
83b568ee2d |
fix(events): publish-from-draft email carries the real password (#627)
Previously, publishing a password-protected DRAFT gallery sent the gallery_created email with the literal sentinel "(set at creation)", which the email processor localised to "The password you set when creating the gallery" / "Das bei der Erstellung der Galerie gesetzte Passwort". Root cause: at draft creation only the bcrypt hash is stored (no plaintext column, by design); the publish endpoint had nowhere to pull the actual password from. Create-and-publish-in-one-step worked because the plaintext is still in memory at email-queue time. Fix: the Publish action now opens a small PublishGalleryDialog that prompts the admin to (re-)type the gallery password. The publish endpoint accepts an optional `password` body, re-hashes + writes `password_hash` so the stored hash matches what was just emailed (admins who mistype at creation get a self-healing publish flow), and puts the plaintext into the gallery_password email field. When the publish call is made without a password (API-only consumers), behaviour falls back to the legacy sentinel — no breaking change. The window.confirm() publish flow is gone; the dialog handles the no- password case too (plain confirm + Publish button). I18n: EN + DE entries for the dialog. Other locales fall through to the EN defaults via the t() default-value pattern. No schema changes. No plaintext at rest. |
||
|
|
a239fec9d7 |
fix(admin/exports): Lightroom TXT export joins with comma + drops extension (#623)
The PhotoExportMenu's TXT format advertises "Simple text list for Lightroom search" but emitted newline-separated filenames WITH `.jpg`. Lightroom's filename search wants a comma-separated one-liner, and the gallery JPEGs may correspond to RAW files in the catalog — so the search has to match on the stem only. The frontend now passes `separator: 'comma'` + `include_extension: false` for the TXT format specifically. The backend gains an `include_extension` option (defaulting to true so direct API consumers don't break), and the comma case joins without a trailing space (the form Lightroom expects). Unit test pins the Lightroom-mode output AND the backward-compatible default for any direct API caller. CSV / XMP / JSON exports are unchanged. |
||
|
|
86dff75898 |
test(accounting): cover export scope, unique-violation detector, PDF page cap
Closes the test gaps from the PR #622 work + the export-scope feature: - export scope: scopeLedger/normalizeScope (exported via _internal) unit tests + renderTaxReportCsv income/cost/all output assertions (income drops supplier rows, cost drops invoice rows, filename gets the scope tag). - isUniqueViolation: Postgres 23505 / SQLITE_CONSTRAINT / "UNIQUE constraint failed" message, false for FK + nullish (the IMAP claim-first race detector). - getRenderedPagePath: out-of-range pages reject with PAGE_OUT_OF_RANGE before touching pdftoppm/disk (the per-file resource bound). |
||
|
|
9f3b28684f |
feat(accounting): scope the tax-report export to income-only or cost-only
Adds a Complete / Income only / Cost only selector to the readable PDF + CSV export (the on-screen report stays complete). Income-only emits just the outgoing rows + the income summary line (+ the per-rate breakdown in the PDF); cost-only emits the incoming-invoice + expense rows + the cost line and drops the income-by-rate breakdown. Useful in Liechtenstein where, under the income threshold, a flat 20% Gewinnungskosten deduction is sometimes better than actual costs — handing the Treuhänder just the income (or just the cost) basis is cleaner. Backend: renderTaxReportPdf/Csv take a `scope` param (all|income|cost) that filters report.ledger by row.type + the summary lines; the /pdf + /csv routes accept & validate `?scope=`; filenames get an income_/cost_ tag. Frontend: scope <select> beside the export buttons, threaded through buildQueryString. i18n en/de. The 20% calculation itself is intentionally NOT in-app (applied by the Treuhänder) per the scoping decision. |
||
|
|
d6da89f48a |
chore(accounting): PR #622 nits — stray artifact, dedupe requireFlag, IMAP poll backoff
1. Remove the committed test artifact backend/storage/business-docs/quote/2026/ Q-2026-0001.pdf and gitignore backend/storage/business-docs/ so generated CRM docs can't be committed again. 2. adminLedger + adminExpenses dropped their local requireFlag copies and now import the shared (now cached) requireFeatureFlag middleware. 4. roundTripTest polls IMAP with ×1.5 backoff (cap 8s) instead of a flat 3s, so a 30s test takes ~5 SELECT/SEARCH locks not ~10 (some servers throttle). Nit 3 (dashboard + events pages still on the gallery-theme vars, not dark-mode- swapped) is left as a documented follow-up per the review. |
||
|
|
a93b6dc232 |
fix(accounting): PR #622 concerns — flag-cache, customer master gate, VAT-unconfigured, helpers, page cap
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. |
||
|
|
cd6d57839b |
fix(accounting): PR #622 blockers — CSV formula injection + IMAP double-ingest race
Blocker 1 — CSV/Banana formula injection. Neither csvEscape (ledgerService) nor the tax-report CSV escape nor the unquoted tab-separated Banana cell formatter prefixed risky leading chars, so an admin-/sender-controlled cell beginning with = + - @ TAB CR executes as a formula when the Treuhänder opens the export. New shared util neutralizeSpreadsheetFormula() prepends a single quote; wired into all three sinks (quoted CSV + unquoted Banana). Unit test pins one of each char. Blocker 2 — IMAP intake double-ingest race. received_emails.message_id was INDEX, not UNIQUE, and the poller ingested attachments BEFORE writing the audit row, so a second replica / rolling-deploy overlap double-ingested the same mail. Migration 128 makes message_id UNIQUE (nulls stay distinct); the intake now CLAIMS the message row (status='processing') BEFORE ingesting — a concurrent claim hits the unique constraint and skips cleanly (shared isUniqueViolation helper). Stale 'processing' rows (worker crashed mid-ingest) are reclaimed after 10 min so no attachment is orphaned. NOT done (deliberate): the suggested UNIQUE on inbound_documents.file_sha256 — that column is a SOFT dedup key by design (manual re-uploads are kept as flagged 'duplicate' rows + duplicate_of_id for the Duplikat disposition); a unique index would break that feature. The file race only yields an extra 'unsorted' row (a data-quality nit, caught by the existing manual Duplikat backstop), not a double-count. Rationale to be added to the PR reply. |
||
|
|
03fa3d8296 |
fix(flags): close CRM/accounting feature-gating gaps from the audit
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).
|
||
|
|
8621338c48 |
fix(settings): don't insert non-existent created_at into app_settings
app_settings has no created_at column (src/database/db.js defines only
setting_key/value/type + updated_at), so inserting one threw — which
broke saving any FIRST-TIME setting key. Existing keys took the UPDATE
path and worked, hiding the bug; it surfaced on the new VAT-registration
toggle + reclaim-countries keys ("Failed to save accounting settings").
Also fixes the same latent failure on the customer-surface settings route.
|
||
|
|
d7107aaf0a |
feat(accounting): tax report VAT-payable honours registration + reclaim
The report's vatPayable is now: 0 when not VAT-registered; otherwise output VAT minus the RECLAIMABLE input VAT only (costs with tax_treatment foreign_vat_non_reclaimable are excluded from the deduction). Registration reads accounting_vat_registered; when unset it falls back to a behaviour-preserving heuristic (charged output VAT this period ⇒ registered), so existing reports are unchanged and non-VAT installs correctly show 0. loadCosts now tracks reclaimableVat. Tests updated; 32 pass. |
||
|
|
2479d87afc |
feat(accounting): bill editor VAT dropdown + GET returns vat_code snapshot
Slice 2 + 1b: - Bill editor: VAT-rate field → VatRateSelect dropdown (mirrors the quote editor); snapshots vatCode on create + carries it from a source quote. - getQuoteById + the invoice serializer now return vat_code, so re-editing a saved document preserves the snapshot instead of falling back to the rate→code map. Payload types (quotes + bills) carry vatCode. 72 tests pass; build green. |
||
|
|
fbbbb8ab73 |
feat(accounting): VAT registration/reclaim settings + un-gated VAT-codes read
Slice 1 of the VAT consolidation backend: - PUT /admin/settings/accounting accepts accounting_vat_registered (bool) + accounting_vat_reclaim_countries (ISO-2 list); GET /:type already returns them parsed, so no GET change needed. - New read-only GET /api/admin/vat-codes (adminAuth, NOT accounting-gated) so the invoice/quote editors can populate their VAT dropdown even when the accounting layer is off. Management CRUD stays under /admin/ledger. |
||
|
|
5b52969e36 |
feat(accounting): snapshot the chosen VAT code on quote/invoice create + storno
Wires the vat_code snapshot (migration 130) through the write paths: quote create/update, the main invoice create, and the Storno carry-over (so a cancellation exports the same code as the invoice it reverses). Guarded with hasColumnCached; reads payload.vatCode (sent by the editor dropdown, coming in a later slice — inert until then, falls back to the rate→code map). 72 tests pass. |
||
|
|
0a7dc1cf5d |
feat(accounting): snapshot vat_code on quotes/invoices + export prefers it (foundation)
First slice of the VAT-consolidation: migration 130 adds a nullable vat_code snapshot column to quotes + invoices, and the Treuhänder export now prefers the invoice's snapshotted code over the (mutable) rate→code map, so a historical invoice's VatCode never changes when codes are re-mapped. Schema-drift guarded; behaviour-neutral until the editors start writing the snapshot (next slices). Part of: VAT registry → Settings→Accounting, invoice VAT dropdown, registration/ reclaim toggle. |
||
|
|
53a16f9f6f |
fix(accounting): Banana I&E export uses the 'Category' column (not 'ContraAccount')
Real Banana Income & Expense files name the category column 'Category', not 'ContraAccount' (which the doc listed but is a double-entry concept) — so the income/expense account never landed and Banana warned 'ContraAccount column not found'. Use 'Category'. VatCode stays (it only warns on a non-VAT-enabled file; amounts are gross). Test updated. |