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.
Zszywany reported on v3.44.0 (Ubuntu, Postgres 15): with Settings →
General → "Max Files per Upload" set to 10, an event configured with
allow_user_uploads + a guest selecting 16 files in the gallery's
"Upload Photos" modal succeeded silently — admin uploads to the same
event correctly refused with "Upload limit reached". On top of that,
the gallery modal's "fileRequirements" hint literally rendered
`{{limit}}` instead of the configured number.
Two separate misses for the guest path, both fixed here:
1. **Backend enforcement** — `backend/src/routes/gallery.js:1641` had
`limits: { fileSize: 50MB, files: 10 }` and `.array('photos', 10)`
hardcoded. The admin path at adminPhotos.js:131 has always resolved
files-per-batch via `getMaxFilesPerUpload()` (cached 60s read of
`general_max_files_per_upload`); guest path just never used it.
Mirror the admin: `const maxFilesPerUpload = await getMaxFilesPerUpload()`
and feed multer both `limits.files` AND the `.array(...)` cap. The
50MB per-file size is a separate concern from this issue and stays
as-is for now.
2. **i18n interpolation missing on the guest modal** —
`UserPhotoUpload.tsx:203` called `t('upload.fileRequirements')` with
no arguments. The translation string at `en.json:160` is
"JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)"
— `{{limit}}` is unbound, so i18next emits it literally. The admin
variant `PhotoUpload.tsx:414` correctly passes
`{ limit: maxFilesPerUpload }`.
Also wired up the same client-side count guard the admin component
uses: addFiles refuses additions past the limit (`upload.limitReached`)
and warns on partial-truncate (`upload.someFilesSkipped`). Backend
enforces too, but the client guard saves a 4MB+ multipart POST when
the user is clearly over.
To surface the setting on the guest side, `general_max_files_per_upload`
joins the public-settings whitelist + projection (publicSettings.js)
and the `PublicSettings` TS interface gets the new field. Default
fallback (500, matching `uploadSettings.js` DEFAULT_MAX_FILES_PER_UPLOAD)
in both backend projection and frontend reader so an install that's
never set the value renders a sensible number rather than "undefined".
jodrmx reported on v3.44.0 (Pi Lite, Docker compose): admin-UI event
delete removes the DB row but leaves `storage/events/active/<event>/`
intact on disk.
Root cause: `deleteEventCascade` in adminEvents.js read
`event.folder_path` and gated the `fs.rm` on it. That column is NEVER
WRITTEN anywhere in the codebase — grep confirms two reads in this one
function, zero writes elsewhere. So `event.folder_path` was always
undefined, `if (event.folder_path)` always false, and the per-folder
cleanup silently no-op'd for every delete. The DB-cascade transaction
ran fine, so the symptom was always "row gone, files stay" — exactly
what jodrmx hit.
The actual on-disk location is `events/active/{slug}` everywhere else
in the codebase:
- adminPhotos.js:260 — `path.posix.join('events/active', event.slug)`
- adminEvents.js:610, events.js:155, adminThumbnails.js:153 — read
from `events/active/{slug}`
- adminArchives.js:171 — reads from same root
- photoResolver.js:14-15 — documents the layout
The delete cascade was the only path looking at the non-existent column.
Cure: drop the `if (event.folder_path)` guard, read `event.slug`
instead, and remove from both `events/active/{slug}` (active gallery
folder) and `events/archived/{slug}` (the post-archive copy that
survives the archive flow). `event.slug` is NOT NULL and slugify-
sanitized (lower-case ASCII + dashes only via utils/slug.js), so the
path is well-formed and path-traversal-safe. Best-effort `fs.rm`
semantics + try/catch unchanged — failures still log a warning rather
than unwinding the DB transaction, since orphan files are recoverable
noise compared to a half-deleted DB row.
Forward fix only — does not retroactively clean up the orphans that
have accumulated on existing installs. Admins can `rm -rf
storage/events/active/<old-slug>` manually for those; not worth a
migration script for a one-time deploy ritual.
patchingfailed reported on v3.44 stable: a gallery named `Ägypten` with
photo `Ägypten_individual_0050.jpg` downloads as `gypten_...` —
the leading umlaut is dropped entirely. Their hypothesis was a
Content-Disposition encoding issue, but the actual root cause sits
one layer earlier: at UPLOAD time when `generatePhotoFilename` calls
`sanitizeFilename`.
`sanitizeFilename` did:
String(str).trim()
.replace(/\s+/g, '_')
.replace(/[^a-zA-Z0-9_\-\.]/g, '') // ← drops `Ä` outright
.replace(/[_\-]{2,}/g, '_')
.replace(/^[_\-]+|[_\-]+$/g, ''); // ← would strip a leading _ too
For `Ägypten`: alphanumeric-strip → `gypten` (Ä gone, no underscore
left behind because the regex used '' as the replacement, not '_'). The
result is stored in `photos.filename` and that's what downloads serve.
By that point `buildContentDisposition` is doing the right thing
(emits both `filename="..."` ASCII fallback AND RFC 5987
`filename*=UTF-8''…` — Chrome correctly picks the UTF-8 form), but the
string it's encoding has already lost the umlaut at the DB layer.
Cure: NFD-normalize + strip combining marks BEFORE the alphanumeric
strip. Same pipeline `utils/slug.js` (#525) already uses for URL
slugs:
sanitized = sanitized
.normalize('NFD')
.replace(/[̀-ͯ]/g, '');
Now `Ägypten` → NFD-decomposed `A` + combining diaeresis → strip
combining mark → `Agypten` survives the alphanumeric pass. Filename
and URL slug stay in sync (the URL was already `Agypten`, per
patchingfailed's report — the filename now matches).
Test surface: new `filenameSanitizer.test.js` pins:
- the headline #607 contract for German / Portuguese / French / Spanish
accented inputs (with a counter-example using the pre-fix pipeline so
a future edit can't quietly regress it)
- ASCII-input parity — pre-#607 byte-identical output for every
pre-existing ASCII case
- `generatePhotoFilename` composed round-trip
- `sanitizeForContentDisposition` + `buildContentDisposition` RFC 6266
dual-form output (since the helper sits next to this function and is
the next thing to break if a refactor goes sideways)
- `sanitizeForZipEntry` path-traversal blocking
31 cases total, all pass.
Bundled into PR #609 since it's a small targeted fix and that PR is
already an admin-UI polish branch with low review weight.
Two issues Rekoo-PS hit immediately after upgrading to v3.60.3-beta.0:
1. **Broken logo URL rendered the browser's broken-image icon + alt
text.** Their `<img src={resolvedLogoUrl}>` had no `onError` handler,
so a 404 / slow logo URL produced the default broken-image rendering
— which uses the `alt` attribute (`companyName`) as text. Visually it
looked like the wordmark span had unexpectedly re-appeared on phone,
even though the actual `<span>` was correctly hidden by the existing
`wordmarkVisibilityClass` logic.
Fix:
- `useState` tracks `logoLoadError` (first failure) and
`fallbackLoadError` (second failure). On a configured-URL miss the
`<img>` swaps to the bundled `/picpeak-kamera-transparent.png`; on
a second miss the `<img>` is removed from the DOM entirely.
- `useEffect([resolvedLogoUrl])` resets both flags when the URL
changes, so a dark-mode toggle that flips `lightLogo ↔ darkLogo`
gets a fresh attempt instead of being permanently sad.
- `wordmarkVisibilityClass` now derives from `logoEffectivelyVisible`
(showLogo && !fallbackLoadError) — when both the configured URL
AND the bundled fallback have failed, the wordmark un-hides on <sm
so the phone header isn't completely empty.
2. **Sidebar VersionInfo + StorageInfo vanished during the
permission-hydration window.** The bottom block was gated on
`hasPermission('settings.view')` directly, which returns `false`
while `PermissionsContext.isLoading` is still resolving (a few
hundred ms right after a deploy when the auth context bootstraps).
Net effect: the whole "Version / Storage" block was absent on first
paint, then re-appeared once permissions hydrated — Rekoo-PS read
that flash as "backend version + storage missing".
Fix: gate on `permissionsLoading || hasPermission('settings.view')`.
Optimistic render during hydration; permitted users see the widgets
immediately (with each widget's own internal loading state), denied
users still see nothing once the permission state lands as `false`.
Side benefit: the `<img>` fallback chain also covers the broader "logo
hosted on a flaky CDN" case for self-hosters, not just the one-time
post-upgrade asset-cache hiccup. Pure resilience polish — no behaviour
change when everything works.
linkDealToProject(dealUuid, projectId): links every quote + contract sharing
the deal_uuid, re-points the events the deal converted into (so their
invoices/emails/gallery roll up), and adopts the deal's customer onto an
empty project. Invoked from the assign endpoints AND the quote/contract
editors' project picker (create + update). Drop a quote on an empty project
and its linked contract, event and invoices populate the cockpit automatically.
Verified on a booted DB: assignQuote on an empty project propagates project_id
to the contract + event, adopts the customer, and the overview rolls up all
four document types.
A project has at most one customer, so the hours picker shouldn't offer
other customers' projects. HoursSection now passes customerAccountId to
ProjectSelect (shows this customer's projects + still-unassigned ones).
Backend createEntry rejects a projectId owned by a different customer
(422 PROJECT_CUSTOMER_MISMATCH) as defence-in-depth behind the picker.
A long unbreakable token (e.g. the gallery link) overflowed the email
container and forced the admin to side-scroll. The preview HTML prep now
also injects overflow-wrap:break-word so long words/URLs wrap within the
container. break-word only triggers on overflow, so table layout is
unaffected. (Renamed neutralizeLinks → preparePreviewHtml.)
The project create/update/assign routes required 'events.manage', which is
not a real permission (the event perms are view/create/edit/delete/archive).
Since it's absent from the permissions table, even super_admin's all-perms
set excluded it, so every write 403'd with 'Insufficient permissions'.
Switched the write routes to the existing 'events.edit'. (Reads keep
events.view; cockpit doc gating + email actions already use real keys.)
Inside the preview iframe the Accept button navigated but other links didn't
— inconsistent, and worse, clicking Accept/Decline would hit the live action
URLs and change the quote state. Sandbox the iframe (no popups/scripts/forms)
and force all anchors to target=_blank so every link is inert. Now nothing in
the preview is clickable (consistent + safe); scrolling and brand colors are
unaffected.
The email wrapper already sets body/container/text backgrounds from the
brand email-theme settings (email_body_bg_color etc.), so a dark preview is
the configured design — forcing it light was wrong. Render the email as-is;
set the iframe color-scheme to 'normal' only so the admin's dark app theme
doesn't leak into the iframe's UA defaults. The brand's light/dark choice is
respected.
Document rows navigate on click, but email rows only had clickable action
buttons — the row itself was dead, which read as inconsistent. The whole
email row now opens the preview; the action buttons stopPropagation so
Resend/Cancel/Retry/Send-now still fire without also opening the preview.
Every actionable feed row is now uniformly clickable.
The cockpit surfaces quotes/invoices/contracts by PERMISSION, but their
detail routes are gated by feature FLAG (RequireFeature). With those flags
off, clicking a row navigated to a route that redirects to /admin/dashboard
— so links 'did nothing' while the email action buttons (plain API calls)
worked. hrefFor now returns null when the destination flag is off, so the
row renders as non-clickable text instead of a dead link. Galleries/events
are never flag-gated, so they always link.
Each email in the rollup now carries a 'stored' flag (rendered_html present).
Emails without an exact stored copy show an amber '≈ re-rendered' tag next to
Preview, so it's visible at a glance — not just inside the modal. en + de.
- The customer address often doubles as the admin notification target, so
matching emails purely by recipient swept in system alerts (backup_failed,
restore_failed, …). The recipient match is now restricted to CRM document
types (quote_/contract_/invoice_/storno_); event-scoped mails still match
by event_id.
- getEmailPreview now falls back to renderQueuedEmail() — re-rendering from
the current template + the row's stored email_data — for emails sent before
rendered_html capture, flagged exact:false with an amber 're-rendered' note.
Only a missing template / no variables falls through to 'nothing stored'.
Known limitation: a customer with multiple projects sees their event_id=null
CRM mails under each (email_queue has no project_id).
- Milestones + feed rows now link to the document (quote/contract/bill
detail, event for galleries); hours have no page so stay non-clickable.
- Email rollup also matches the project customer's address — quote/invoice/
contract mails are queued with event_id=null, so the by-event scope alone
showed none (hence 'no email preview'). Now they appear with preview.
- Feed amounts coerce total_amount_minor with Number(): Postgres returns
bigint as a string, which formatMoneyMinor's Number.isFinite check
rejected and rendered as CHF 0.00. (computeValuation already coerced.)
- computeValuation helper: per deal_uuid, the invoice total (installments
summed, storno netted) wins over the quote; contracts carry no total so
never contribute. Summed across the project's events, split by currency.
- Value column on the Project Overview list + a value/paid block in the
cockpit header. Both gated by bills.view/quotes.view so no figure leaks.
- listProjects computes all values in two bulk queries (not per-project).
- en + de i18n; six unit assertions cover the rule's edge cases.
Search any event by name and attach it to the project (re-points
events.project_id via assignEvent). Lists the project's current events
above the search. en + de i18n. Completes event grouping UX — admins
can now regroup the auto-created per-event projects however they like.
- ProjectSelect: a reusable picker that renders nothing when the projects
flag is off (satisfies 'book to project hidden unless projects enabled').
- projects.service.ts: full frontend API client (list/get/create/update,
overview, assign event/quote/contract, email preview + 4 actions).
- Quote + contract editors carry an optional projectId (state, prefill,
payload); service payload/detail types updated.
- HoursSection gains a 'book to project' control; backend createEntry
persists project_id (migration 118, hasColumnCached guarded).
- Migration 121 adds quotes.project_id + contracts.project_id (nullable FK,
index) and backfills the unambiguous single-project-per-customer case.
- projectService rolls quotes/contracts up by project_id, with a
customer-based fallback on pre-121 DBs (hasColumnCached guarded).
- quote/contract create+update accept an optional projectId; detail
transforms surface it for editor prefill.
- POST /projects/:id/quotes and /:id/contracts assign endpoints.
processEmailQueue now stores the actual rendered HTML in email_queue
.rendered_html on a successful send (sendTemplateEmail returns it). Guarded
by hasColumnCached so installs without migration 119 just skip it; never
blocks the send. Powers the cockpit's exact-sent email preview.
Backend API for the cockpit (admin-only, Model A):
- projectService: list/get/create/update, assignEvent (re-point events.project_id),
getProjectOverview (rollup — invoices/emails/gallery by event, quotes/contracts
by customer since they carry no event_id, hours by project_id, + a milestone
timeline), getEmailPreview (actual sent HTML).
- adminProjects routes (/api/admin/projects): read=events.view, write=events.manage;
the overview gates each money-doc type on the admin's own bills/quotes/contracts
.view permission. Registered in server.js.
All aggregation queries verified against the real schema on a temp DB.
Data model for the admin-only Project Overview cockpit (Model A — projects
group events; money docs stay per-event and roll up).
- migration 117: projects table (name, customer_account_id nullable, status)
+ events.project_id FK; backfill one auto-project per existing event (1:1
default, customer = the event's single assignment when unambiguous), admins
relink freely afterward. 1 project : N events.
- migration 118: customer_hour_entries.project_id (book hours to a project).
- migration 119: email_queue.rendered_html (store actual sent HTML for the
cockpit's email preview).
All idempotent (hasTable/hasColumn guards), reversible downs. Verified: full
migration boot + backfill on a temp DB.
From dev testing:
- BillEditor 'Geplanter Versand' was a native <input type=datetime-local> →
rendered US date + 12h regardless of settings. Split into LocalizedDateInput
+ TimeField (honour general_date_format + general_time_format), recombined
into the YYYY-MM-DDTHH:MM the payload/scheduler expect.
- InstallmentsPanel 'Send on' native <input type=date> (browser-locale via a
lang hint, wrong in Safari/Firefox) → LocalizedDateInput, consistent in every
browser. (Luca approved converting it.)
- Business-profile Timezone was a free-text input → dropdown of the full IANA
list (Intl.supportedValuesOf, CH/LI fallback), blank = system default.
- Recent Activity rendered literal {{email}} — the per-row t() call didn't
pass the email interpolation var. Source it like formatActivityMessage
(metadata.email ?? actorName).
- Customer 'Deine Galerien' dates rendered en-US ('May','Jun') under a German
UI because they used raw date-fns format(parseISO(iso),'PP') with no locale.
Route through useLocalizedDate().format → honours general_date_format + the
active language.
Addresses the maintainer's non-blocking review items + the Outlook email bug:
- invoice create: verify the chosen event belongs to the customer (only when
the event has assignments; legacy unassigned events pass through).
- mark-paid + import: bound paidAt to [2000-01-01, now+30d] so a typo'd year
can't silently drop a payment out of every cash-basis revenue window.
- customer routes: country_code now {min:2,max:2}+isAlpha+uppercase-normalize
(was isString/max:2 — allowed '', '1', '!@'), matching the business-profile
route.
- email transporter: close the previous instance before re-init (leak guard
for a future pooled transport).
- scheduled-email tz: warn loudly when business_hours is set but the profile
timezone is blank (was silently using the server/UTC tz).
- wrapEmailHtml: rebuild the chrome as inline-styled tables + bgcolor and
inline the themed CTA button, so the design survives Outlook/Apple Mail
stripping the head <style> (kept the <style> as progressive enhancement).
The /favicon.ico + /apple-touch-icon routes stream the file directly,
bypassing the secureStatic middleware that locks down served SVGs. An
admin-uploaded SVG favicon with <script> would then run at the top-level
origin (stored XSS). Re-apply the same CSP (default-src 'none') + nosniff
for .svg here, mirroring secureStatic.js. Reported in the #603 review.
Two complaints in Rekoo-PS's 3.60.1-beta.0 follow-up screenshots:
1. "Logo took some time to load" — header appeared empty for the
~hundreds-of-ms window between admin mount and `usePublicSettings()`
resolving. The previous code rendered the static fallback
`/picpeak-kamera-transparent.png` during that window, which often
either 404'd or loaded after the rest of the chrome, and because the
wordmark is `hidden sm:inline` whenever a logo is intended to be
shown, phone-width admins saw an empty left cluster instead of
anything.
Cure: render a small pulsing skeleton block (h-8 w-8 on <sm, w-32
on sm+) while `brandingLoading === true`. Same h-8 footprint as the
real logo image so there's no layout shift when the real payload
arrives. Once the public-settings query settles, the normal brand
block renders against known state.
2. "Moving the languages inside the profile tab" — Rekoo-PS argues
language is set-once and shouldn't occupy permanent header real
estate on mobile (4 widgets in the right cluster on phone is
crowded). I agree.
On <sm: header LanguageSelector is hidden (`hidden sm:block` wrapper
around the existing component). A collapsible Language section is
added at the top of the user-menu dropdown showing the current
flag/name + chevron-down. Expanding shows the 8 supported languages
as inline rows highlighting the active one. Picking a language fires
i18n.changeLanguage and closes the menu.
On sm+: header LanguageSelector stays where it was. The user-menu
Language section is suppressed (`sm:hidden`) so the same control
isn't surfaced twice.
Also: `useOnClickOutside(userMenuRef, …)` and the in-menu action
handlers now route through a shared `closeUserMenu()` helper that
also resets the lang sub-section state, so re-opening the menu
doesn't surprise the user with the language list still expanded.
`SUPPORTED_LANGUAGES` re-exported from `components/common` so
AdminHeader doesn't reach into `LanguageSelector.tsx` directly.
No behaviour change on `sm+` — pure phone-view layout fix +
loading-state polish. Locales unaffected (uses the already-existing
language names from SUPPORTED_LANGUAGES).