fix(api/v1): accept color_theme + create feedback row on event create (#550)

POST /v1/events was a strict subset of the admin create path: it did not
accept color_theme on the body, and it skipped the event_feedback_settings
insert that adminEvents.js does. Two visible bugs followed.

1. Editing an API-created event in the admin UI snapped the theme picker
   to GALLERY_THEME_PRESETS.default (EventDetailsPage.tsx falls through to
   the default preset when event.color_theme is falsy), and saving wrote
   that default back. Inherited themes were silently clobbered.

2. The "Enable Guest Feedback by default" admin setting (#520) did not
   apply to API-created events. With no event_feedback_settings row the
   gallery UI reads feedback as off, regardless of
   event_default_feedback_enabled.

Fix mirrors the admin path:

  - color_theme accepted on the request body (optional, persisted as-is —
    preset name or JSON-encoded ThemeConfig, same shape adminEvents
    stores).
  - feedback_enabled accepted on the request body; when omitted, falls
    back to the event_default_feedback_enabled global setting (same
    behaviour adminEvents.js:511-520 implements via readBooleanSetting).
  - event_feedback_settings row inserted when feedback resolves to true,
    using the same sub-flag defaults as the admin form (everything on
    except require_name_email).

OpenAPI JSDoc updated so docs.picpeak.app picks up the new fields.

Tests cover all four scenarios — explicit color_theme persisted, JSON
theme persisted verbatim, explicit feedback_enabled creates the row,
omitted feedback_enabled honours the global setting, and a validator
regression for non-boolean feedback_enabled.
This commit is contained in:
Paul Nothaft
2026-05-25 10:29:33 +02:00
parent ce1ccf0a4d
commit 1b521e761c
2 changed files with 269 additions and 2 deletions
@@ -0,0 +1,218 @@
/**
* Regression tests for issue #550.
*
* Two related bugs in POST /v1/events:
* 1. color_theme was not accepted on the request body and never written
* to the events row. Editing such an event later in the admin UI
* snapped the theme picker to GALLERY_THEME_PRESETS.default and
* saving overwrote whatever theme was inherited visually.
* 2. event_feedback_settings row was never created, so the gallery UI
* read it as "feedback off" regardless of the global
* event_default_feedback_enabled toggle (#520).
*
* Test pattern mirrors events.category.test.js — queue up db() chains
* with db.__setImplementations() in the exact order the handler invokes
* them, then assert against the captured payloads.
*/
const request = require('supertest');
const express = require('express');
const buildChain = ({ firstResult, insertResult, returningResult } = {}) => {
const chain = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue(firstResult),
insert: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]),
};
return chain;
};
jest.mock('../../../database/db', () => {
const dbMock = jest.fn();
dbMock.raw = jest.fn();
dbMock.__setImplementations = (...chains) => {
dbMock.mockReset();
chains.forEach((chain) => {
dbMock.mockImplementationOnce(() => chain);
});
};
return {
db: dbMock,
logActivity: jest.fn().mockResolvedValue(undefined),
};
});
jest.mock('../../../middleware/apiTokenAuth', () => ({
apiTokenAuth: (req, _res, next) => {
req.apiToken = { id: 1, admin_id: 1, scopes: ['admin'] };
req.admin = { id: 1, username: 'token-admin' };
next();
},
requireApiScope: () => (_req, _res, next) => next(),
}));
// bcrypt.hash is awaited twice per request (real path + dummy path).
// Stub it to a constant so tests don't burn CPU on bcrypt rounds.
jest.mock('bcrypt', () => ({
hash: jest.fn().mockResolvedValue('$2b$10$mocked-hash'),
}));
jest.mock('../../../services/shareLinkService', () => ({
buildShareLinkVariants: jest.fn().mockResolvedValue({
shareUrl: 'https://example.test/gallery/some-slug?t=abc',
shareLinkToStore: '/gallery/some-slug?t=abc',
}),
}));
// Webhook fire is in a try/catch; stub to silence the predictable
// failure log so test output stays clean.
jest.mock('../../../services/webhookService', () => ({
fire: jest.fn().mockResolvedValue(undefined),
buildEventSubject: jest.fn().mockReturnValue({}),
}));
const { db } = require('../../../database/db');
const eventsRouter = require('../events');
const buildApp = () => {
const app = express();
app.use(express.json());
app.use('/', eventsRouter);
return app;
};
const BASE_BODY = {
event_name: 'Issue 550 Wedding',
event_type: 'wedding',
event_date: '2026-06-15',
require_password: false,
};
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);
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, color_theme: 'default' })
.expect(201);
const insertedRow = insertChain.insert.mock.calls[0][0];
expect(insertedRow).toMatchObject({
event_name: 'Issue 550 Wedding',
color_theme: 'default',
});
});
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 customTheme = JSON.stringify({ primaryColor: '#ff0066' });
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, color_theme: customTheme })
.expect(201);
const insertedRow = db.mock.results[2].value.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
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 50 }] });
const feedbackInsertChain = buildChain();
db.__setImplementations(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');
const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0];
expect(feedbackRow).toMatchObject({ event_id: 50 });
// formatBoolean() returns 1/0 on SQLite and true/false on PG. Either
// way the value must be truthy/falsy in the right places — assert by
// coercion so the test stays driver-agnostic.
expect(Boolean(feedbackRow.feedback_enabled)).toBe(true);
expect(Boolean(feedbackRow.allow_ratings)).toBe(true);
expect(Boolean(feedbackRow.allow_likes)).toBe(true);
expect(Boolean(feedbackRow.allow_comments)).toBe(true);
expect(Boolean(feedbackRow.allow_favorites)).toBe(true);
expect(Boolean(feedbackRow.require_name_email)).toBe(false);
expect(Boolean(feedbackRow.moderate_comments)).toBe(true);
expect(Boolean(feedbackRow.show_feedback_to_guests)).toBe(true);
});
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({
firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' },
});
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 51 }] });
const feedbackInsertChain = buildChain();
db.__setImplementations(settingChain, slugChain, insertChain, feedbackInsertChain);
await request(buildApp())
.post('/events')
.send(BASE_BODY)
.expect(201);
expect(db).toHaveBeenNthCalledWith(4, '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);
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);
expect(db).not.toHaveBeenCalledWith('event_feedback_settings');
});
it('rejects non-boolean feedback_enabled with 400', async () => {
// Validators run before any db() call, so no chain queueing needed.
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, feedback_enabled: 'maybe' })
.expect(400);
});
});
+51 -2
View File
@@ -24,6 +24,8 @@ const { buildShareLinkVariants } = require('../../services/shareLinkService');
const { generateThumbnail } = require('../../services/imageProcessor');
const logger = require('../../utils/logger');
const { slugify } = require('../../utils/slug');
const { formatBoolean } = require('../../utils/dbCompat');
const { parseBooleanInput } = require('../../utils/parsers');
const router = express.Router();
@@ -87,6 +89,8 @@ const photoUpload = multer({
* require_password: { type: boolean, default: true }
* password: { type: string, nullable: true, description: "Required when require_password is 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." }
* responses:
* 201:
* description: Event created
@@ -117,7 +121,9 @@ router.post(
body('admin_email').optional({ nullable: true, checkFalsy: true }).isEmail(),
body('require_password').optional().isBoolean(),
body('password').optional({ nullable: true }).isString().isLength({ min: 6 }),
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601()
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('color_theme').optional({ nullable: true }).isString().trim(),
body('feedback_enabled').optional().isBoolean()
],
async (req, res) => {
try {
@@ -127,9 +133,28 @@ router.post(
event_name, event_type, event_date,
customer_name = null, customer_email = null, customer_phone = null,
admin_email = null, require_password = true, password,
expires_at = null
expires_at = null,
color_theme = null,
feedback_enabled: feedbackEnabledInput
} = req.body;
// Issue #550 — mirror the admin POST path so API-created events
// pick up the global "Enable Guest Feedback by default" toggle
// (event_default_feedback_enabled). Without this, the UI reads
// a missing event_feedback_settings row as "feedback off"
// regardless of the admin's chosen default.
let feedbackEnabledFallback = false;
if (feedbackEnabledInput === undefined) {
const setting = await db('app_settings').where('setting_key', 'event_default_feedback_enabled').first();
if (setting) {
try {
const parsed = JSON.parse(setting.setting_value);
if (typeof parsed === 'boolean') feedbackEnabledFallback = parsed;
} catch { /* keep false */ }
}
}
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
if (require_password && (!password || password.length < 6)) {
return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' });
}
@@ -174,12 +199,36 @@ router.post(
created_at: new Date().toISOString(),
created_by: req.admin.id,
is_draft: false,
// Issue #550 — without this, editing an API-created event in the
// admin UI snaps the theme picker to GALLERY_THEME_PRESETS.default
// and saving overwrites whatever theme was inherited visually.
color_theme,
...(customer_name ? { customer_name } : {}),
...(customer_email ? { customer_email } : {}),
...(persistPhone ? { customer_phone: persistPhone } : {})
}).returning('id');
const id = insertResult[0]?.id || insertResult[0];
// Issue #550 — mirror adminEvents.js: create event_feedback_settings
// row when feedback is enabled, so the gallery actually shows
// feedback UI. Sub-flags default to the same values the admin form
// ships with (everything on except require_name_email).
if (feedback_enabled) {
await db('event_feedback_settings').insert({
event_id: id,
feedback_enabled: formatBoolean(true),
allow_ratings: formatBoolean(true),
allow_likes: formatBoolean(true),
allow_comments: formatBoolean(true),
allow_favorites: formatBoolean(true),
require_name_email: formatBoolean(false),
moderate_comments: formatBoolean(true),
show_feedback_to_guests: formatBoolean(true),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
}
await logActivity('event_created', { via: 'api_v1', event_type }, id, {
type: 'admin', id: req.admin.id, name: req.admin.username
});