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
@@ -0,0 +1,94 @@
/**
* Colour labels for client proofing (#1044).
*
* Structurally identical to emoji reactions (migration 164): ONE value per
* guest per photo, changeable, the same value again toggles it off, drawn
* from a fixed curated set (constants/colorLabels.js). No new table.
*
* - event_feedback_settings.allow_color_labels: per-event toggle next to
* allow_reactions. Defaults FALSE, unlike its siblings — a colour bar
* appearing unannounced in every gallery that already has feedback enabled
* would be a visible change to live galleries mid-proofing, so this one is
* opt-in. New events inherit the global `event_default_allow_color_labels`
* via services/feedbackDefaults.js.
* - event_feedback_settings.keybind_mode: which lightbox shortcut scheme the
* gallery uses — 'colors' (1/2/3 = green/yellow/red, simplest for clients)
* or 'lightroom' (1-5 stars, 6-9 colours, identical muscle memory for
* photographers). See constants/colorLabels.js for the maps.
* - photo_feedback.color_label: the colour for feedback_type='color_label'
* rows, validated against COLOR_LABELS.
* - photos.color_label_count: denormalized total, maintained by
* updatePhotoFeedbackStats alongside like_count / reaction_count.
* - photo_feedback_color_label_idx: the admin "show only the greens" filter
* runs as a whereExists over (event_id, feedback_type, color_label).
*/
exports.up = async function (knex) {
const hasAllowColorLabels = await knex.schema.hasColumn('event_feedback_settings', 'allow_color_labels');
if (!hasAllowColorLabels) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.boolean('allow_color_labels').defaultTo(false);
});
}
const hasKeybindMode = await knex.schema.hasColumn('event_feedback_settings', 'keybind_mode');
if (!hasKeybindMode) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.string('keybind_mode', 16).defaultTo('colors');
});
}
const hasColorLabel = await knex.schema.hasColumn('photo_feedback', 'color_label');
if (!hasColorLabel) {
await knex.schema.alterTable('photo_feedback', (table) => {
// Lightroom's colour names, lowercase: red/yellow/green/blue/purple.
table.string('color_label', 16);
});
}
// Outside the column guard on purpose: a run that died between the two
// statements would otherwise leave the column present and the index missing,
// and a re-run would skip both — silently costing the admin colour filter
// the index this migration's header says it relies on. IF NOT EXISTS is
// supported by Postgres and by SQLite (verified idempotent on 3.44), so this
// is safe to re-run in any state.
await knex.raw(
'CREATE INDEX IF NOT EXISTS photo_feedback_color_label_idx '
+ 'ON photo_feedback (event_id, feedback_type, color_label)'
);
const hasColorLabelCount = await knex.schema.hasColumn('photos', 'color_label_count');
if (!hasColorLabelCount) {
await knex.schema.alterTable('photos', (table) => {
table.integer('color_label_count').defaultTo(0);
});
}
};
exports.down = async function (knex) {
if (await knex.schema.hasColumn('photos', 'color_label_count')) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('color_label_count');
});
}
// Drop the index first, and unconditionally: SQLite rebuilds the table on
// dropColumn and a lingering index over the dropped column makes that
// rebuild fail. Unconditional so a down() after a partial up() still clears
// whichever half landed.
await knex.raw('DROP INDEX IF EXISTS photo_feedback_color_label_idx');
if (await knex.schema.hasColumn('photo_feedback', 'color_label')) {
await knex.schema.alterTable('photo_feedback', (table) => {
table.dropColumn('color_label');
});
}
if (await knex.schema.hasColumn('event_feedback_settings', 'keybind_mode')) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.dropColumn('keybind_mode');
});
}
if (await knex.schema.hasColumn('event_feedback_settings', 'allow_color_labels')) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.dropColumn('allow_color_labels');
});
}
};
@@ -0,0 +1,48 @@
/**
* Photographer-side stars and colour labels (#1044 follow-up).
*
* The photographer triages their own shoot with the same 1-5 stars and five
* Lightroom colours the client uses while proofing — but their marks are a
* SEPARATE table rather than more photo_feedback rows, for one reason:
* photo_feedback is read by a long tail of guest-facing queries (the gallery
* payload, the per-photo tallies, the denormalized photos.average_rating /
* *_count columns, the feedback exports, moderation). Adding admin rows there
* would leak the photographer's own opinions into the client's proofing view
* through whichever of those queries someone forgot to filter — and "forgot to
* filter" is exactly the failure this shape makes impossible.
*
* One row per (photo, admin): rating and colour live together because they are
* one person's verdict on one photo, and a row with neither is deleted rather
* than kept as a tombstone.
*/
exports.up = async function (knex) {
const hasTable = await knex.schema.hasTable('photo_admin_marks');
if (!hasTable) {
await knex.schema.createTable('photo_admin_marks', (table) => {
table.increments('id').primary();
table.integer('photo_id').notNullable()
.references('id').inTable('photos').onDelete('CASCADE');
// Denormalized from photos.event_id so the per-event filter and the
// count queries don't have to join photos on every admin grid render.
table.integer('event_id').notNullable();
table.integer('admin_id').notNullable();
table.integer('rating'); // 1-5, NULL = no star rating
table.string('color_label', 16); // NULL = no colour
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// One verdict per photo per admin. Two admins on the same event keep
// their own marks; the same admin marking twice updates in place.
table.unique(['photo_id', 'admin_id'], 'photo_admin_marks_photo_admin_uniq');
table.index(['event_id', 'admin_id'], 'photo_admin_marks_event_admin_idx');
table.index(['event_id', 'color_label'], 'photo_admin_marks_color_idx');
});
}
};
exports.down = async function (knex) {
if (await knex.schema.hasTable('photo_admin_marks')) {
await knex.schema.dropTable('photo_admin_marks');
}
};