From 8978acdb492f085fbe92186d9dbddfb35b07b676 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:13:39 +0200 Subject: [PATCH] fix(events): accept hero_logo_visible: null on create/update (#822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hero_logo_visible is nullable — null means "inherit the global branding_logo_display_hero toggle" (#756, migration 152). But the create and update validators used `.optional()` without `{ nullable: true }`, which only skips `undefined`; an explicit `null` still ran `.isBoolean()` and failed with HTTP 400 "Invalid value". Saving an event with `hero_logo_visible: null` (the inherit state the frontend sends) was rejected on v3.45.2. - Both routes: `body('hero_logo_visible').optional({ nullable: true }).isBoolean()`, matching the already-correct `hero_logo_size` rule next to it. - Create handler: guard on `!= null` instead of `!== undefined` so an explicit null stores NULL (inherit) rather than being coerced to 0/false by formatBoolean on SQLite. The update handler already did `=== null ? null`. Left hero_logo_position on plain `.optional()` on purpose: its column is NOT NULL (no inherit migration) and its handler always resolves to a concrete value via `|| brandingDefaults`, so null is genuinely invalid there — allowing it would trade the 400 for a 500. Adds smoke tests: PUT accepts hero_logo_visible: null and stores NULL; a non-boolean value is still rejected. --- .../routes/adminEvents.smoke.test.js | 21 +++++++++++++++++++ backend/src/routes/adminEvents/crud.js | 10 +++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/routes/adminEvents.smoke.test.js b/backend/__tests__/routes/adminEvents.smoke.test.js index fdee970b..728ceb9e 100644 --- a/backend/__tests__/routes/adminEvents.smoke.test.js +++ b/backend/__tests__/routes/adminEvents.smoke.test.js @@ -180,6 +180,27 @@ describe('admin events CRUD endpoints (smoke)', () => { }); expect(res.status).toBe(404); }); + + // #822 — hero_logo_visible/position are nullable (null = "inherit the global + // branding toggle"), but the validator used .optional() without + // { nullable: true }, so an explicit null was rejected with 400. + it('accepts hero_logo_visible: null and stores NULL (inherit)', async () => { + const id = await insertEvent(db, adminId, { hero_logo_visible: 1 }); + const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({ + hero_logo_visible: null, + }); + expect(res.status).toBe(200); + const row = await db('events').where({ id }).first(); + expect(row.hero_logo_visible).toBeNull(); + }); + + it('still rejects a non-boolean hero_logo_visible', async () => { + const id = await insertEvent(db, adminId); + const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({ + hero_logo_visible: 'maybe', + }); + expect(res.status).toBe(400); + }); }); describe('DELETE /:id', () => { diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index d8dfbf13..56b8252f 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -94,7 +94,7 @@ module.exports = (router) => { body('allow_presigned_download').optional().isBoolean(), body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), // Hero logo settings - body('hero_logo_visible').optional().isBoolean(), + body('hero_logo_visible').optional({ nullable: true }).isBoolean(), body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']), body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), // Header style settings (decoupled from layout) @@ -342,8 +342,10 @@ module.exports = (router) => { // hero_logo_visible: store NULL ("inherit") unless the admin explicitly // set it, so the global branding_logo_display_hero toggle keeps // controlling this gallery afterwards (#756). Only an explicit per-event - // choice overrides the global. - const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined + // choice overrides the global. `!= null` treats an explicit null the same + // as omitted (both → inherit); otherwise formatBoolean(null) would coerce + // to 0/false on SQLite instead of NULL (the PUT handler already does this). + const effectiveHeroLogoVisible = req.body.hero_logo_visible != null ? formatBoolean(hero_logo_visible) : null; // NULL = inherit the global branding_logo_size (#756), resolved at read @@ -1224,7 +1226,7 @@ module.exports = (router) => { }), body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), // Hero logo settings - body('hero_logo_visible').optional().isBoolean(), + body('hero_logo_visible').optional({ nullable: true }).isBoolean(), body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']), body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), // Header style settings (decoupled from layout)