From dcc629cad23ce0ca89aabb6f8b1eacfde599774e Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 31 May 2026 22:35:00 +0200 Subject: [PATCH 1/9] fix(csp): external bootstrap script to survive strict reverse-proxy CSP (#564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit demo.picpeak.app sits behind Caddy + Cloudflare; Caddy replaces the nginx CSP entirely with one that omits 'unsafe-inline' / hash / nonce, so the #358 inline theme-bootstrap was being blocked there — admin loaded a black page, the SPA bundle 404'd, link buttons did nothing. Move the bootstrap to /public/bootstrap.js served as 'self' so the script runs under every reasonable CSP without further coordination. Vite copies /public/* to the dist root at build time (same pipeline as /favicon-32x32.png), and it remains in without defer/async so it still runs before paints. The OS-preference @media CSS above still handles the first-frame dark/light baseline. --- frontend/index.html | 36 ++++++++---------------------------- frontend/public/bootstrap.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 28 deletions(-) create mode 100644 frontend/public/bootstrap.js diff --git a/frontend/index.html b/frontend/index.html index 9af5be8f..0e9b724a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -39,9 +39,9 @@ +
diff --git a/frontend/public/bootstrap.js b/frontend/public/bootstrap.js new file mode 100644 index 00000000..f40e14b2 --- /dev/null +++ b/frontend/public/bootstrap.js @@ -0,0 +1,32 @@ +/* + * Pre-React theme bootstrap (#358). + * + * Loaded as an external script (rather than inline) so a strict CSP + * with no 'unsafe-inline' / hash / nonce — like the one Caddy puts in + * front of demo.picpeak.app — does not block it (#564). + * + * Reads the per-gallery cached background written by ThemeContext on + * the previous visit and applies it before React mounts, so revisits + * land on the right colour from the first frame. The OS-preference + * default is already handled by the @media CSS in index.html for + * first-visit / cache-miss callers. + * + * Placed in /public so vite copies it to /bootstrap.js at build time + * (same pipeline as /favicon-32x32.png). Kept in without + * defer/async so it runs before paints. + */ +(function () { + try { + var m = location.pathname.match(/\/gallery\/([^\/?#]+)/); + var bg = null; + if (m && m[1]) { + bg = localStorage.getItem('gallery-theme-bg-' + decodeURIComponent(m[1])); + } + if (bg) { + var root = document.documentElement; + root.style.backgroundColor = bg; + document.body && (document.body.style.backgroundColor = bg); + root.style.setProperty('--color-background', bg); + } + } catch (e) { /* never block render on a cache miss */ } +})(); From e7cf834325e8686613fbbee78d52213cb3ba98b1 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 31 May 2026 22:35:07 +0200 Subject: [PATCH 2/9] fix(admin-header): truncate long company names on narrow widths (#523 regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #527 hid the language *name* on = ({ onMenuClick }) => { // Re-used in left / center / right slots below so all three positions // produce visually identical brand chrome. const renderBrandBlock = () => ( -
+ // min-w-0 + truncate on the name span so long company names shrink + // within the left cluster instead of pushing into the right-side + // action buttons on narrow mobile widths (#523 regression). +
{!logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && ( - {companyName} + {companyName} )} {(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && ( - {companyName} + {companyName} )}
); From d292b9fa10bffe5d751629b900c606763fa8e73a Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 31 May 2026 22:35:15 +0200 Subject: [PATCH 3/9] fix(gallery): toggle (not add) the local liked set on click (#590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /feedback like endpoint is a server-side toggle — the same one the lightbox uses. Every grid layout's optimistic-UI setter only ever did next.add(photoId), so click 2 on a liked tile fired a server unlike but kept the heart filled in the UI. Switch each setter to toggle (delete if present, else add). Covers Masonry (default), Grid, Justified, Timeline, Carousel, Mosaic, and Premium layouts — including their identity-modal callback paths for shape consistency. Lightbox toggle is unchanged (already correct). --- .../gallery/layouts/CarouselGalleryLayout.tsx | 16 ++++++++++++++-- .../gallery/layouts/GalleryPremiumLayout.tsx | 16 ++++++++++++---- .../gallery/layouts/GridGalleryLayout.tsx | 10 +++++++--- .../gallery/layouts/JustifiedGalleryLayout.tsx | 9 +++++++-- .../gallery/layouts/MasonryGalleryLayout.tsx | 6 +++++- .../gallery/layouts/MosaicGalleryLayout.tsx | 6 ++++-- .../gallery/layouts/TimelineGalleryLayout.tsx | 16 ++++++++++++++-- 7 files changed, 63 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx index f20d040e..14a3360b 100644 --- a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx @@ -158,7 +158,13 @@ export const CarouselGalleryLayout: React.FC = ({ } catch { return; } - setLikedIds(prev => new Set(prev).add(currentPhoto.id)); + // Toggle — server /feedback like is a toggle (#590). + setLikedIds(prev => { + const next = new Set(prev); + if (next.has(currentPhoto.id)) next.delete(currentPhoto.id); + else next.add(currentPhoto.id); + return next; + }); try { await feedbackService.submitFeedback(slug!, String(currentPhoto.id), { feedback_type: 'like', @@ -171,7 +177,13 @@ export const CarouselGalleryLayout: React.FC = ({ setShowIdentityModal(true); return; } - setLikedIds(prev => new Set(prev).add(currentPhoto.id)); + // Toggle — server /feedback like is a toggle (#590). + setLikedIds(prev => { + const next = new Set(prev); + if (next.has(currentPhoto.id)) next.delete(currentPhoto.id); + else next.add(currentPhoto.id); + return next; + }); try { await feedbackService.submitFeedback(slug!, String(currentPhoto.id), { feedback_type: 'like', diff --git a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx index 9bc02f12..1cd4ef23 100644 --- a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx @@ -260,9 +260,11 @@ export const GalleryPremiumLayout: React.FC = ({ } catch { return; } + // Toggle — server /feedback like is a toggle (#590). setLikedPhotoIds(prev => { const next = new Set(prev); - next.add(photo.id); + if (next.has(photo.id)) next.delete(photo.id); + else next.add(photo.id); return next; }); try { @@ -282,10 +284,11 @@ export const GalleryPremiumLayout: React.FC = ({ return; } - // Optimistic update + // Optimistic update — toggle, not add (#590). setLikedPhotoIds(prev => { const next = new Set(prev); - next.add(photo.id); + if (next.has(photo.id)) next.delete(photo.id); + else next.add(photo.id); return next; }); @@ -306,9 +309,14 @@ export const GalleryPremiumLayout: React.FC = ({ setShowIdentityModal(false); if (pendingLikePhotoId) { + // Toggle — server /feedback like is a toggle (#590). The identity + // modal only fires the first time per session, so the user is + // intentionally liking a not-yet-liked photo here, but keep the + // setter shape consistent with the other paths. setLikedPhotoIds(prev => { const next = new Set(prev); - next.add(pendingLikePhotoId); + if (next.has(pendingLikePhotoId)) next.delete(pendingLikePhotoId); + else next.add(pendingLikePhotoId); return next; }); diff --git a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx index 4a89c1f2..2bbff5a8 100644 --- a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx @@ -443,9 +443,12 @@ export const GridGalleryLayout: React.FC = ({ onFeedbackChange={onFeedbackChange} liked={likedPhotoIds.has(photo.id)} onLikeSuccess={() => { + // Toggle, not add — like endpoint toggles server-side, + // so the optimistic UI has to follow suit on click 2 (#590). setLikedPhotoIds((prev) => { const next = new Set(prev); - next.add(photo.id); + if (next.has(photo.id)) next.delete(photo.id); + else next.add(photo.id); return next; }); }} @@ -482,11 +485,12 @@ export const GridGalleryLayout: React.FC = ({ guest_name: name, guest_email: email, }); - // Immediately reflect like UI + // Immediately reflect like UI — toggle for consistency (#590). if (pendingAction.type === 'like') { setLikedPhotoIds((prev) => { const next = new Set(prev); - next.add(pendingAction.photoId); + if (next.has(pendingAction.photoId)) next.delete(pendingAction.photoId); + else next.add(pendingAction.photoId); return next; }); } diff --git a/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx b/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx index f34d4f36..00b26ccb 100644 --- a/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx @@ -763,9 +763,12 @@ export const JustifiedGalleryLayout: React.FC = ({ onFeedbackChange={onFeedbackChange} liked={likedPhotoIds.has(photo.id)} onLikeSuccess={() => { + // Toggle, not add — like endpoint toggles server-side, + // so the optimistic UI has to follow suit on click 2 (#590). setLikedPhotoIds((prev) => { const next = new Set(prev); - next.add(photo.id); + if (next.has(photo.id)) next.delete(photo.id); + else next.add(photo.id); return next; }); }} @@ -788,10 +791,12 @@ export const JustifiedGalleryLayout: React.FC = ({ guest_name: name, guest_email: email, }); + // Toggle for consistency (#590). if (pendingAction.type === 'like') { setLikedPhotoIds((prev) => { const next = new Set(prev); - next.add(pendingAction.photoId); + if (next.has(pendingAction.photoId)) next.delete(pendingAction.photoId); + else next.add(pendingAction.photoId); return next; }); } diff --git a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx index 9cd5b0db..bad05802 100644 --- a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx @@ -832,9 +832,13 @@ export const MasonryGalleryLayout: React.FC = ({ columnWidth={columnWidth} liked={likedPhotoIds.has(photo.id)} onLikeSuccess={() => { + // Toggle, not add — the /feedback like endpoint toggles + // server-side, so click 2 on a liked photo unlikes it; + // the optimistic UI must follow suit (#590). setLikedPhotoIds((prev) => { const next = new Set(prev); - next.add(photo.id); + if (next.has(photo.id)) next.delete(photo.id); + else next.add(photo.id); return next; }); }} diff --git a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx index 2335afe2..c47643b1 100644 --- a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx @@ -116,7 +116,8 @@ const MosaicPhoto: React.FC = ({ } catch { return; } - setLikedLocal(true); + // Toggle — server /feedback like is a toggle (#590). + setLikedLocal(prev => !prev); try { await feedbackService.submitFeedback(slug!, String(photo.id), { feedback_type: 'like', @@ -129,7 +130,8 @@ const MosaicPhoto: React.FC = ({ setShowIdentityModal(true); return; } - setLikedLocal(true); + // Toggle — server /feedback like is a toggle (#590). + setLikedLocal(prev => !prev); try { await feedbackService.submitFeedback(slug!, String(photo.id), { feedback_type: 'like', diff --git a/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx b/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx index 279fdbb7..de3a7ddd 100644 --- a/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx @@ -155,7 +155,13 @@ export const TimelineGalleryLayout: React.FC = ({ } catch { return; } - setLikedIds(prev => new Set(prev).add(photo.id)); + // Toggle — server /feedback like is a toggle (#590). + setLikedIds(prev => { + const next = new Set(prev); + if (next.has(photo.id)) next.delete(photo.id); + else next.add(photo.id); + return next; + }); try { await feedbackService.submitFeedback(slug!, String(photo.id), { feedback_type: 'like', @@ -168,7 +174,13 @@ export const TimelineGalleryLayout: React.FC = ({ setShowIdentityModal(true); return; } - setLikedIds(prev => new Set(prev).add(photo.id)); + // Toggle — server /feedback like is a toggle (#590). + setLikedIds(prev => { + const next = new Set(prev); + if (next.has(photo.id)) next.delete(photo.id); + else next.add(photo.id); + return next; + }); try { await feedbackService.submitFeedback(slug!, String(photo.id), { feedback_type: 'like', From c83e88348fbd473fb1de041cebabd3ace65d4d98 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 31 May 2026 22:35:19 +0200 Subject: [PATCH 4/9] fix(nginx): defensive large_client_header_buffers bump (#591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default nginx is 4 8k — too tight when an outer Cloudflare / corp-proxy injects long Set-Cookie / X-Forwarded-* headers, or when a power-user accumulates many per-gallery gallery_token_ cookies over the 24h maxAge in tokenUtils.js. Either way users hit "400 Request Header Or Cookie Too Large" and clearing cookies is the only workaround. 4×32k is cheap RAM, matches what most reverse proxies do upstream, and means PicPeak doesn't fail the request before the upstream even sees it. --- frontend/nginx.conf | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 9a16f008..1c8dc721 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -24,6 +24,15 @@ server { client_max_body_size 1G; client_body_timeout 300s; + # Defensive header buffer bump (#591). Default `4 8k` is too tight when + # an outer Cloudflare / corp-proxy sits in front and injects long + # Set-Cookie / X-Forwarded-* headers, or when a power-user accumulates + # many per-gallery `gallery_token_` cookies over the 24h maxAge + # in tokenUtils.js. Either way users hit "400 Request Header Or Cookie + # Too Large" and clearing cookies is the only fix. 4×32k is cheap RAM + # and matches what most reverse proxies already do upstream. + large_client_header_buffers 4 32k; + # Gzip compression gzip on; gzip_vary on; From 2304b2562465f8c86c82f2155fbdce264e68fdb3 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 31 May 2026 22:35:23 +0200 Subject: [PATCH 5/9] fix(api/v1/events): honour global devtools-detection default on create (#592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same class of bug as #550 part 2 (feedback default ignored on API events): the events table column default for enable_devtools_protection is true, so an admin who disabled detection globally still got it ON for every API-created gallery. Mirror the feedback fallback that landed in 1b521e7 — accept an optional enable_devtools_protection body field, fall back to the app_settings entry of the same name, and write the resolved value explicitly on insert so the column default doesn't shadow it. OpenAPI doc updated to match. --- backend/src/routes/v1/events.js | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/v1/events.js b/backend/src/routes/v1/events.js index 3932009a..3eba3964 100644 --- a/backend/src/routes/v1/events.js +++ b/backend/src/routes/v1/events.js @@ -91,6 +91,7 @@ const photoUpload = multer({ * expires_at: { type: string, format: date-time, nullable: true } * color_theme: { type: string, nullable: true, description: "Preset name (e.g. 'default') or JSON-encoded ThemeConfig. Persisted as-is on the event row." } * feedback_enabled: { type: boolean, nullable: true, description: "Enable guest feedback for this gallery. When omitted, falls back to the global event_default_feedback_enabled setting." } + * enable_devtools_protection: { type: boolean, nullable: true, description: "Block right-click / devtools shortcuts in the gallery. When omitted, falls back to the global enable_devtools_protection setting." } * responses: * 201: * description: Event created @@ -123,7 +124,8 @@ router.post( body('password').optional({ nullable: true }).isString().isLength({ min: 6 }), body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(), body('color_theme').optional({ nullable: true }).isString().trim(), - body('feedback_enabled').optional().isBoolean() + body('feedback_enabled').optional().isBoolean(), + body('enable_devtools_protection').optional().isBoolean() ], async (req, res) => { try { @@ -135,7 +137,8 @@ router.post( admin_email = null, require_password = true, password, expires_at = null, color_theme = null, - feedback_enabled: feedbackEnabledInput + feedback_enabled: feedbackEnabledInput, + enable_devtools_protection: devtoolsInput } = req.body; // Issue #550 — mirror the admin POST path so API-created events @@ -155,6 +158,22 @@ router.post( } const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback); + // Issue #592 — same shape as the feedback fallback above. The + // events table column default is `true`, so without this an admin + // who disabled devtools detection globally still gets it ON for + // every API-created gallery. Mirrors adminEvents.js behaviour. + let devtoolsFallback = true; + if (devtoolsInput === undefined) { + const setting = await db('app_settings').where('setting_key', 'enable_devtools_protection').first(); + if (setting) { + try { + const parsed = JSON.parse(setting.setting_value); + if (typeof parsed === 'boolean') devtoolsFallback = parsed; + } catch { /* keep true */ } + } + } + const enable_devtools_protection = parseBooleanInput(devtoolsInput, devtoolsFallback); + if (require_password && (!password || password.length < 6)) { return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' }); } @@ -203,6 +222,9 @@ router.post( // admin UI snaps the theme picker to GALLERY_THEME_PRESETS.default // and saving overwrites whatever theme was inherited visually. color_theme, + // Issue #592 — write the resolved devtools setting (input value + // or global fallback) so the column default doesn't shadow it. + enable_devtools_protection: formatBoolean(enable_devtools_protection), ...(customer_name ? { customer_name } : {}), ...(customer_email ? { customer_email } : {}), ...(persistPhone ? { customer_phone: persistPhone } : {}) From 2d44b1ab2d251a4fc752cfeb645cb2126c0da3b7 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 31 May 2026 22:47:01 +0200 Subject: [PATCH 6/9] fix(api/v1/events): also honour require_password + branding defaults (#592 follow-up) Same class of bug as the devtools-detection gap landed in 2304b25. v1 POST /events was hardcoding require_password=true in the destructure default and skipping getBrandingDefaults entirely, so: - Admins who disabled "require password by default" globally still got password-required galleries through the API. - API-created events ignored the global branding_logo_display_hero and branding_logo_size toggles, defaulting to visible/medium regardless of the admin's preferred branding chrome. Mirror the readBooleanSetting + getBrandingDefaults pattern from adminEvents.js inline (helpers aren't exported, and pulling them out is out-of-scope for this fix). Adds validators, fallback resolution, and the three resolved values to the events insert. hero_logo_position stays at 'top' since #357 / migration 084 explicitly disconnected it from the header-bar branding_logo_position setting. OpenAPI updated. --- backend/src/routes/v1/events.js | 63 ++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/backend/src/routes/v1/events.js b/backend/src/routes/v1/events.js index 3eba3964..6684a6ea 100644 --- a/backend/src/routes/v1/events.js +++ b/backend/src/routes/v1/events.js @@ -86,12 +86,15 @@ const photoUpload = multer({ * customer_email: { type: string, format: email, nullable: true } * customer_phone: { type: string, nullable: true, description: "Only persisted when the global phone-field setting is enabled." } * admin_email: { type: string, format: email, nullable: true } - * require_password: { type: boolean, default: true } - * password: { type: string, nullable: true, description: "Required when require_password is true." } + * require_password: { type: boolean, nullable: true, description: "When omitted, falls back to the global event_default_require_password setting." } + * password: { type: string, nullable: true, description: "Required when require_password resolves to true." } * expires_at: { type: string, format: date-time, nullable: true } * color_theme: { type: string, nullable: true, description: "Preset name (e.g. 'default') or JSON-encoded ThemeConfig. Persisted as-is on the event row." } * feedback_enabled: { type: boolean, nullable: true, description: "Enable guest feedback for this gallery. When omitted, falls back to the global event_default_feedback_enabled setting." } * enable_devtools_protection: { type: boolean, nullable: true, description: "Block right-click / devtools shortcuts in the gallery. When omitted, falls back to the global enable_devtools_protection setting." } + * hero_logo_visible: { type: boolean, nullable: true, description: "Show event logo in the hero block. When omitted, falls back to the global branding_logo_display_hero setting." } + * hero_logo_size: { type: string, nullable: true, enum: [small, medium, large, xlarge], description: "Hero logo size. When omitted, falls back to the global branding_logo_size setting." } + * hero_logo_position: { type: string, nullable: true, enum: [top, center, bottom], description: "Hero logo position. Defaults to 'top' (not settings-backed — see migration 084)." } * responses: * 201: * description: Event created @@ -125,7 +128,10 @@ router.post( body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(), body('color_theme').optional({ nullable: true }).isString().trim(), body('feedback_enabled').optional().isBoolean(), - body('enable_devtools_protection').optional().isBoolean() + body('enable_devtools_protection').optional().isBoolean(), + body('hero_logo_visible').optional().isBoolean(), + body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']), + body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']) ], async (req, res) => { try { @@ -134,11 +140,16 @@ router.post( const { event_name, event_type, event_date, customer_name = null, customer_email = null, customer_phone = null, - admin_email = null, require_password = true, password, + admin_email = null, + require_password: requirePasswordInput, + password, expires_at = null, color_theme = null, feedback_enabled: feedbackEnabledInput, - enable_devtools_protection: devtoolsInput + enable_devtools_protection: devtoolsInput, + hero_logo_visible: heroLogoVisibleInput, + hero_logo_size: heroLogoSizeInput, + hero_logo_position: heroLogoPositionInput } = req.body; // Issue #550 — mirror the admin POST path so API-created events @@ -174,6 +185,44 @@ router.post( } const enable_devtools_protection = parseBooleanInput(devtoolsInput, devtoolsFallback); + // Same shape as the feedback / devtools fallbacks: honour the global + // event_default_require_password toggle (#317). Without this an admin + // who disabled "require password by default" globally still got + // password-required galleries through the API. + let requirePasswordFallback = true; + if (requirePasswordInput === undefined) { + const setting = await db('app_settings').where('setting_key', 'event_default_require_password').first(); + if (setting) { + try { + const parsed = JSON.parse(setting.setting_value); + if (typeof parsed === 'boolean') requirePasswordFallback = parsed; + } catch { /* keep true */ } + } + } + const require_password = parseBooleanInput(requirePasswordInput, requirePasswordFallback); + + // Branding inheritance (Feature 7) — mirror adminEvents.js + // getBrandingDefaults so API-created events inherit the global + // hero logo visibility + size. hero_logo_position is intentionally + // NOT settings-backed (see migration 084 / #357 — branding_logo_position + // is the *header bar*, a different concept than the hero block). + let heroLogoVisibleFallback = true; + let heroLogoSizeFallback = 'medium'; + const brandingRows = await db('app_settings') + .whereIn('setting_key', ['branding_logo_display_hero', 'branding_logo_size']) + .select('setting_key', 'setting_value'); + for (const row of brandingRows) { + let value = row.setting_value; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { /* keep raw */ } + } + if (row.setting_key === 'branding_logo_display_hero') heroLogoVisibleFallback = value !== false; + if (row.setting_key === 'branding_logo_size' && value) heroLogoSizeFallback = value; + } + const hero_logo_visible = heroLogoVisibleInput !== undefined ? heroLogoVisibleInput : heroLogoVisibleFallback; + const hero_logo_size = heroLogoSizeInput || heroLogoSizeFallback; + const hero_logo_position = heroLogoPositionInput || 'top'; + if (require_password && (!password || password.length < 6)) { return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' }); } @@ -225,6 +274,10 @@ router.post( // Issue #592 — write the resolved devtools setting (input value // or global fallback) so the column default doesn't shadow it. enable_devtools_protection: formatBoolean(enable_devtools_protection), + // Branding inheritance — resolved value from body or app_settings. + hero_logo_visible: formatBoolean(hero_logo_visible), + hero_logo_size, + hero_logo_position, ...(customer_name ? { customer_name } : {}), ...(customer_email ? { customer_email } : {}), ...(persistPhone ? { customer_phone: persistPhone } : {}) From 791e9974eb4c81cc4b095f9b604eccd708fb3a66 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 31 May 2026 22:47:16 +0200 Subject: [PATCH 7/9] fix(gallery): preserve per-viewer is_liked across hard refresh (#590 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-session toggle fix in d292b9f handles click 2 correctly, but on a hard refresh likedPhotoIds was always initialized to an empty Set — so previously-liked photos rendered un-filled until the user opened the lightbox. Backend: gallery.js GET /:slug/photos now mounts resolveGuest and emits a per-viewer is_liked boolean per photo. Prefers req.guest.id when a verified guest token is present (per-person identity), falls back to the IP+UA hash that generateGuestIdentifier produces — same identity model galleryFeedback.js uses for /my-feedback. Skipped when feedback is hidden from guests. Frontend: Photo type gains optional is_liked. Each of the 7 grid layouts (Masonry / Grid / Justified / Timeline / Carousel / Mosaic / Premium) seeds its lifted likedPhotoIds Set from photos.filter(is_liked) on the first non-empty payload, gated by a seededRef so subsequent React Query refetches don't clobber in-session optimistic toggles. Mosaic uses photo.is_liked ?? false in its per-card useState initializer. GalleryPremium also drops the buggy `|| like_count > 0` fallback at line 521 that treated "anyone liked this" as "I liked it" — the per-viewer seed is now the correct source. GalleryStory had the same shape of bug in two places — same #590 fix: - Seed switched from like_count > 0 (global) to is_liked (per-viewer), with the same mount-only seededRef guard. - handleToggleFavorite now calls submitFeedback on EVERY click, not only when adding. The previous code skipped the unlike submit, so the UI removed the heart while the server kept the like row. --- backend/src/routes/gallery.js | 31 ++++++++++- .../gallery/layouts/CarouselGalleryLayout.tsx | 8 +++ .../gallery/layouts/GalleryPremiumLayout.tsx | 15 +++++- .../gallery/layouts/GalleryStoryLayout.tsx | 53 +++++++++---------- .../gallery/layouts/GridGalleryLayout.tsx | 8 +++ .../layouts/JustifiedGalleryLayout.tsx | 8 +++ .../gallery/layouts/MasonryGalleryLayout.tsx | 9 ++++ .../gallery/layouts/MosaicGalleryLayout.tsx | 4 +- .../gallery/layouts/TimelineGalleryLayout.tsx | 10 +++- frontend/src/types/index.ts | 6 +++ 10 files changed, 119 insertions(+), 33 deletions(-) diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index e155da0a..444712fc 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -7,6 +7,8 @@ const router = express.Router(); const watermarkService = require('../services/watermarkService'); const watermarkGeneratorService = require('../services/watermarkGeneratorService'); const { verifyGalleryAccess, isAdminPreview } = require('../middleware/gallery'); +const { resolveGuest } = require('../middleware/guestAuth'); +const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit'); const secureImageService = require('../services/secureImageService'); const logger = require('../utils/logger'); const { resolvePhotoFilePath } = require('../services/photoResolver'); @@ -211,7 +213,7 @@ router.get('/:slug/info', async (req, res) => { }); // Get all photos -router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { +router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) => { try { // Get filter and sort parameters from query const { filter, guest_id, sort = 'upload_date', order = 'desc' } = req.query; @@ -357,6 +359,29 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { commentCounts.forEach(c => { commentMap[c.photo_id] = parseInt(c.comment_count); }); + + // Per-viewer "is_liked" set (#590 follow-up). Hard refresh on the + // gallery grid used to reset every heart to empty because the lifted + // likedPhotoIds state started as a fresh Set on mount — even photos + // the viewer had actually liked. Surface a per-viewer flag so the + // frontend can seed correctly. Prefers req.guest.id when a verified + // guest token is present (per-person identity), falls back to the + // IP+UA hash that the original like was recorded under — same model + // the /my-feedback endpoint uses. Skipped when feedback is hidden + // from guests. + const likedPhotoIds = new Set(); + if (showFeedbackToGuests && photos.length > 0) { + const likeQuery = db('photo_feedback') + .where({ event_id: req.event.id, feedback_type: 'like' }) + .whereIn('photo_id', photos.map(p => p.id)); + if (req.guest?.id) { + likeQuery.where('guest_id', req.guest.id); + } else { + likeQuery.where('guest_identifier', generateGuestIdentifier(req)); + } + const likedRows = await likeQuery.select('photo_id'); + likedRows.forEach(row => likedPhotoIds.add(row.photo_id)); + } // Get actual categories used by photos in this event // This includes both global categories and event-specific ones @@ -524,6 +549,10 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { average_rating: showFeedbackToGuests ? (photo.average_rating || 0) : 0, comment_count: showFeedbackToGuests ? (commentMap[photo.id] || 0) : 0, like_count: showFeedbackToGuests ? (photo.like_count || 0) : 0, + // Per-viewer flag (#590 follow-up) — true when this viewer has + // an active like row for this photo, false otherwise. Lets the + // grid seed its lifted likedPhotoIds correctly on hard refresh. + is_liked: showFeedbackToGuests ? likedPhotoIds.has(photo.id) : false, favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0, // Visibility (only included for clients) ...(isClient ? { visibility: photo.visibility || 'visible' } : {}) diff --git a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx index 14a3360b..009ba744 100644 --- a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx @@ -69,6 +69,14 @@ export const CarouselGalleryLayout: React.FC = ({ const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const guestIdentity = useGuestIdentityOptional(); const [likedIds, setLikedIds] = useState>(new Set()); + // Seed from server is_liked on first non-empty payload (#590 follow-up). + // Mount-only so refetches don't clobber in-session optimistic toggles. + const likedSeededRef = useRef(false); + useEffect(() => { + if (likedSeededRef.current || photos.length === 0) return; + setLikedIds(new Set(photos.filter(p => p.is_liked).map(p => p.id))); + likedSeededRef.current = true; + }, [photos]); const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback); return ( diff --git a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx index 1cd4ef23..cebba08f 100644 --- a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { MasonryPhotoAlbum } from 'react-photo-album'; import 'react-photo-album/masonry.css'; import Lightbox from 'yet-another-react-lightbox'; @@ -199,6 +199,14 @@ export const GalleryPremiumLayout: React.FC = ({ const [lightboxIndex, setLightboxIndex] = useState(-1); const [activeCategory, setActiveCategory] = useState(null); const [likedPhotoIds, setLikedPhotoIds] = useState>(new Set()); + // Seed from server is_liked on first non-empty payload (#590 follow-up). + // Mount-only so refetches don't clobber in-session optimistic toggles. + const likedSeededRef = useRef(false); + useEffect(() => { + if (likedSeededRef.current || photos.length === 0) return; + setLikedPhotoIds(new Set(photos.filter(p => p.is_liked).map(p => p.id))); + likedSeededRef.current = true; + }, [photos]); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const guestIdentity = useGuestIdentityOptional(); const [showIdentityModal, setShowIdentityModal] = useState(false); @@ -518,7 +526,10 @@ export const GalleryPremiumLayout: React.FC = ({ }} isSelected={selectedPhotos.has(originalPhoto.id)} isSelectionMode={isSelectionMode} - isLiked={likedPhotoIds.has(originalPhoto.id) || (originalPhoto.like_count ?? 0) > 0} + // #590 follow-up: drop the `|| like_count > 0` fallback, + // which treated "anyone liked this" as "I liked it". The + // per-viewer is_liked seed above is the correct source. + isLiked={likedPhotoIds.has(originalPhoto.id)} slug={slug} allowDownloads={allowDownloads} protectionLevel={protectionLevel} diff --git a/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx b/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx index 8397ec25..ce0fc157 100644 --- a/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useCallback, useEffect } from 'react'; +import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { Search, Heart, Menu, LogOut } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -87,15 +87,16 @@ export const GalleryStoryLayout: React.FC = ({ return () => window.removeEventListener('scroll', handleScroll); }, []); - // Initialize favorites from photo like_counts + // Seed favorites from per-viewer is_liked on first non-empty payload + // (#590 follow-up). The previous code seeded from like_count > 0 which + // marked every photo with ANY likes as "favorited" for the current + // viewer — wrong. Also gated by a mount-only ref so refetches don't + // clobber the user's in-session toggles. + const favoritesSeededRef = useRef(false); useEffect(() => { - const initialFavorites = new Set(); - photos.forEach(photo => { - if ((photo.like_count ?? 0) > 0) { - initialFavorites.add(photo.id); - } - }); - setFavorites(initialFavorites); + if (favoritesSeededRef.current || photos.length === 0) return; + setFavorites(new Set(photos.filter(p => p.is_liked).map(p => p.id))); + favoritesSeededRef.current = true; }, [photos]); // Get hero photo @@ -138,27 +139,23 @@ export const GalleryStoryLayout: React.FC = ({ const handleToggleFavorite = useCallback(async (photoId: number) => { const newFavorites = new Set(favorites); - const isCurrentlyFavorite = newFavorites.has(photoId); - - if (isCurrentlyFavorite) { - newFavorites.delete(photoId); - } else { - newFavorites.add(photoId); - } + if (newFavorites.has(photoId)) newFavorites.delete(photoId); + else newFavorites.add(photoId); setFavorites(newFavorites); - // Only submit like if adding favorite - if (!isCurrentlyFavorite) { - try { - await feedbackService.submitFeedback(slug, String(photoId), { - feedback_type: 'like', - guest_name: savedIdentity?.name, - guest_email: savedIdentity?.email, - }); - onFeedbackChange?.(); - } catch (err) { - console.warn('Like submit failed', err); - } + // The server /feedback like endpoint is a toggle (#590) — fire on + // every click, not only when adding. The previous code skipped the + // submit on unlike, so the UI removed the heart but the server + // still had the like row. + try { + await feedbackService.submitFeedback(slug, String(photoId), { + feedback_type: 'like', + guest_name: savedIdentity?.name, + guest_email: savedIdentity?.email, + }); + onFeedbackChange?.(); + } catch (err) { + console.warn('Like submit failed', err); } }, [favorites, slug, savedIdentity, onFeedbackChange]); diff --git a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx index 2bbff5a8..0d6365a2 100644 --- a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx @@ -403,6 +403,14 @@ export const GridGalleryLayout: React.FC = ({ const [showIdentityModal, setShowIdentityModal] = React.useState(false); const [pendingAction, setPendingAction] = React.useState(null); const [likedPhotoIds, setLikedPhotoIds] = React.useState>(new Set()); + // Seed from server is_liked on first non-empty payload (#590 follow-up). + // Mount-only so refetches don't clobber in-session optimistic toggles. + const likedSeededRef = React.useRef(false); + React.useEffect(() => { + if (likedSeededRef.current || photos.length === 0) return; + setLikedPhotoIds(new Set(photos.filter(p => p.is_liked).map(p => p.id))); + likedSeededRef.current = true; + }, [photos]); const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null); const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4'; diff --git a/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx b/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx index 00b26ccb..a837c022 100644 --- a/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx @@ -543,6 +543,14 @@ export const JustifiedGalleryLayout: React.FC = ({ null ); const [likedPhotoIds, setLikedPhotoIds] = useState>(new Set()); + // Seed from server is_liked on first non-empty payload (#590 follow-up). + // Mount-only so refetches don't clobber in-session optimistic toggles. + const likedSeededRef = useRef(false); + useEffect(() => { + if (likedSeededRef.current || photos.length === 0) return; + setLikedPhotoIds(new Set(photos.filter(p => p.is_liked).map(p => p.id))); + likedSeededRef.current = true; + }, [photos]); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); // Track container width with ResizeObserver diff --git a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx index bad05802..7cea8d3c 100644 --- a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx @@ -281,6 +281,15 @@ export const MasonryGalleryLayout: React.FC = ({ // Optimistic "I liked this" state — lifted here so it survives re-renders // of individual MasonryPhoto components during layout reflow/resize. const [likedPhotoIds, setLikedPhotoIds] = useState>(new Set()); + // Seed from server is_liked on first non-empty photos payload (#590 + // follow-up). Mount-only: subsequent refetches don't clobber in-session + // optimistic toggles, only the first arrival of photos initializes. + const likedSeededRef = useRef(false); + useEffect(() => { + if (likedSeededRef.current || photos.length === 0) return; + setLikedPhotoIds(new Set(photos.filter(p => p.is_liked).map(p => p.id))); + likedSeededRef.current = true; + }, [photos]); const gallerySettings = theme.gallerySettings || {}; const gutter = gallerySettings.masonryGutter || 16; const mode = gallerySettings.masonryMode || 'columns'; diff --git a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx index c47643b1..1e2b06cf 100644 --- a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx @@ -55,7 +55,9 @@ const MosaicPhoto: React.FC = ({ const [pendingAction, setPendingAction] = React.useState(null); const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null); const guestIdentity = useGuestIdentityOptional(); - const [likedLocal, setLikedLocal] = React.useState(false); + // Seed from server is_liked (#590 follow-up). useState's initializer + // fires once on mount, so subsequent prop updates don't reseed. + const [likedLocal, setLikedLocal] = React.useState(photo.is_liked ?? false); const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment); // Calculate aspect ratio from photo dimensions (fallback to 1 if unknown) diff --git a/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx b/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx index de3a7ddd..ebaa361a 100644 --- a/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Download, Maximize2, Check, Calendar, Heart, MessageSquare } from 'lucide-react'; import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns'; import { useTheme } from '../../../contexts/ThemeContext'; @@ -24,6 +24,14 @@ export const TimelineGalleryLayout: React.FC = ({ }) => { const { theme } = useTheme(); const [likedIds, setLikedIds] = useState>(new Set()); + // Seed from server is_liked on first non-empty payload (#590 follow-up). + // Mount-only so refetches don't clobber in-session optimistic toggles. + const likedSeededRef = useRef(false); + useEffect(() => { + if (likedSeededRef.current || photos.length === 0) return; + setLikedIds(new Set(photos.filter(p => p.is_liked).map(p => p.id))); + likedSeededRef.current = true; + }, [photos]); const [showIdentityModal, setShowIdentityModal] = useState(false); const [pendingAction, setPendingAction] = useState(null); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 32f9553c..6d4b542b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -131,6 +131,12 @@ export interface Photo { total_ratings?: number; comment_count?: number; like_count?: number; + // Per-viewer flag (#590 follow-up). True when the requesting viewer has + // an active like row for this photo, false otherwise. Computed server-side + // by gallery.js using the same identity model as galleryFeedback.js + // (guest_id when a guest token is present, else IP+UA hash fallback). + // Used to seed the lifted likedPhotoIds Set in grid layouts on mount. + is_liked?: boolean; favorite_count?: number; } From 8c6525af017b556ec72bbcfe7082f2f15cd97078 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 31 May 2026 23:02:51 +0200 Subject: [PATCH 8/9] test(v1/events): update mock chains to cover new app_settings probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #592 fix added a devtools-detection probe, and the #592 follow-up added a require_password probe + a branding-defaults whereIn().select(). Both shift the db() call indices the existing #550 test relied on, and the branding probe needed `.select()` to resolve to an array (the mock chain wasn't thenable, so `for..of` on the result threw → 500 on every test that hit BASE_BODY). Add `whereIn` + `selectResult` to buildChain so the branding probe yields an iterable. Factor the three pre-slug app_settings chains into a baseSettingsChains() helper and update each test's queued sequence and toHaveBeenNthCalledWith / toHaveBeenCalledTimes expectations to match the new shape. No behaviour change in v1/events.js — only the test scaffolding moves. --- .../routes/v1/__tests__/events.create.test.js | 82 +++++++++++-------- 1 file changed, 49 insertions(+), 33 deletions(-) diff --git a/backend/src/routes/v1/__tests__/events.create.test.js b/backend/src/routes/v1/__tests__/events.create.test.js index 6e0fb84f..a47b1daf 100644 --- a/backend/src/routes/v1/__tests__/events.create.test.js +++ b/backend/src/routes/v1/__tests__/events.create.test.js @@ -18,12 +18,17 @@ const request = require('supertest'); const express = require('express'); -const buildChain = ({ firstResult, insertResult, returningResult } = {}) => { +const buildChain = ({ firstResult, insertResult, returningResult, selectResult } = {}) => { const chain = { where: jest.fn().mockReturnThis(), + whereIn: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orWhere: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), + // `select` resolves to an array so `await db(...).whereIn(...).select(...)` + // gives an iterable result (used by the branding-defaults probe added in + // #592 follow-up). Tests that don't need it leave selectResult undefined + // and get `[]`, which is a safe no-op for any caller that iterates. + select: jest.fn().mockResolvedValue(selectResult ?? []), first: jest.fn().mockResolvedValue(firstResult), insert: jest.fn().mockReturnThis(), returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]), @@ -92,23 +97,28 @@ const BASE_BODY = { require_password: false, }; +// db() call sequence for BASE_BODY (no feedback / devtools provided, +// require_password supplied so its probe is skipped, no customer_phone, +// no slug collision): +// 1. app_settings.where('event_default_feedback_enabled').first() (#550) +// 2. app_settings.where('enable_devtools_protection').first() (#592) +// 3. app_settings.whereIn([branding_logo_display_hero,...]).select(...) (#592 follow-up) +// Then slug probe, events insert, optional feedback insert. +const baseSettingsChains = () => [ + buildChain({ firstResult: null }), // feedback default + buildChain({ firstResult: null }), // devtools default + buildChain({ selectResult: [] }), // branding whereIn → empty rows +]; + describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => { beforeEach(() => { jest.clearAllMocks(); }); it('persists color_theme to the events row when provided', async () => { - // db() call sequence for this body (feedback_enabled omitted, no - // customer_phone, no slug collision): - // 1. app_settings.where('event_default_feedback_enabled').first() - // 2. events.where({ slug }).first() ← uniqueness probe - // 3. events.insert(...).returning('id') - // No event_feedback_settings insert because the global setting - // returns nothing (feedback stays off) — covered separately below. - const settingChain = buildChain({ firstResult: null }); const slugChain = buildChain({ firstResult: null }); const insertChain = buildChain({ returningResult: [{ id: 42 }] }); - db.__setImplementations(settingChain, slugChain, insertChain); + db.__setImplementations(...baseSettingsChains(), slugChain, insertChain); await request(buildApp()) .post('/events') @@ -123,11 +133,9 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => { }); it('accepts a JSON-encoded theme string and persists it verbatim', async () => { - db.__setImplementations( - buildChain({ firstResult: null }), - buildChain({ firstResult: null }), - buildChain({ returningResult: [{ id: 43 }] }), - ); + const slugChain = buildChain({ firstResult: null }); + const insertChain = buildChain({ returningResult: [{ id: 43 }] }); + db.__setImplementations(...baseSettingsChains(), slugChain, insertChain); const customTheme = JSON.stringify({ primaryColor: '#ff0066' }); await request(buildApp()) @@ -135,26 +143,30 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => { .send({ ...BASE_BODY, color_theme: customTheme }) .expect(201); - const insertedRow = db.mock.results[2].value.insert.mock.calls[0][0]; + const insertedRow = insertChain.insert.mock.calls[0][0]; expect(insertedRow.color_theme).toBe(customTheme); }); it('creates event_feedback_settings row when feedback_enabled=true is sent', async () => { - // 3 db() calls when feedback_enabled is sent explicitly (the - // settings probe is skipped because feedbackEnabledInput !== undefined): - // 1. slug probe, 2. events insert, 3. feedback insert + // feedback_enabled provided → feedback probe SKIPPED. Sequence: + // 1. devtools probe + // 2. branding probe (whereIn → select) + // 3. slug probe + // 4. events insert + // 5. event_feedback_settings insert + const devtoolsChain = buildChain({ firstResult: null }); + const brandingChain = buildChain({ selectResult: [] }); const slugChain = buildChain({ firstResult: null }); const insertChain = buildChain({ returningResult: [{ id: 50 }] }); const feedbackInsertChain = buildChain(); - db.__setImplementations(slugChain, insertChain, feedbackInsertChain); + db.__setImplementations(devtoolsChain, brandingChain, slugChain, insertChain, feedbackInsertChain); await request(buildApp()) .post('/events') .send({ ...BASE_BODY, feedback_enabled: true }) .expect(201); - // db('event_feedback_settings') is the 3rd invocation. - expect(db).toHaveBeenNthCalledWith(3, 'event_feedback_settings'); + expect(db).toHaveBeenNthCalledWith(5, 'event_feedback_settings'); const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0]; expect(feedbackRow).toMatchObject({ event_id: 50 }); @@ -172,39 +184,43 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => { }); it('honours the event_default_feedback_enabled global when body omits feedback_enabled', async () => { - // settings probe returns a serialized "true" — fallback should kick - // in and the feedback row should still be written. - const settingChain = buildChain({ + // Feedback probe returns serialized "true" → fallback kicks in and + // the feedback insert runs. Sequence: feedback probe, devtools probe, + // branding probe, slug, insert, feedback insert (6 calls total). + const feedbackProbe = buildChain({ firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' }, }); + const devtoolsChain = buildChain({ firstResult: null }); + const brandingChain = buildChain({ selectResult: [] }); const slugChain = buildChain({ firstResult: null }); const insertChain = buildChain({ returningResult: [{ id: 51 }] }); const feedbackInsertChain = buildChain(); - db.__setImplementations(settingChain, slugChain, insertChain, feedbackInsertChain); + db.__setImplementations( + feedbackProbe, devtoolsChain, brandingChain, slugChain, insertChain, feedbackInsertChain + ); await request(buildApp()) .post('/events') .send(BASE_BODY) .expect(201); - expect(db).toHaveBeenNthCalledWith(4, 'event_feedback_settings'); + expect(db).toHaveBeenNthCalledWith(6, 'event_feedback_settings'); expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1); }); it('does NOT create a feedback row when global setting is unset and body omits feedback_enabled', async () => { - const settingChain = buildChain({ firstResult: null }); const slugChain = buildChain({ firstResult: null }); const insertChain = buildChain({ returningResult: [{ id: 52 }] }); - db.__setImplementations(settingChain, slugChain, insertChain); + db.__setImplementations(...baseSettingsChains(), slugChain, insertChain); await request(buildApp()) .post('/events') .send(BASE_BODY) .expect(201); - // Only 3 db() calls — the event_feedback_settings table is never - // touched because feedback_enabled resolved to false. - expect(db).toHaveBeenCalledTimes(3); + // 5 db() calls: feedback + devtools + branding probes, slug, insert. + // event_feedback_settings is never touched. + expect(db).toHaveBeenCalledTimes(5); expect(db).not.toHaveBeenCalledWith('event_feedback_settings'); }); From c246fd3cc89962d626218029ab3da1346a726246 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 31 May 2026 23:07:19 +0200 Subject: [PATCH 9/9] fix(admin-header): hide wordmark on = ({ onMenuClick }) => { // Renders the logo + wordmark block per the current logo_display_mode. // Re-used in left / center / right slots below so all three positions // produce visually identical brand chrome. + const showLogo = !logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text'); + const showText = logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text'; + // On ( // min-w-0 + truncate on the name span so long company names shrink // within the left cluster instead of pushing into the right-side // action buttons on narrow mobile widths (#523 regression).
- {!logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && ( + {showLogo && ( {companyName} )} - {(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && ( - {companyName} + {showText && ( + {companyName} )}
);