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