Files
picpeak/backend/src/services/feedbackDefaults.js
T
Luca e2844d1909 feat(gallery): colour labels for client proofing, and one global default per feedback type (#1044) (#1137)
Colour labels for client proofing, plus the photographer's own stars and colours in the admin grid.

- Guest colour labels alongside likes/reactions, opt-in per event (defaults off so live galleries do not change mid-proofing), with 'colors' and 'lightroom' keybind schemes.
- One global default per feedback type, replacing the per-type scatter.
- Admin marks live in their own table (photo_admin_marks) so they can never reach a guest-facing surface.
- XMP export prefers a real label, keeping the rating-derived mapping as a fallback.

Review: concurrent-write loss on the mark update path, migration index idempotency and error classification all fixed in 7139bcae; migrations renumbered to 182/183 in 8fecdfae after 180/181 were taken on main.

Merged with admin privileges: bypass-size-gate is a required check that fails on size alone for review-bypass authors and never re-evaluates on review, which is its designed behaviour once a maintainer has approved.
2026-08-23 11:15:01 +02:00

171 lines
6.3 KiB
JavaScript

/**
* Global defaults for the per-event guest-feedback toggles (#1044).
*
* Before this module the creation defaults lived in four places that had
* already drifted apart: the admin create route's destructuring defaults, the
* v1 create route's hard-coded insert (which forgot `allow_reactions`
* entirely), `feedbackService.getEventFeedbackSettings()`'s no-row fallback
* and the migration column defaults. The same install answered "are comments
* on by default?" differently depending on which code path created the event.
*
* Everything now resolves through FEEDBACK_TOGGLES:
*
* app_settings row -> per-event column default -> the built-in fallback
*
* The `event_default_*` keys extend the family `event_default_require_password`
* (#317) and `event_default_feedback_enabled` (#520) already established: they
* are the value a NEW event starts from, not a retroactive master switch.
* Flipping one never changes a gallery that already exists — a switch that
* silently strips the rating stars off a gallery a client is mid-way through
* proofing would be a different, much riskier feature.
*
* Adding a seventh feedback type means adding one row to FEEDBACK_TOGGLES
* (plus its column in the migration and its UI) — no sweep across the create
* routes.
*/
const { db } = require('../database/db');
const logger = require('../utils/logger');
/**
* Every guest-feedback toggle, in the order the admin UI shows them.
*
* `fallback` is what applies when the admin has never touched the global
* setting. These match the values the admin create route has shipped with, so
* installs that never open Settings > Events keep creating events exactly as
* they did before — except `allow_color_labels`, which is new in #1044 and
* opt-in so no existing gallery grows a colour bar on upgrade.
*/
const FEEDBACK_TOGGLES = [
{ column: 'allow_ratings', settingKey: 'event_default_allow_ratings', fallback: true },
{ column: 'allow_likes', settingKey: 'event_default_allow_likes', fallback: true },
{ column: 'allow_favorites', settingKey: 'event_default_allow_favorites', fallback: true },
{ column: 'allow_comments', settingKey: 'event_default_allow_comments', fallback: true },
{ column: 'allow_reactions', settingKey: 'event_default_allow_reactions', fallback: true },
{ column: 'allow_color_labels', settingKey: 'event_default_allow_color_labels', fallback: false },
];
/** Non-boolean feedback defaults that follow the same global -> event flow. */
const KEYBIND_MODES = ['colors', 'lightroom'];
const DEFAULT_KEYBIND_MODE = 'colors';
const KEYBIND_MODE_SETTING_KEY = 'event_default_keybind_mode';
/** Every app_settings key this module owns — for the settings API surface. */
const FEEDBACK_DEFAULT_SETTING_KEYS = [
...FEEDBACK_TOGGLES.map((t) => t.settingKey),
KEYBIND_MODE_SETTING_KEY,
];
/**
* app_settings stores JSON-encoded values, but rows written by older code
* paths (and by SQLite's looser typing) can arrive as raw strings. Accept
* both, and return undefined for anything that isn't recognisably a boolean
* so the caller falls back rather than persisting `null` into a NOT NULL-ish
* boolean column.
*/
function parseBooleanSetting(rawValue) {
let value = rawValue;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch {
const normalized = value.trim().toLowerCase();
if (normalized === 'true') return true;
if (normalized === 'false') return false;
return undefined;
}
}
if (typeof value === 'boolean') return value;
// SQLite hands back 0/1 for booleans written through formatBoolean.
if (value === 1 || value === 0) return value === 1;
return undefined;
}
function parseKeybindModeSetting(rawValue) {
let value = rawValue;
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (typeof parsed === 'string') value = parsed;
} catch {
/* keep raw */
}
}
return KEYBIND_MODES.includes(value) ? value : undefined;
}
/**
* Resolve the global feedback defaults in ONE query (mirrors
* getBrandingDefaults' batched whereIn rather than firing seven `.first()`
* calls on every event creation).
*
* Never throws: a settings-table hiccup must not fail event creation, so the
* built-in fallbacks stand in.
*
* @returns {Promise<{allow_ratings: boolean, allow_likes: boolean,
* allow_favorites: boolean, allow_comments: boolean, allow_reactions: boolean,
* allow_color_labels: boolean, keybind_mode: string}>}
*/
async function resolveEventFeedbackDefaults() {
const defaults = {};
for (const toggle of FEEDBACK_TOGGLES) {
defaults[toggle.column] = toggle.fallback;
}
defaults.keybind_mode = DEFAULT_KEYBIND_MODE;
try {
const rows = await db('app_settings')
.whereIn('setting_key', FEEDBACK_DEFAULT_SETTING_KEYS)
.select('setting_key', 'setting_value');
const bySettingKey = new Map(rows.map((row) => [row.setting_key, row.setting_value]));
for (const toggle of FEEDBACK_TOGGLES) {
if (!bySettingKey.has(toggle.settingKey)) continue;
const parsed = parseBooleanSetting(bySettingKey.get(toggle.settingKey));
if (parsed !== undefined) defaults[toggle.column] = parsed;
}
if (bySettingKey.has(KEYBIND_MODE_SETTING_KEY)) {
const parsed = parseKeybindModeSetting(bySettingKey.get(KEYBIND_MODE_SETTING_KEY));
if (parsed !== undefined) defaults.keybind_mode = parsed;
}
} catch (error) {
logger.error('Failed to read global feedback defaults, using built-ins', {
error: error.message,
});
}
return defaults;
}
/**
* Merge an explicit request body over the resolved globals. A value the caller
* actually sent always wins; `undefined` (the field was omitted) inherits.
*
* @param {Object} body - the create-request payload
* @param {Object} globals - output of resolveEventFeedbackDefaults()
*/
function applyFeedbackDefaults(body = {}, globals) {
const resolved = { ...globals };
for (const toggle of FEEDBACK_TOGGLES) {
if (body[toggle.column] !== undefined) {
resolved[toggle.column] = body[toggle.column];
}
}
if (KEYBIND_MODES.includes(body.keybind_mode)) {
resolved.keybind_mode = body.keybind_mode;
}
return resolved;
}
module.exports = {
FEEDBACK_TOGGLES,
FEEDBACK_DEFAULT_SETTING_KEYS,
KEYBIND_MODES,
KEYBIND_MODE_SETTING_KEY,
DEFAULT_KEYBIND_MODE,
resolveEventFeedbackDefaults,
applyFeedbackDefaults,
};