feat(categories): per-event category ordering — global default + override (#782)

Order a gallery's categories in the flow of the day instead of A–Z. Two layers,
resolved per event: per-event override > global default > name.

- migration 158: photo_categories.display_order (global default), backfilled
  from the current alphabetical order so existing galleries don't reshuffle.
- migration 159: event_category_order (event_id, category_id, position) — the
  per-event override; no backfill, every event starts on the default.
- utils/categoryOrder: shared resolution used by the admin event view and the
  public gallery; fails safe to the global default if the table is absent.
- adminCategories: POST /reorder sets a per-event override (globals +
  event-specific, interleaved); DELETE /reorder/:eventId resets; POST
  /reorder-global sets the global default. Ordering endpoints + create append.
- gallery renders the resolved order.
- Settings → Photo Categories reorders the global default; an event's Categories
  tab reorders that gallery (one combined list + Reset to default). Up/down
  buttons — no drag-and-drop dependency.
- en/de strings.
This commit is contained in:
Luca
2026-07-10 16:24:17 +02:00
parent ed0fa3241b
commit 4698402b54
10 changed files with 573 additions and 140 deletions
@@ -0,0 +1,57 @@
/**
* Migration 158: per-event category ordering (#782).
*
* Adds a `display_order` integer to `photo_categories` so photographers can
* arrange an event's categories in the flow of the day (Pre-Ceremony →
* Ceremony → Reception …) instead of the hard-coded AZ order. Mirrors the
* `display_order` column + reorder pattern already used by `event_types`.
*
* Preserve existing galleries: backfill `display_order` from the CURRENT
* (alphabetical) order, scoped — globals numbered together, event-specific
* numbered per event — so nothing reshuffles on upgrade. A custom order is
* opt-in via the admin reorder controls. See feedback: migrations should pin
* previously-implicit defaults onto existing rows.
*
* Backfill runs in JS (not a SQL window function) to stay portable across
* SQLite (dev) and Postgres (prod).
*
* Additive + hasColumn-guarded.
*/
async function addColumn(knex, table, column, builder) {
if (!(await knex.schema.hasColumn(table, column))) {
await knex.schema.alterTable(table, builder);
}
}
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
await addColumn(knex, 'photo_categories', 'display_order', (t) => {
t.integer('display_order').notNullable().defaultTo(0);
t.index('display_order');
});
// Backfill from the current alphabetical order, per scope, so existing
// galleries render exactly as before until an admin reorders.
const cats = await knex('photo_categories')
.select('id', 'name', 'is_global', 'event_id')
.orderBy('name', 'asc');
const counters = {};
for (const c of cats) {
const scope = c.is_global ? 'global' : `event:${c.event_id}`;
counters[scope] = (counters[scope] || 0) + 1;
await knex('photo_categories')
.where('id', c.id)
.update({ display_order: counters[scope] });
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
if (await knex.schema.hasColumn('photo_categories', 'display_order')) {
await knex.schema.alterTable('photo_categories', (t) =>
t.dropColumn('display_order')
);
}
};
@@ -0,0 +1,46 @@
/**
* Migration 159: per-event category order override (#782).
*
* Builds on migration 158 (photo_categories.display_order = the GLOBAL default
* order) by adding a per-event OVERRIDE layer. Global categories are shared
* across every event, so a single display_order can only express one order for
* them. This table lets a single gallery arrange its categories — globals AND
* event-specific, interleaved into the flow of the day — independently of the
* global default.
*
* Resolution (see adminCategories / gallery):
* 1. if the event has override rows -> use override.position;
* 2. else fall back to photo_categories.display_order (the global default);
* 3. else name.
*
* An event is either "using the default" (no rows here) or "customised" (a row
* per category it shows). No backfill: every existing event starts on the
* default order, so nothing reshuffles — a custom order is opt-in per event.
*
* Additive + hasTable-guarded.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
if (await knex.schema.hasTable('event_category_order')) return;
await knex.schema.createTable('event_category_order', (t) => {
t.increments('id').primary();
t.integer('event_id').notNullable()
.references('id').inTable('events').onDelete('CASCADE');
t.integer('category_id').notNullable()
.references('id').inTable('photo_categories').onDelete('CASCADE');
t.integer('position').notNullable().defaultTo(0);
t.timestamp('created_at').defaultTo(knex.fn.now());
// At most one position per (event, category).
t.unique(['event_id', 'category_id']);
// Ordered reads are always scoped to one event.
t.index(['event_id', 'position']);
});
};
exports.down = async function (knex) {
if (await knex.schema.hasTable('event_category_order')) {
await knex.schema.dropTable('event_category_order');
}
};