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.
This commit is contained in:
Luca
2026-08-23 11:15:01 +02:00
committed by GitHub
parent 7b77bbf243
commit e2844d1909
60 changed files with 3912 additions and 182 deletions
+14 -1
View File
@@ -2,6 +2,8 @@ const { body, param, validationResult } = require('express-validator');
const validator = require('validator');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('./emailNormalization');
const { REACTION_EMOJIS } = require('../constants/reactions');
const { COLOR_LABELS } = require('../constants/colorLabels');
const { KEYBIND_MODES } = require('../services/feedbackDefaults');
/**
* Validation rules for feedback submission
@@ -134,7 +136,7 @@ function getValidationRules(feedbackType) {
*/
const validateFeedbackSubmission = [
body('feedback_type')
.isIn(['rating', 'like', 'comment', 'favorite', 'reaction'])
.isIn(['rating', 'like', 'comment', 'favorite', 'reaction', 'color_label'])
.withMessage('Invalid feedback type'),
// Conditional validation based on feedback type. 0 clears the guest's
@@ -151,6 +153,14 @@ const validateFeedbackSubmission = [
.if(body('feedback_type').equals('reaction'))
.custom((value) => REACTION_EMOJIS.includes(value))
.withMessage('Invalid reaction'),
// Colour labels (#1044): Lightroom's five colours only — the value ends up
// in an XMP field Lightroom parses, so free-form strings are rejected here
// rather than sanitised later.
body('color_label')
.if(body('feedback_type').equals('color_label'))
.custom((value) => COLOR_LABELS.includes(value))
.withMessage('Invalid color label'),
body('comment_text')
.if(body('feedback_type').equals('comment'))
@@ -194,6 +204,9 @@ const validateFeedbackSettings = [
body('allow_comments').optional().isBoolean(),
body('allow_favorites').optional().isBoolean(),
body('allow_reactions').optional().isBoolean(),
body('allow_color_labels').optional().isBoolean(),
body('keybind_mode').optional().isIn(KEYBIND_MODES)
.withMessage(`keybind_mode must be one of: ${KEYBIND_MODES.join(', ')}`),
body('require_name_email').optional().isBoolean(),
body('moderate_comments').optional().isBoolean(),
body('show_feedback_to_guests').optional().isBoolean(),
+71 -3
View File
@@ -3,6 +3,23 @@
* Builds Knex queries for filtering photos by feedback metrics
*/
const { COLOR_LABELS } = require('../constants/colorLabels');
/**
* Accept a colour filter as an array or a comma-separated string, drop
* anything that isn't one of the five known colours, and de-duplicate.
*/
function normalizeColorLabels(value) {
if (!value) return [];
const raw = Array.isArray(value) ? value : String(value).split(',');
const seen = new Set();
for (const entry of raw) {
const color = String(entry).trim().toLowerCase();
if (COLOR_LABELS.includes(color)) seen.add(color);
}
return [...seen];
}
class PhotoFilterBuilder {
constructor(queryBuilder, eventId) {
this.query = queryBuilder;
@@ -21,6 +38,9 @@ class PhotoFilterBuilder {
has_favorites,
min_favorites,
has_comments,
color_labels,
my_color_labels,
admin_id,
category_id,
logic = 'AND'
} = filters;
@@ -59,6 +79,36 @@ class PhotoFilterBuilder {
conditions.push(builder => builder.where('photos.comment_count', '>', 0));
}
// Colour labels (#1044): "only the greens". Can't read a denormalized
// count — "has any label" and "has a GREEN label" are different questions
// — so this is an EXISTS over photo_feedback, covered by the
// photo_feedback_color_label_idx index from migration 180.
const requestedColors = normalizeColorLabels(color_labels);
if (requestedColors.length > 0) {
conditions.push(builder => builder.whereExists(function () {
this.select('*')
.from('photo_feedback')
.whereRaw('photo_feedback.photo_id = photos.id')
.where('photo_feedback.feedback_type', 'color_label')
.where('photo_feedback.is_hidden', false)
.whereIn('photo_feedback.color_label', requestedColors);
}));
}
// The same question against the caller's own marks (#1044 follow-up).
// Requires admin_id: without one this would filter by every admin's marks
// at once, so it is skipped rather than silently widened.
const requestedMyColors = normalizeColorLabels(my_color_labels);
if (requestedMyColors.length > 0 && admin_id) {
conditions.push(builder => builder.whereExists(function () {
this.select('*')
.from('photo_admin_marks')
.whereRaw('photo_admin_marks.photo_id = photos.id')
.where('photo_admin_marks.admin_id', admin_id)
.whereIn('photo_admin_marks.color_label', requestedMyColors);
}));
}
if (category_id) {
conditions.push(builder => builder.where('photos.category_id', category_id));
}
@@ -143,18 +193,36 @@ class PhotoFilterBuilder {
db.raw('COUNT(CASE WHEN average_rating > 0 THEN 1 END) as with_ratings'),
db.raw('COUNT(CASE WHEN like_count > 0 THEN 1 END) as with_likes'),
db.raw('COUNT(CASE WHEN favorite_count > 0 THEN 1 END) as with_favorites'),
db.raw('COUNT(CASE WHEN comment_count > 0 THEN 1 END) as with_comments')
db.raw('COUNT(CASE WHEN comment_count > 0 THEN 1 END) as with_comments'),
db.raw('COUNT(CASE WHEN color_label_count > 0 THEN 1 END) as with_color_labels')
)
.first();
// Per-colour totals for the filter chips (#1044) — the swatch row shows
// "Green 42" so the photographer knows which colours are worth filtering.
const colorRows = await db('photo_feedback')
.where({ event_id: eventId, feedback_type: 'color_label' })
.where('is_hidden', false)
.groupBy('color_label')
.select('color_label')
.countDistinct('photo_id as count');
const colorLabelCounts = {};
for (const color of COLOR_LABELS) colorLabelCounts[color] = 0;
for (const row of colorRows) {
if (row.color_label) colorLabelCounts[row.color_label] = parseInt(row.count) || 0;
}
return {
total: parseInt(result.total) || 0,
withRatings: parseInt(result.with_ratings) || 0,
withLikes: parseInt(result.with_likes) || 0,
withFavorites: parseInt(result.with_favorites) || 0,
withComments: parseInt(result.with_comments) || 0
withComments: parseInt(result.with_comments) || 0,
withColorLabels: parseInt(result.with_color_labels) || 0,
colorLabelCounts
};
}
}
module.exports = { PhotoFilterBuilder };
module.exports = { PhotoFilterBuilder, normalizeColorLabels };