Commit Graph
74 Commits
Author SHA1 Message Date
Paul Nothaft 74ff236b51 fix(images): fence the capture-date backfill on the file it read (#1201) (#1205)
Stable twin of #1204.

The capture-date backfill committed its result keyed on the row id alone. It
snapshots every candidate up front, then walks them one at a time reading
originals off S3 or a NAS mount — a pass that can run for many minutes.

replacePhoto, reachable from the replace_by_name upload path, swaps a NEW file
under an existing row and rewrites path/filename. A replacement landing inside
that window carries no date of its own, so captured_at was still NULL, the
whereNull guard passed, and the previous file's EXIF date was written onto the
new photo. Silent: nothing errored, the run reported it as a success, and the
gallery just sorted that photo to the wrong place.

Fenced on path and filename as well as the id, so a replaced row matches zero
rows and is skipped. The candidate query already selects both columns, so no
query change. Knex renders a null value in the object form as `is null` on both
the pg and sqlite3 clients, so a row with a NULL path still matches itself.

Those skipped candidates are now counted rather than dropped. replacePhoto is
not the only writer of path/filename — eventRenameService rewrites both on an
event rename, which is not a content change — and another writer filling
captured_at first lands in the same place. Without a counter they fell out of
the run's arithmetic entirely: success + noExif + failed no longer added up to
the count the operator was shown when they started the job.

The card shows the count only when it is non-zero, and states what is known —
changed by something else, not updated — rather than promising a retry: for the
already-dated case there is nothing to retry, and the Missing Capture Date
figure above is what says whether work is left. Locale coverage: en, de, fr, sl,
with the defaultValue carrying the rest.

Regression test: a replacement landing mid-run leaves captured_at NULL and is
not counted as updated. Verified to fail against the unfenced code on this
branch.
2026-08-27 08:44:32 +02:00
Paul NothaftandPaul Nothaft ac7ef266dc fix(admin): make "Storage used" report storage used (#1164) (#1177)
* fix(admin): make "Storage used" report storage used (#1164)

Stable twin of #1170.

The tile summed photos.size_bytes — the catalogued size of the ORIGINALS,
which in reference mode live on external storage and have no relationship to
the disk PicPeak runs on. The reporter's tile read ~80 GB against 21 GB of
real usage. Worse than the label: the same number drove the soft-limit warning
bar and, via /storage/info, the recommended soft limit — so a reference-mode
install got a disk-capacity recommendation computed from bytes that are not on
the disk.

- new localStorageUsage service walks the storage root and reports the total
  plus a breakdown. Walking rather than summing DB columns is the point:
  thumbnail/preview/hero rows record a key and never a byte count, and orphans
  from a deleted event or an interrupted import are real bytes.
- the external media root is excluded when it sits inside the storage root.
  Its compose default is <storage>/external-media, where the NAS is
  bind-mounted — a plain directory, not a symlink — so walking it would put
  every referenced original back into a figure whose purpose is to leave them
  out. Symlinks are not followed either.
- .download-cache gets its own line: it lives inside the event directory, so
  the naive rule files a multi-GB zip as photography.
- concurrent cold-cache callers share one walk; the dashboard, /storage/info
  and the sidebar are routinely requested together.
- S3 installs keep the catalogued figure and the walk is skipped before it
  runs, since the objects are in the bucket and STORAGE_PATH holds only
  incidental local files.
- an absent measurement reads as "unavailable" and a partial one is marked
  `+` across the dashboard, analytics, sidebar and status tab — a floor
  silently compared against a soft limit reads as "safely under".

Verified on this branch: 11 new service tests, dashboardScope updated for the
changed contract, full suite leaves the same 5 pre-existing failures as
origin/stable. Frontend 20 files / 104 tests, tsc clean.

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

External review found both of these on this branch.

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

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

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-26 09:14:48 +02:00
Paul NothaftandPaul Nothaft 75facb4d67 fix(gallery): stop the lightbox loading originals to display a photo (#1166) (#1175)
* fix(gallery): stop the lightbox loading originals to display a photo (#1166)

Stable twin of #1169.

The lightbox read preview_url, which the server only emits once an admin has
flipped lightbox_preview_enabled — off by default. So a stock install fell
straight through to url, the untouched original: a reporter measured 16.5 MB
for a photo whose preview is 345 KB. The lightbox renders its neighbours too,
so opening one photo pulled three originals.

slideshow_url is the same /preview/:id URL, watermark query included, and has
been emitted unconditionally for images since #1015. Preferring it fixes every
existing install with no migration and no admin action.

Two other surfaces bypass PhotoLightbox entirely and had the same bug:

- premium galleries build their own slides with `src: photo.url`. Fixing that
  also required carrying the photo id on the slide, because the download
  handler recovered the photo by matching slide.src against photo.url — a
  derivative src would have made Download a silent no-op.
- the Story layout rendered the full original as its GRID TILE, at
  object-cover in a small card, and its hero rendered one as a full-bleed
  background when hero_url exists for exactly that. Cards now use the preview
  tier (not the thumbnail: thumbnail_fit is seeded to 'cover', so a thumbnail
  would be cropped a second time and reframe every photo) and only load once
  within 200px of the viewport, since every card mounts at page load.

GIF, APNG and PNG keep the original: generatePreviewImage encodes JPEG, which
has neither a second frame nor an alpha channel. The backend fix that removes
this list is the next commit in this stack.

Divergence from the main twin: no responsive `?w=` tiers. #1095 is main-only,
so `lightboxImageUrl` here selects a URL and nothing more. It lives in
`imageTiers.ts` under the same path main uses, so that backporting #1095 later
merges into this file rather than landing beside it.

Verified on this branch: 8 new tests; frontend suite 21 files / 113 tests,
tsc clean.

* fix(gallery): make the Story hero fix actually work on external galleries (#1166)

External review. Same two fixes as the main twin.

hero_url was inert for external media. ensureHeroImage only ever called
resolvePhotoStorageKey, which returns null for external/reference photos by
design — and that null was handed straight to withLocalCopy, which throws, so
the hero route caught it and redirected to the full ORIGINAL. #1078 fixed
exactly this shape for ensurePreviewImage and nobody carried it across. It
stayed invisible until this PR pointed the Story hero at hero_url: on a
managed gallery that is a real saving, on a reference-mode gallery it quietly
changed nothing.

Needed one extra piece here that main already had: generateHeroImage on this
branch ignores outputBasename and always derives the key from the source
basename, so two events referencing the same NAS filename would clobber each
other's hero. It now honours the option, matching generateThumbnail and
generatePreviewImage.

The format bypass trusted mime_type, which is not trustworthy: migration 039
backfilled every pre-existing photo to image/jpeg regardless of what it was,
and adminExternalMedia inserts rows with no mime_type at all — so a
mislabelled PNG sailed past the guard and came back flattened. It now checks
the filename extension as well.

* test(gallery): the hero fixture follows the root-relative relpath contract (#1166)

Same fix as the main twin: external_relpath has been resolved from
EXTERNAL_MEDIA_ROOT rather than from event.external_path since #1163 landed,
and this fixture still carried the base-relative form, so the two tests stopped
resolving the moment that stack merged. Production was never affected.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-26 09:08:50 +02:00
Paul NothaftandPaul Nothaft 7f0ed23ea4 fix(external-media): record captured_at on import and add a backfill (stable) (#1183)
* fix(external-media): record captured_at on import and add a backfill (#1172)

Stable twin of #1179.

External media never went through photoProcessor, so captured_at stayed NULL
for every externally imported photo. The gallery's "Date Taken" sort then
degraded into import order through its own COALESCE fallback — a library
imported in two batches showed the first days of a trip after the last ones.

Ported whole:
- adminExternalMedia.js reads the capture date at import, off the file it has
  already opened for the dimensions. Best-effort, like the dimensions.
- A backfill endpoint for photos imported before this, so existing installs
  can fix historical rows rather than only new imports. Managed originals go
  through resolvePhotoStorageKey + withLocalCopy so S3 installs work; archived
  events are excluded because archiving deletes their originals; the run flag
  is claimed before the candidate query so two POSTs cannot both start.
- gallery.js carries photos.id as a tiebreaker on all three sorts. A bulk
  import writes hundreds of rows inside the same second, so uploaded_at ties
  are the normal case and the grid reshuffled between page loads.

One deliberate difference from main: the backfill is gated on settings.edit /
settings.view rather than system.manage / system.view, which do not exist on
this branch. They are what settings.edit was later split into, and main's
migration 175 projects every settings.edit holder forward onto system.manage,
so both branches let exactly the same people through.

* fix(external-media): gate the status card on the permission the button needs (#1172)

The built-in admin role holds settings.view but not settings.edit
(056_add_role_permissions_table.js:63), and StatusTab renders its card and
enabled button purely on a successful status payload. Gating the status
endpoint on settings.view therefore showed every admin a Backfill button whose
every click 403s with no error surfaced.

* fix(gallery): make the Date Taken sort correct on SQLite (#1172)

Same defect as the main twin: photos.captured_at holds three storage classes on
SQLite — an epoch-millisecond integer from managed uploads (photoProcessor.js:441
hands knex a Date), ISO text from external imports and the backfill, and null
falling through to uploaded_at's 'YYYY-MM-DD HH:MM:SS' text. SQLite sorts
INTEGER before TEXT unconditionally, so a 2027 capture came back before a 2020
one, and within the text values the 'T' separator outranked the space.

Normalised in the ORDER BY; Postgres keeps the plain COALESCE, its column being
a real timestamp. Regression tests drive the real gallery route on real SQLite.

* fix(gallery): normalise epoch-integer uploaded_at too, and stop polling a 403 (#1172)

Both follow-ups from the main twin's review, ported.

uploaded_at is not always text on SQLite: a .picpeak restore can carry epoch
milliseconds in from an install that stored them that way, and the fallback
branch read it with substr(), comparing '1830297600000' against
'2020-01-01 00:00:00' as text. Both columns now get the integer/real branch.

The status card also polled every ten seconds regardless of permission. On this
branch that hits every built-in admin — they hold settings.view but not
settings.edit — so each would have had a 403 and a logged denial every ten
seconds for a panel they were never shown.

* style: quote convention in the capture-sort test (#1172)

* fix(capture-dates): skip watcher-imported videos, and make the status counts consistent (#1172)

All three follow-ups from the main twin, ported: the three-marker video filter
(fileWatcher sets type/mime but not media_type, so those rows sat in the
backlog forever), the single-aggregate status counts (two queries could report
a negative backlog mid-import), and the card's render gated on settings.edit as
well as the cached payload.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-26 09:04:18 +02:00
Luca 0ed3bbefa1 chore(messages): describe the feature as 'unified' rather than by a third-party product name 2026-07-07 11:10:00 +02:00
Luca b96ad36f5d fix(messages): make the Messaging feature flag toggleable
The Messaging FeatureCard was a hardcoded-disabled 'roadmap' placeholder
(no-op toggle), so the messaging flag could never be turned on — the Messages
sidebar item + page stayed hidden. Wire the toggle to setFlag, mark it 'new',
and describe the actual admin Messages client.
2026-07-07 11:06:16 +02:00
Paul Nothaft 96e3c68b9d feat(admin-ui): TOTP MFA enrollment + two-step login; remove stub 2FA toggle
Frontend for #738.
- mfa.service.ts + MfaSettingsCard (Settings → General → Admin Account):
  per-user setup (QR + manual secret + verify), recovery codes shown once
  (copy/download/confirm), status, regenerate, disable. Renders for
  super_admin (closes #735).
- Two-step login in AdminLoginPage: on {mfaRequired,mfaToken} swap to a
  code step (TOTP or recovery), call /auth/admin/login/mfa; handle
  MFA_INVALID / MFA_SESSION_EXPIRED / 423 lockout.
- Removed the non-functional global enable_2fa checkbox from SecurityTab
  (and its persistence) — replaced with a note pointing to per-user setup.
- en + de i18n.

Verified live in-browser: enroll (QR→code→recovery codes), logout, and
the two-step challenge into the dashboard as super_admin.
2026-07-03 11:44:07 +02:00
Paul Nothaft 15be3b8d32 Merge pull request #667 from Luca-Timo/feat/workflow-engine
feat: admin-configurable workflow engine + dunning/Mahngebühr rework (RFC — feedback welcome)
2026-06-25 19:17:03 +02:00
Paul Nothaft 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.
2026-06-23 18:11:58 +02:00
Paul Nothaft 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.
2026-06-23 17:37:41 +02:00
Luca ff478619b5 feat(workflows): add workflows feature flag + Features-tab toggle
New opt-in 'workflows' master flag (default off) across the backend
KNOWN_FLAGS/DEFAULT_FLAGS and the frontend FeatureKey union, context
defaults, and a new Automation section card in the Features tab. Gates
the upcoming Workflows admin area and the engine runtime. en/de i18n
added (DE native).
2026-06-23 01:53:03 +02:00
Paul Nothaft 80e8ec5bc7 Merge pull request #650 from the-luap/fix/whatsapp-template-params-647-followup
feat(whatsapp): admin-selectable template parameters + reorder (#647 follow-up)
2026-06-21 21:35:09 +02:00
Paul Nothaft 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)
2026-06-21 21:33:29 +02:00
Paul Nothaft 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.
2026-06-21 20:51:20 +02:00
Paul Nothaft 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.
2026-06-20 23:29:08 +02:00
Luca 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.
2026-06-20 03:08:04 +02:00
Paul Nothaft a8bb7b439f fix(i18n): wrap WhatsApp token show/hide aria-label through t()
i18n audit caught one straggler — the eye-icon toggle on the access-token
input had a bare `aria-label={showToken ? 'Hide' : 'Show'}` that wouldn't
translate for screen readers on non-English locales. Switched to
`t('common.hide')` / `t('common.show')`; added the matching `common.show`
key in EN + DE (common.hide already existed).

The two remaining `placeholder=` literals in the WhatsApp tab are sample
ID strings (`123456789012345`, `gallery_ready`, `+49123456789`) — those
are identifier/value examples, not translatable English.

Other PR-touched UI surfaces passed the audit clean: 30 new i18n keys
across categories (5), settings.whatsapp (16), settings.features.whatsapp
(2), feedback (3), and the activity-log + bell entries (4) all exist in
both EN and DE.
2026-06-19 08:42:22 +02:00
Paul Nothaft 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)
2026-06-18 22:57:22 +02:00
Paul Nothaft eaed00fcca Merge remote-tracking branch 'origin/beta' into fix/i18n-activity-types-comprehensive
# Conflicts:
#	frontend/src/i18n/locales/de.json
#	frontend/src/i18n/locales/en.json
2026-06-18 21:28:18 +02:00
Paul Nothaft 997a41293e fix(i18n): sweep Events / API Tokens / Webhooks settings tabs
Continuing the activity-type i18n sweep from this PR: three settings
tabs still had hardcoded English strings (or referenced i18n keys that
didn't exist in either locale).

EventsTab (Settings → Event Creation):
- defaultFeedbackEnabled + defaultFeedbackEnabledHelp were referenced
  by the component but missing from both locales. The inline-default
  English text leaked through to German users.

ApiTokensTab (Settings → API Tokens):
- "Preview" table-header column was a bare string literal; now wraps
  through t('settings.apiTokens.preview').
- confirmRevoke called t() with a backtick template-literal default
  ("Revoke \"${token.name}\"…"). The interpolation happened at the
  default-string level, so the actual translated string never received
  the name and shipped without it. Switched to the i18next {{name}}
  parameter pattern with the matching value in en+de.

WebhooksTab (Settings → Webhooks):
- Half the tab was still hardcoded English. Wired everything through
  t(): toast messages (createError, updateError, deletedToast,
  deleteError, copied, copyFailed), Just-Created Secret card buttons
  (Copy, Dismiss), form placeholders (name, URL, template), advanced
  toggle label, filter and template help paragraphs, the filterError
  setter, all six table headers, the eventsSubscribed count (with
  proper {{count}} pluralisation), the status badge (Active/Disabled),
  the active/inactive title tooltips, the Deliveries link, the Delete
  button, and the delete-confirm dialog (proper {{name}} interpolation
  instead of the broken template-literal-in-default-string pattern).

Added 34 new key/value pairs to each locale; counts now symmetric at
events=28, apiTokens=23, webhooks=43 in both EN and DE.

DE wording authored natively; tone matches the existing maintainer-
voice style.
2026-06-18 21:16:30 +02:00
Luca 33d5408977 refactor(accounting): consolidate the Accounting tab into two cards + one Save
- Box 1 "Default rates": mileage, daily allowance, hourly rate, require-proof.
  Hints now make the cost-vs-billing split explicit (daily allowance = expense,
  hourly = billing fallback).
- Box 2 retitled "VAT": registration, reclaim, default invoice VAT code, and
  the VAT label (moved out of its own card).
- Drop the third card (AccountingProfileFields deleted); the two Save buttons
  become one — it persists both the app_settings and the two business_profile
  fields (VAT label + hourly rate) together.
- Rename "Per-diem" → "Daily allowance" (EN) for clarity; German keeps the
  established "Spesenpauschale".
2026-06-18 15:56:52 +02:00
Luca 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.
2026-06-18 15:35:58 +02:00
Luca 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.
2026-06-18 15:11:11 +02:00
Luca 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.
2026-06-18 15:10:44 +02:00
Luca 97795f6d1e feat(accounting): move Chart of accounts into Settings → Accounting
Consolidate all accounting configuration in one place. The Chart of
accounts (accounts table + category/default-account mappings) becomes a
self-contained ChartOfAccountsManager rendered in Settings → Accounting,
next to the VAT codes that already moved there. The /admin/accounting
section is now purely operational (Incoming invoices · Expenses · Tax).

The old /admin/accounting/ledger route redirects to the settings tab so
bookmarks keep working; the Tax page "Configure" link points there too.
ChartOfAccountsManager saves only the account keys (partial-merge safe,
same as VatCodesManager), so the two never revert each other's edits.
2026-06-16 01:11:12 +02:00
Luca 4ff5b84cb6 feat(accounting): relocate VAT codes + rate maps into Settings → Accounting
Move VAT-code CRUD and the rate→code / treatment→code maps off the
Chart-of-accounts page into a self-contained VatCodesManager rendered in
Settings → Accounting, so all VAT config lives in one place. CoA keeps
the accounts table, default/system accounts, and expense-category maps.

Both pages save disjoint key sets through the partial-merge updateSettings
(CoA → account keys only; VatCodesManager → ledger_vat_map +
ledger_output_vat_map only), so neither reverts the other's edits.
2026-06-16 00:45:50 +02:00
Luca 4d87684882 feat(accounting): VAT registration + reclaim-country settings in the Accounting tab
Adds the 'VAT registration & reclaim' section to Settings → Accounting: a
'VAT-registered' toggle (charge output + reclaim input VAT) and a multi-select
of countries whose input VAT is reclaimable (default domestic CH/LI). Wires
accounting.service + the backend keys added earlier (accounting_vat_registered,
accounting_vat_reclaim_countries). i18n en/de. The report VAT-payable math that
consumes these is the next slice.
2026-06-16 00:27:17 +02:00
Luca 402dbde0a1 Merge origin/beta into feat/accounting-inbound-invoices
Resolves the 7 feature-flag / i18n conflicts (accounting flags vs upstream's
Project Overview 'projects' flag, both registered in the same files) as
additive unions — accounting + incomingInvoices + expenses AND projects all
coexist. Migrations slot cleanly: projects 117-121, accounting 122-129, no
collisions. Frontend build + backend node --check pass.
2026-06-15 16:37:23 +02:00
Luca 31280e1f7a feat(email): incoming mail UI - IMAP config block + Received emails tab
Frontend for the incoming-mail feature.

- Settings -> Email: an "Incoming mail (IMAP)" block under the outgoing SMTP
  settings (same field shape: host/port/security/user/pass/folder), shown only
  when the incomingMail flag is on (IncomingMailConfigCard, self-contained
  load/save).
- A "Received emails" tab next to "Sent emails" (ReceivedEmailsPanel) listing
  the received_emails log with from/subject/received/status + attachment count
  and a link to the incoming-invoices inbox.
- `incomingMail` flag in the frontend (type + context default, standalone) +
  a Communication-section Features card.
- email.service: getIncomingConfig / updateIncomingConfig / listReceived.
- i18n: settings.features.incomingMail, email.incoming, email.received (EN+DE).

Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
2026-06-11 16:19:55 +02:00
Luca 2b7495e4dc feat(accounting): Accounting settings tab (km / per-diem rate, require-proof)
New Settings -> Accounting tab (gated by the accounting flag) to edit the km
rate, per-diem rate and the "require proof for expense" toggle (reads GET /
writes PUT /admin/settings/accounting). Rates are CHF, stored as integer minor
units; carries the "verify with your Treuhaender" disclaimer. Wired into
SettingsPage (TabType, keys, flag-gated nav item, render) + the features barrel.

i18n: settings.accounting.* (EN + DE, DE native).
Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
2026-06-11 12:59:36 +02:00
Luca c59df52d40 feat(accounting): split Incoming invoices vs Expenses - flags, schema, settings (stage 1)
Foundation for separating external supplier invoices from internal expenses,
per design review. This stage is additive + buildable; the service/route/UI
data rework follows in stage 2.

- Migration 126: incoming invoices own their payable on inbound_documents
  (supplier_paid/at/method/ref + disposition + tax_treatment + booking event_id
  + category_id + re-bill markup/linkage); expenses gain kind (amount/mileage/
  per_diem) + quantity + snapshotted rate_minor. Additive, hasColumn-guarded.
- Migration 127: seed `expenses` feature flag (default off) + accounting
  app_settings (accounting_km_rate_minor=70, accounting_per_diem_rate_minor=0,
  accounting_require_proof=false).
- Backend: `expenses` added to feature-flag known/defaults/dependency (forced
  off when the accounting master is off); new PUT /admin/settings/accounting
  (read via the generic GET /:type).
- Frontend: `expenses` flag (type + context + dependency); Features tab gets an
  Expenses sub-card; the Expenses sub-nav + route now gate on `expenses` (not
  incomingInvoices); AccountingIndex prefers inbox -> expenses -> tax.
- i18n: settings.features.expenses.* (EN + DE).

Verified: node -c; migration 124->126->127 harness (new columns, flag, settings
+ idempotency); en/de JSON valid; npm run build green.
2026-06-11 12:24:53 +02:00
Luca 2c351bf0c9 refactor(accounting): make Accounting a master flag with sub-toggles
Replaces the earlier peer-`accounting` flag (which only *conditionally*
relocated Tax) with a cleaner top-level master + sub-toggle model, per design
discussion:

- `accounting` = explicit top-level MASTER (Settings -> Features). Off hides
  the whole Accounting section.
- Sub-toggles, gated under the master:
  - `taxReport` ("Tax export") moves PERMANENTLY out of CRM. Removed from the
    Clients sub-nav and from the derived `clients` flag. Now INDEPENDENT of
    Bills (per decision). Old /admin/clients/tax-report -> redirect to
    /admin/accounting/tax-report.
  - `incomingInvoices` (new) gates the supplier-invoice capture / expenses /
    re-bill feature; the /api/admin/expenses router now checks it.
- Dependency rules (backend + frontend): accounting off forces taxReport +
  incomingInvoices off; taxReport dropped from the clients derivation; the
  bills->taxReport rule removed.
- Preserve visuals: migration 122 rewritten to auto-enable `accounting` on
  installs that already had Tax on (so the tab doesn't vanish), and to seed
  `incomingInvoices` off. Verified with a SQLite harness (taxReport on ->
  accounting on; off -> off).
- Settings -> Features: new "Accounting" section with the master card + Tax
  export + Incoming invoices sub-cards (disabled until the master is on).
- i18n: navigation.accounting, accounting.*, settings.features.{accounting,
  incomingInvoices,taxReport.requiresAccounting}, sections.accounting (EN + DE,
  DE authored natively); Tax report relabelled "Tax export"/"Steuerexport".

Verified: node -c, migration-122 harness, en/de JSON valid, npm run build green.
2026-06-11 00:17:44 +02:00
Luca 30c0007f40 feat(accounting): Accounting nav section + relocate Tax report out of CRM
Adds the `accounting` feature flag to the frontend (type, context default) and
a Settings -> Features toggle card. When enabled:

- A new top-level "Accounting" sidebar entry appears (gated by `accounting` +
  accounting.view), with an AccountingLayout sub-nav mirroring ClientsLayout.
- The Tax report relocates: it is HIDDEN from the CRM (Clients) sub-nav and
  shown under Accounting instead, at /admin/accounting/tax-report. When
  accounting is OFF, Tax stays under CRM exactly as before.

Tax visibility still depends on `taxReport` (which depends on `bills`), so the
relocation only changes WHERE the menu item lives, not whether it exists.

Files: featureFlags.service.ts (+'accounting'), FeatureFlagsContext default,
AdminSidebar entry, new AccountingLayout, ClientsLayout filter, App.tsx route,
FeaturesTab card, en/de i18n (navigation.accounting, accounting.*,
settings.features.accounting; DE authored natively).

Verified: `npm run build` green; en/de JSON valid.
2026-06-11 00:04:40 +02:00
Luca 1bf0b34ea5 feat(projects): gate Project Overview behind a projects feature flag + cockpit email actions
- Migration 120 seeds the projects flag (default OFF), idempotent.
- Backend feature-flags whitelist + DEFAULT_FLAGS + clients derivation.
- adminProjects routes 403 PROJECTS_DISABLED when the flag is off.
- projectService email actions (resend/cancel/retry/send-now) + routes.
- Frontend flag type, DEFAULT_FLAGS, Features tab card (en+de).
2026-06-06 12:59:57 +02:00
Luca b064aab8ef feat(crm): unlock reminderEmails feature flag in Features tab
The full reminder-email implementation (eventReminderService,
eventReminderTemplates self-heal, ReminderTemplatesPage,
EventReminderOverrideCard) shipped in the CRM bundle but the
FeaturesTab card kept lockedReason=NOT_YET_AVAILABLE — so the
working feature was invisible.

Flip the card to the same shape as customerPortal: status="beta",
real setFlag handler, no disabled/lockedReason. The sub-tab in
Settings → Reminder templates already self-mounts when the flag
is on, and the per-event override card already self-renders on
the event detail page.

Description copy + EN/DE i18n updated to describe what the feature
actually does (per-category pre-event nudge) instead of the old
"coming soon" placeholder.
2026-05-26 20:31:14 +02:00
Luca a7e16e7bf6 feat(crm): frontend code — pages + services + components
Brings in the full frontend CRM stack: admin authoring pages,
customer-portal surfaces, public response flows, typed services,
and the supporting component library. i18n locale JSON is the next
commit (kept separate so reviewers can read it as data).

Pages
  - Quotes: list / editor / detail / public accept-decline
  - Invoices: list / editor / detail / public payment-check
  - Contracts: list / editor / detail / block library / public sign
  - Calendar (FullCalendar — admin-only v1)
  - Tax report (period picker + CSV/PDF export)
  - Hours (logged time entries, per-customer)
  - Deals lineage (DocumentLineageCard surfaces)
  - CRM Development (admin dev tools, gated by crmDevelopment flag)
  - Customer-portal pages for quotes / invoices / contracts
  - Settings reorg: CRM-Settings group + dedicated tabs for Business
    Profile, CRM behaviour, Contracts block library, Reminder emails
  - BrandingPage typography (PDF font picker)
  - EventDetailsPage / CustomerDetailPage / CreateEventPage extensions
    (event-time fields, hours toggle, per-event reminder override)

Services (typed)
  - quotes.service, bills.service, contracts.service
  - customerAdmin.service, deals.service, calendar.service,
    taxReport.service, contracts-blocks.service
  - businessProfile.service (timezone, font picker, bank accounts)
  - useInstallmentDefaults hook, useLocalizedDate dateInputLang extension

Components (admin)
  - CustomerPicker (shared across quote/invoice/contract editors)
  - LineItemsTable (hierarchy + details_text, memoised pricing)
  - InstallmentsPanel (simple + advanced toggle, fixed-date vs trigger)
  - DocumentLineageCard (deal_uuid grouped view)
  - EditInstallmentPlanModal (atomic post-spawn plan reshape)
  - EventReminderOverrideCard, EmailTemplateEditor (tiptap),
    PdfFontPicker, IntegrityCheckCard
  - Feature-flag context + RequireFeature wrapper + AdminSidebar
    featureFlagsAny derivation + UI-hiding sweep

Build infra
  - vite.config: fullcalendar chunk carved off (~200 KB lazy-loaded)
  - frontend/package.json: tiptap, fullcalendar, signature_pad,
    react-international-phone, et al.
  - tailwind + prose styles updated for editor surfaces

3-way merge note: 1 conflict (CustomerDetailPage.tsx) hand-resolved
to keep upstream's SUPPORTED_LANGUAGES.map() data-driven pattern
over feat/crm's hardcoded option list; feat/crm's DecimalInput
import preserved alongside.
2026-05-26 18:19:32 +02:00
Paul Nothaft 3465b55abc feat(events): default Guest Feedback ON via admin setting (#520)
@Rekoo-PS asked for an admin-level switch so new events can have Guest
Feedback enabled out of the box instead of toggling it on every time.
Mirrors the existing event_default_require_password pattern (#317) —
same shape end-to-end, same set of five files.

- publicSettings.js: whitelist + expose event_default_feedback_enabled
  (defaults to false to match the prior hard-coded form default; no
  behaviour change for existing installs until an admin flips it).
- adminEvents.js: rename `feedback_enabled = false` destructure to
  `feedback_enabled: feedbackEnabledInput` so we can distinguish
  "omitted" from "explicit false", then resolve the default from the
  setting only when the caller omitted it — identical to the
  require_password handling a few lines above.
- Frontend EventSettings type + state + loader: new boolean,
  default false.
- EventsTab: toggle UI right under "Require password by default".
- CreateEventPage: one-shot useEffect that seeds
  feedback_settings.feedback_enabled from the public setting on first
  load (mirrors the require_password seed effect right above it).
  Sub-toggles (likes / ratings / comments) keep their hard-coded
  true defaults so flipping the master setting immediately gives
  sensible behaviour without a second admin setting to manage.

Refs: #520
2026-05-18 21:26:45 +02:00
Paul Nothaft 482e91bbf8 Merge pull request #501 from filpgame/fix/settings-page-language-reset
fix(i18n): settings page resets UI language to server default
2026-05-17 01:19:27 +02:00
Paul Nothaft 98f3c3df41 fix(upload): restore configurable batch-size for reverse proxies (#509)
Regression of #208. PR #214 (commit 02a46e0, re-merged at 9b7495e)
shipped the configurable `general_max_upload_batch_size_mb` setting so
users behind Cloudflare Tunnel and other reverse proxies with
per-request size caps could lower the chunked-upload size below their
proxy's limit. Six days later the "Merge main into beta for
release/beta-to-main" commit (28793bb) resolved its conflict by
keeping main's older tree — which silently deleted the migration
(072), the setting input on Settings → General, the i18n strings, the
`useSettingsState` field, and the read in PhotoUpload.tsx, putting the
hardcoded 500MB chunk back. Galleries fronted by Cloudflare have
quietly been broken on batch uploads since then.

Re-applying exactly the same change set:

- `backend/migrations/core/072_add_max_upload_batch_size.js`
  recreated, with a comment pointing at the regression in case the
  same merge accident happens again.
- `frontend/src/components/admin/PhotoUpload.tsx` line 168 now reads
  the setting from query cache and falls back to 95MB (Cloudflare-safe
  headroom under 100MB).
- `useSettingsState.ts`, `GeneralTab.tsx`, `en.json`, `de.json` —
  added the field to the state type + defaults + load path + the
  Site-Configuration input.

Existing installs are safe either way:
- Ran original 072 then lost the file: migrations table still has the
  filename, so the runner skips re-applying. The setting row in
  `app_settings` is also untouched (the deletion was source-only, no
  down migration ran). Now the new code starts reading it again.
- Installed after the regression: migrations runner picks up the new
  072 normally and seeds the setting at 95.
2026-05-17 00:42:22 +02:00
filpgame 165ebce8d1 fix: settings page resets UI language to server default
When navigating to the Settings page, useSettingsState called
i18n.changeLanguage() with the server-stored general_default_language
value on every settings query resolution. This caused the admin UI
language to reset to the server default (e.g. "en") regardless of the
language the user had selected via the LanguageSelector.

The general_default_language setting is intended as the default for
public galleries, not for controlling the admin UI language. The admin
UI language is already persisted via localStorage through
i18next-browser-languagedetector and should not be overridden by server
settings.

Remove the i18n.changeLanguage() call from the useEffect that
initialises settings state from the API response.
2026-05-16 01:14:57 -03:00
Paul Nothaft 7eeef2ba98 feat(downloads): preserve original camera filenames on download (opt-in) (#493)
New Settings → General toggle `Use original filenames on download` (off by
default). When on, single-photo downloads, bulk/selection zips, and per-event
archive zips surface `photos.original_filename` instead of the sanitized
storage filename. Storage paths are unchanged.

- Content-Disposition uses RFC 5987 (`filename=` ASCII + `filename*=UTF-8''…`)
  so unicode camera filenames survive while header-injection bytes are stripped.
- Zip entries are deduplicated with a deterministic `_1` / `_2` suffix on
  collision (folder structure preserved in archive zips).
- Pre-generated download-all zips and the in-memory setting cache are
  invalidated when the toggle flips so the next download rebuilds with the
  new names.
- Falls back to the storage filename whenever `original_filename` is null
  (legacy uploads predating migration 062).
2026-05-14 23:11:00 +02:00
Paul Nothaft 61f1d13210 feat(lightbox): medium-resolution preview tier (#492)
Adds an opt-in lightbox preview tier so guests open photos against an
aspect-preserved ~1920px JPEG (~200–500 KB) instead of the full original
(often 5–12 MB). Originals are still served on Download.

Backend:
  - imageProcessor: generatePreviewImage / isPreviewValid / ensurePreviewImage
    using fit:'inside' + withoutEnlargement (longEdge 1920, q85, mozjpeg)
  - migration 104: photos.preview_path + lightbox_preview_enabled setting
    (off by default, JSON-stringified for SQLite/Postgres parity)
  - GET /api/gallery/:slug/preview/:photoId — gallery-auth, lazy generation,
    ETag based on mtime+photoId+watermarkHash
  - preview_url surfaced in the photo response only when the toggle is on
  - admin /thumbnails/regenerate-previews mirrors regenerate-thumbnails,
    skipping videos
  - backup walk + archive cleanup + photo-delete now include previews/

Frontend:
  - PhotoLightbox uses photo.preview_url ?? photo.url (null-safe fallback)
  - ThumbnailsTab gets a Lightbox Preview Tier card: opt-in toggle +
    Regenerate All Previews button (gated until the toggle is on)
  - en/de locale strings; nl/pt/ru/fr fall back to en

Tested end-to-end: 11 MB / 4000×3000 source → 985 KB / 1920×1440 preview,
381 ms first call, 7 ms cached, ~91% byte reduction.
2026-05-14 22:30:39 +02:00
Paul Nothaft dec2f5d3d2 fix(features): customer-portal card uses 'Clients' to match sidebar wording
Settings → Features showed the customer-portal toggle as "Accounts"
("Konten" in DE, "Comptes" in FR, etc.) — the deeper sub-nav label
inside ClientsLayout — while the prominent menu-bar entry the admin
actually clicks first reads "Clients" / "Kunden". The mismatch was
confusing on first encounter ("which one do I look for?").

Align the Features tab card title and the "Sidebar:" callout with
the menu-bar wording (`navigation.clients`) across all six locales.
The sub-nav inside ClientsLayout keeps its own "Accounts" label —
that one matches the /admin/clients/accounts URL and is correct.
2026-05-11 21:52:42 +02:00
Luca 9091ed4012 feat(clients): scaffold top-level Clients section with sub-nav around Accounts 2026-05-11 16:17:14 +02:00
Luca bf7ef14626 fix(settings): readable contrast on accent-tinted icon tiles + pills 2026-05-11 11:27:16 +02:00
Luca 15d01f3756 fix(features-tab): icon tiles + preview pills follow CI accent 2026-05-11 11:07:55 +02:00
Paul Nothaft 936a277eb8 fix(import): capture photo dimensions in fileWatcher + s3AutoImporter (#447)
The aspect-aware gallery layouts (masonry / mosaic / justified) read
photo.width and photo.height to size each card to the source's real
proportions. Two import paths were inserting rows without those
fields, which forced MasonryGalleryLayout to fall back to a hard-coded
800×600 default — every card came out the same shape, so users
reported masonry as "always cropped to 1:1ish" no matter which
thumbnail fit mode they chose.

- fileWatcher.js: extract dims with sharp.metadata() before insert.
- s3AutoImporter.js: same, materialising a tmp local copy via
  withLocalCopy so it works in S3 mode.
- migration 090: backfill any pre-existing rows with NULL dims
  (skips videos, skips S3 deployments — those need the writer fix
  alone since migrations cannot reach the storage backend).
- imageProcessor.js: change DEFAULT_THUMBNAIL_FIT from 'cover' to
  'inside' (only kicks in when the seed setting is missing — existing
  installs keep their saved value). Add UI tooltip recommending
  'inside' for masonry/mosaic/justified, 'cover' for uniform grids.

i18n covers all six locales.
2026-05-11 09:58:08 +02:00
LucaandClaude Opus 4.6 087ef45942 feat(customers): customer portal (#354) on top of feature-flags reorg
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]>
2026-05-11 00:05:20 +02:00
Paul Nothaft 15e333681f feat(settings): Features tab + sidebar reorg with feature-flag gating
Reorganises the admin sidebar around what users actually do, and adds a
single Features page that gates which feature surfaces appear in the
nav. Shrinks the main sidebar from 11 items to 4-6 (depending on
feature flags) and groups configuration screens into a single Settings
home with six logical sections.

Why
---
The current sidebar mixes three concerns: workspaces (Dashboard, Events,
Archives), feature surfaces (Analytics, Users), and configuration
screens that get touched maybe once a month (Email Settings, Branding,
Event Types, Backup, CMS Pages). That's 11 items, half of them config.

Backend
-------
- New `feature_flags` table (key, value, updated_at, updated_by).
  Migration 088 detects existing-vs-fresh installs from the events
  table:
    * Existing install (events>0)  → all 9 flags TRUE so nothing
      vanishes from an admin's UI on upgrade.
    * Fresh install      (events=0) → spec defaults: galleries,
      reminderEmails, analytics, userManagement TRUE; calendar,
      calendarBooking, quotes, bills, messaging FALSE.

- New `/api/admin/feature-flags` (GET/PUT) under `settings.view` and
  `settings.edit`. Server enforces the same dependency rules the
  frontend does (galleries always TRUE, quotes=false → bills=false,
  calendar=false → calendarBooking=false). PUT writes one
  `feature_flags_updated` activity log row with the diff.

Frontend
--------
- `FeatureFlagsContext` provides `useFeatureFlags()` (with staged/save/
  reset/isDirty) and `useFeatureEnabled(key)`. Mounted inside
  AdminLayout so flag fetches carry the auth cookie. Source of truth
  is the server response; staged is a local copy that the Features tab
  edits and the Save button PUTs.

- `RequireFeature` route guard for /admin/analytics and /admin/users —
  redirects to /admin/dashboard when the corresponding flag is OFF.

- AdminSidebar dropped from 11 to 6 items. Removed: Email Settings,
  Branding, Event Types, Backup, CMS Pages (now Settings tabs).
  Feature-gated: Analytics, Users.

- Old top-level routes (/admin/email, /admin/branding, /admin/event-
  types, /admin/backup, /admin/cms) kept as <Navigate> redirects to
  /admin/settings?tab=<key> so existing bookmarks don't 404.

- SettingsPage rewritten with a 6-group inner-nav (General /
  Content & Appearance / Communication / Privacy & Security /
  Integrations / System) and 19 tabs. New Features tab is the
  default landing tab. URL ?tab=<key> roundtrips with state — deep
  links and the back button work.

- FeaturesTab renders 9 cards across 5 sections. Toggles enabled for
  Analytics + User Management (the two flags that gate sidebar items
  in this PR). All other toggles disabled with a "Not yet available"
  lockedReason — the cards still render so admins see the roadmap, but
  the flag has no UI effect until the surface ships in its own PR. The
  galleries card is locked TRUE per spec (foundation, can't be off).

- Live SidebarPreview reflects unsaved staged changes — admins see
  what their sidebar will look like before they save.

- New i18n keys across all 5 locales (en, de, nl, pt, ru) for the
  Features tab copy, the new Settings group labels, and the lifted
  tab titles.

Verified end-to-end
-------------------
- Migration on this dev DB (existing install, 977 events): all 9 flags
  set to TRUE.
- Migration on simulated fresh install (events table emptied): spec
  defaults applied (5 OFF, 4 ON).
- Backend round-trip: GET → PUT → audit-log entry written, dependency
  rule enforced (bills forced false when quotes=false even when bills=
  true requested).
- UI Playwright spec: sidebar dropped 5 items, old top-level routes
  redirect, Features tab is default, Galleries+Calendar+Quotes+Bills+
  Messaging+ReminderEmails toggles disabled, Analytics+Users toggles
  enabled, toggling Analytics off + saving updates the sidebar +
  redirects /admin/analytics to /admin/dashboard.
- Smoke 13/13 still green; no regressions on existing flows.
2026-05-10 20:36:32 +02:00
PiR1 5fc427c74b feat(localization): update thumbnail settings and add fit options translations 2026-05-09 18:42:15 +02:00