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
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)
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)
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)
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`)
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.
- #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).
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.
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.)
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.
- 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.
- 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).
- 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.
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).
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).
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.
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.
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.
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).
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
The Date column imported empty into Banana because dateOnly() did
String(d).slice(0,10) — on Postgres the date columns come back as JS Date
objects, so that yields "Thu Jan 15" instead of "2026-01-15", which Banana
rejects. (SQLite returns strings, so the tests never caught it — the
pg-date-serialisation trap.)
- ledgerService.dateOnly + taxReportService CSV now format Date objects to
yyyy-mm-dd via local calendar parts (DATE columns are local-midnight).
- Regression test added with a real Date object (the existing tests all used
string dates).
The Banana export assumed a double-entry file; a user importing into an Income
& Expense (Einnahmen-Ausgaben) file got "AccountDebit/AccountCredit/Amount/
VatCode column not found", since those columns only exist in double-entry.
Add a second Banana format alongside the double-entry one:
- ledgerService: new `banana_ie` format → Banana I&E columns Date, Doc,
Description, Income, Expenses, ContraAccount (the income/expense account),
VatCode (banana.ch doc 9946). Revenue → gross in Income + revenue account;
cost → gross in Expenses + expense account. Same tab-separated .txt shape.
- Frontend: ExportFormat + dropdown gain `banana_ie`; .txt extension covers
both Banana variants. Labels relabelled: "Banana — double-entry" and
"Banana — income & expense" (de equivalents). Hint de-"double-entry"-fied.
- Test added for the I&E format.
Pairs with the prior UTF-8 BOM fix (the "·" mojibake). Tests + build green.
The /ledger/export route sent the file without a BOM, so Banana (and Excel)
decoded it as the local charset — the '·' description separator and any umlauts
imported as mojibake ('·'). Prepend the EF BB BF BOM like the tax-report CSV
route already does.
Banana's "Text file with column headers" import (Actions → Import into
accounting) requires a TAB-separated .txt with unquoted values — picpeak was
emitting a comma-separated, quoted .csv, which won't even show in Banana's
*.txt file picker, let alone parse into columns.
- ledgerService.exportPostings: the `banana` format now serialises TAB-separated
with no quoting, .txt extension, text/plain content-type. generic + bexio stay
comma-CSV (RFC 4180). Tab/newline chars in a cell are collapsed to spaces.
- Frontend ledger.service: download filename uses .txt for banana.
- Tests updated for the new banana shape (tab header, .txt, text/plain).
The column names already matched Banana's NameXml; only the serialisation was
wrong. bexio left as comma-CSV (verify against bexio's import spec separately).
- Restructure the export area into two labelled groups: 'Report' (PDF/CSV,
for you) and 'Accounting journal' (for your accountant), each with a
one-line caption — instead of two unlabelled button rows.
- i18n: the English label was the German 'Treuhänder export' → now 'Accountant
export' (de stays 'Treuhänder-Export'); hint reworded.
- Feature flags: the journal export is an accounting-layer feature (needs the
Chart-of-accounts mapping), so gate it on the 'accounting' master — the
group only renders when accounting is on, and the backend /export route no
longer requires the 'taxReport' sub-flag (the router already requires accounting).
Build + node --check + JSON parse green.
Replaces the separate revenue + costs tables with a single ledger across the
screen, CSV and PDF. Every row is typed (outgoing invoice / incoming invoice /
expense) and signed — outgoing positive, incoming + expenses negative — so
sorting by value runs income → costs and the column nets toward the Result.
- getTaxReport now returns a `ledger` array (signed, typed, date-sorted);
legacy rows/costs/summary kept for back-compat.
- Frontend: one sortable table (click Type/Date/Party/Net/VAT/Gross), coloured
type badges, cancelled rows greyed with lineage badges; Income/Costs/Result
summary box unchanged.
- CSV + PDF reworked to the same unified, signed layout; PDF totals show
Income / Costs (negative) / Result.
- i18n: en/de (frontend) + pdf-i18n (en/de real; fr/nl/pt/ru English-fallback,
flagged for native review).
Build + node --check + JSON parse green.
The tax-report cost query selected inbound_documents.description, but that
column only exists on the 'expenses' table — inbound_documents has none. On
Postgres this threw 'column inbound_documents.description does not exist',
so the whole cost side failed with 'Costs could not be loaded'.
Use inbound_documents.invoice_number (an existing column, same descriptor
ledgerService surfaces) as the cost-row label instead. Expense rows still
use their real expenses.description column.
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.
Turning on maintenance mode locked out every admin — including ones already
logged in — with no way back in from the browser. Two causes:
1. Backend (middleware/maintenance.js): the skipPaths allow-list pointed at
/api/admin/login and /api/admin/auth/login, but the real admin auth routes
live under /api/auth (POST /api/auth/admin/login, GET /api/auth/session).
So during maintenance both the login POST and the session check 503'd. The
503 on /auth/session made the frontend read every admin as logged-out, and
also tripped the axios interceptor that force-enables maintenance globally.
Fixed the allow-list to the actual endpoints.
2. Frontend (MaintenanceWrapper.tsx): the maintenance screen rendered over
every /admin/* route unless an admin session already existed — covering the
/admin/login page itself. A logged-out admin could never reach the form to
get a session (catch-22). /admin/login is now always allowed through.
With both: a logged-in admin keeps working (session check passes), and a
logged-out admin can reach /admin/login and sign back in, all while
maintenance mode correctly blocks customers.
Single-customer projects, but content is addable whenever ONE of its customers
is the project's customer (not only when the first lineage customer equals it):
- linkDealToProject: collect ALL customers across the deal's quote/contract/
invoice lineage and reject only when none is the project's customer. Mirrors
the events path, where a multi-customer event already attaches if any of its
customers matches. Adoption onto an empty project unchanged.
- assignDocument: a document carries one customer, so equality stays correct;
message aligned with the lineage check.
A project must stay tied to one customer. The quote/contract/hours attach
paths already rejected a foreign customer (equality on project.customer_account_id);
the two remaining holes are closed here:
- assignEvent: an event may only join a project that shares its customer. The
event's customer(s) come from event_customer_assignments; a customer-assigned
project rejects an event for a different customer (PROJECT_CUSTOMER_MISMATCH),
and an empty project ADOPTS a single-customer event's customer. This is why
a foreign-customer event could previously be attached.
- updateProject: re-labelling a project to a customer that conflicts with the
events/quotes/contracts it already holds is rejected (clearing to null is
still allowed), so the customer can't be swapped out from under existing
content.
Frontend: the cockpit attach-event action surfaces the translated mismatch
message; projects.error.customerMismatch reworded to read for both documents
and events (de + en).
Resolves the two blockers and the actionable concerns/nits from review.
Blockers (cross-customer leak):
- linkDealToProject: collect the deal's customer + events BEFORE any write,
then reject a cross-customer link with PROJECT_CUSTOMER_MISMATCH (422) before
re-pointing events/quotes/contracts or adopting a customer. The editors set
project_id via quoteService/contractService → linkDealToProject (not
assignDocument), so the guard lives at that chokepoint. Null-project adoption
("first deal wins") preserved as intended.
- assignDocument: boundary guard mirroring customerHoursService, defense-in-depth
ahead of the cascade.
- Frontend: translated PROJECT_CUSTOMER_MISMATCH (projects.error.customerMismatch,
de+en) wired into HoursSection + quote/contract editor onError (concern 5).
Concerns:
- 1: processEmailQueue gains an onlyId option; cockpit "send now" scopes the
flush to the single row so it can't force-retry other dead-lettered emails.
- 2: resendEmail re-stringifies email_data when PG returns a parsed object,
matching the canonical enqueue — no jsonb double-encode.
- 3: cockpit email feed scoped to the project's own document numbers (event_id
for gallery mails; email_data doc-number match for CRM mails) instead of the
recipient string — a shared inbox no longer leaks another customer's mail.
- 4: migration 117 backfill wrapped in a transaction (adds atomicity on SQLite,
where the runner does not wrap; PG already wraps the whole migration).
- 6: resend/cancel/retry/sendNow now logActivity uniformly (project_email_*),
adminId threaded from the route.
- 8: validator optional({ values: 'null' }) → optional({ nullable: true }).
- 9: pre-121 list valuation falls back to customer-scoped quotes so the list
isn't all-zero during the upgrade window.
Nits:
- milestone selection uses Array.at(-1); removed redundant in-loop require in
emailProcessor; clarifying comments for the list/detail perms split and the
count-vs-value (0 vs em-dash) convention.
#1 Triage 'Save & mark paid' now marks the invoice paid directly (categorize +
markInboundPaid with the entered reference) instead of opening the pay dialog
and leaving it unpaid. Removed the PayModal chain.
#2 Cost side missed captured incoming invoices: the query required
currency='CHF', but email/upload invoices often have a null currency →
silently excluded. Now include null-currency rows (treated as the report
currency). Also replaced COALESCE(invoice_date, created_at) with a split
date filter (invoice_date BETWEEN, else created_at range) to avoid the
mixed date/timestamp comparison risk on Postgres. Same fix in the ledger
export (buildPostings).
en/de: categorizedPaidToast.
The cost side is supplementary — it must never 500 the core revenue report.
getTaxReport now wraps loadCosts in try/catch: on failure it returns empty
costs + a costsError string and logs the real error. The tax page shows the
revenue report plus a non-fatal amber banner with the cost-side error message,
so the actual cause is visible in the UI instead of an opaque 500.
The #4 cost side used 'date(COALESCE(invoice_date, created_at)) BETWEEN ...'
and 'date(created_at) BETWEEN ...'. The mocked unit tests never execute the
SQL, so the Postgres failure (date()/COALESCE(date,timestamp)) slipped through
and surfaced as a 500 on the live tax report. Replaced with plain range
comparisons (col >= from AND col <= '<to> 23:59:59.999') — valid on both PG and
SQLite, inclusive of the whole end day. Same fix applied to ledgerService
buildPostings (the Treuhänder export would have 500'd identically).
Cause of 'not all received emails listed': the poller fetched {seen:false}
only, so any message already read in another client was never pulled or logged.
Now the poller scans a LOOKBACK_DAYS (90) window regardless of \Seen via a
cheap envelope-only pass, dedups by message-id against received_emails, and only
downloads + processes (fetchOne source) messages not yet logged — so the
Received tab is complete while each poll stays light. Marks processed messages
seen; re-checks the parsed message-id before insert.