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:
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user