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:
@@ -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 A–Z 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');
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
@@ -12,8 +13,9 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
|
||||
try {
|
||||
const categories = await db('photo_categories')
|
||||
.where('is_global', formatBoolean(true))
|
||||
.orderBy('display_order', 'asc')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching categories:', error);
|
||||
@@ -21,19 +23,12 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// Get categories for a specific event (global + event-specific)
|
||||
// Get categories for a specific event (global + event-specific), resolved to
|
||||
// the event's effective order: per-event override, else global default, else
|
||||
// name (#782). Each row carries `override_position` (null when not customised).
|
||||
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
const categories = await db('photo_categories')
|
||||
.where(function() {
|
||||
this.where('is_global', formatBoolean(true))
|
||||
.orWhere('event_id', eventId);
|
||||
})
|
||||
.orderBy('is_global', 'desc')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
const categories = await getEventCategoriesOrdered(req.params.eventId);
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event categories:', error);
|
||||
@@ -81,12 +76,27 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
return res.status(400).json({ error: 'Category with this slug already exists' });
|
||||
}
|
||||
|
||||
// Append to the end of its scope so a new category doesn't jump to the
|
||||
// top of an admin-defined order (#782).
|
||||
const maxRow = await db('photo_categories')
|
||||
.where(function() {
|
||||
if (is_global) {
|
||||
this.where('is_global', formatBoolean(true));
|
||||
} else {
|
||||
this.where('event_id', event_id);
|
||||
}
|
||||
})
|
||||
.max('display_order as maxOrder')
|
||||
.first();
|
||||
const nextOrder = (maxRow?.maxOrder || 0) + 1;
|
||||
|
||||
// Create category
|
||||
const insertResult = await db('photo_categories').insert({
|
||||
name,
|
||||
slug: categorySlug,
|
||||
is_global,
|
||||
event_id: is_global ? null : event_id
|
||||
event_id: is_global ? null : event_id,
|
||||
display_order: nextOrder
|
||||
}).returning('id');
|
||||
|
||||
const categoryId = insertResult[0]?.id || insertResult[0];
|
||||
@@ -254,4 +264,119 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// Set a per-event category order override (#782). The client sends the full
|
||||
// ordered id list for THIS event — globals + event-specific, interleaved — and
|
||||
// we replace the event's override rows in one transaction. This overrides the
|
||||
// global default order for this gallery only.
|
||||
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
|
||||
body('event_id').isInt().withMessage('event_id must be an integer'),
|
||||
body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'),
|
||||
body('orderedIds.*').isInt().withMessage('Each id must be an integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const eventId = parseInt(req.body.event_id, 10);
|
||||
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
|
||||
|
||||
// Every id must be a category available to this event: a shared global OR
|
||||
// one of the event's own categories. Anything else is out of scope.
|
||||
const available = await db('photo_categories')
|
||||
.where(function() {
|
||||
this.where('is_global', formatBoolean(true)).orWhere('event_id', eventId);
|
||||
})
|
||||
.pluck('id');
|
||||
const availableSet = new Set(available);
|
||||
const invalid = orderedIds.filter((id) => !availableSet.has(id));
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({ error: 'One or more categories are not available for this event' });
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('event_category_order').where('event_id', eventId).del();
|
||||
await trx('event_category_order').insert(
|
||||
orderedIds.map((id, i) => ({ event_id: eventId, category_id: id, position: i + 1 }))
|
||||
);
|
||||
});
|
||||
|
||||
// Log activity after commit (avoids a SQLite in-transaction global write).
|
||||
await logActivity('event_category_order_set',
|
||||
{ eventId, count: orderedIds.length },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json(await getEventCategoriesOrdered(eventId));
|
||||
} catch (error) {
|
||||
logger.error('Error reordering categories:', error);
|
||||
res.status(500).json({ error: 'Failed to reorder categories' });
|
||||
}
|
||||
});
|
||||
|
||||
// Clear an event's override — revert this gallery to the global default order.
|
||||
router.delete('/reorder/:eventId', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const eventId = parseInt(req.params.eventId, 10);
|
||||
await db('event_category_order').where('event_id', eventId).del();
|
||||
|
||||
await logActivity('event_category_order_reset',
|
||||
{ eventId },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json(await getEventCategoriesOrdered(eventId));
|
||||
} catch (error) {
|
||||
logger.error('Error resetting category order:', error);
|
||||
res.status(500).json({ error: 'Failed to reset category order' });
|
||||
}
|
||||
});
|
||||
|
||||
// Set the GLOBAL default order for shared (global) categories (#782). Applies
|
||||
// to every gallery that hasn't set its own override. Rewrites display_order.
|
||||
router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
|
||||
body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'),
|
||||
body('orderedIds.*').isInt().withMessage('Each id must be an integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
|
||||
|
||||
const globals = await db('photo_categories').where('is_global', formatBoolean(true)).pluck('id');
|
||||
const globalsSet = new Set(globals);
|
||||
const invalid = orderedIds.filter((id) => !globalsSet.has(id));
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({ error: 'One or more categories are not global' });
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
for (let i = 0; i < orderedIds.length; i += 1) {
|
||||
await trx('photo_categories').where('id', orderedIds[i]).update({ display_order: i + 1 });
|
||||
}
|
||||
});
|
||||
|
||||
await logActivity('global_category_order_set',
|
||||
{ count: orderedIds.length },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
const categories = await db('photo_categories')
|
||||
.where('is_global', formatBoolean(true))
|
||||
.orderBy('display_order', 'asc')
|
||||
.orderBy('name', 'asc');
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
logger.error('Error reordering global categories:', error);
|
||||
res.status(500).json({ error: 'Failed to reorder global categories' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -25,6 +25,7 @@ const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
@@ -587,10 +588,12 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
// Fetch category details from photo_categories table
|
||||
let categories = [];
|
||||
if (usedCategoryIds.length > 0) {
|
||||
const categoryDetails = await db('photo_categories')
|
||||
.whereIn('id', usedCategoryIds)
|
||||
.select('id', 'name', 'slug', 'is_global', 'hero_photo_id', 'allow_downloads')
|
||||
.orderBy('name', 'asc');
|
||||
// Resolved category order (#782): per-event override, else global
|
||||
// default, else name — restricted to categories that have photos.
|
||||
const categoryDetails = await getEventCategoriesOrdered(req.event.id, {
|
||||
onlyIds: usedCategoryIds,
|
||||
select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads'],
|
||||
});
|
||||
|
||||
categories = categoryDetails.map(cat => ({
|
||||
id: cat.id,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Category order resolution (#782).
|
||||
*
|
||||
* Resolves an event's categories into their effective display order, layering:
|
||||
* 1. per-event override — event_category_order.position, when the event has
|
||||
* been customised;
|
||||
* 2. the global default — photo_categories.display_order (migration 158);
|
||||
* 3. name.
|
||||
*
|
||||
* Globals and event-specific categories are ordered together so a custom order
|
||||
* can interleave them into the flow of the day. Shared by the admin event view
|
||||
* and the public gallery so the two never diverge.
|
||||
*/
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('./dbCompat');
|
||||
const { hasColumnCached } = require('./schemaCache');
|
||||
|
||||
/**
|
||||
* @param {number|string} eventId
|
||||
* @param {object} [opts]
|
||||
* @param {number[]|null} [opts.onlyIds] restrict to these category ids (the
|
||||
* public gallery only shows categories that actually have photos).
|
||||
* @param {string[]|null} [opts.select] qualified columns to select (default
|
||||
* `c.*`). Always aliased to the `photo_categories as c` table.
|
||||
* @returns rows with an added `override_position` (null when not customised).
|
||||
*/
|
||||
async function getEventCategoriesOrdered(eventId, { onlyIds = null, select = null } = {}) {
|
||||
const eid = parseInt(eventId, 10);
|
||||
|
||||
const base = db('photo_categories as c').where(function () {
|
||||
this.where('c.is_global', formatBoolean(true)).orWhere('c.event_id', eid);
|
||||
});
|
||||
if (onlyIds) base.whereIn('c.id', onlyIds);
|
||||
|
||||
// Fail safe: if the override table isn't present yet (half-applied migration),
|
||||
// fall back to the global-default order so the public gallery never 500s.
|
||||
const overrideReady = await hasColumnCached('event_category_order', 'position');
|
||||
if (!overrideReady) {
|
||||
return base
|
||||
.select(select || 'c.*')
|
||||
.orderBy('c.is_global', 'desc')
|
||||
.orderBy('c.display_order', 'asc')
|
||||
.orderBy('c.name', 'asc');
|
||||
}
|
||||
|
||||
const cols = select ? [...select] : ['c.*'];
|
||||
cols.push('o.position as override_position');
|
||||
|
||||
return base
|
||||
.leftJoin('event_category_order as o', function () {
|
||||
this.on('o.category_id', 'c.id').andOnVal('o.event_id', '=', eid);
|
||||
})
|
||||
.select(cols)
|
||||
// Overridden categories first (in their pinned order), then the rest by the
|
||||
// global default. CASE keeps NULL-ordering portable across SQLite + Postgres.
|
||||
.orderByRaw('CASE WHEN o.position IS NULL THEN 1 ELSE 0 END ASC')
|
||||
.orderBy('o.position', 'asc')
|
||||
.orderBy('c.is_global', 'desc')
|
||||
.orderBy('c.display_order', 'asc')
|
||||
.orderBy('c.name', 'asc');
|
||||
}
|
||||
|
||||
module.exports = { getEventCategoriesOrdered };
|
||||
Reference in New Issue
Block a user