diff --git a/backend/__tests__/integration/categoryReorder.test.js b/backend/__tests__/integration/categoryReorder.test.js new file mode 100644 index 00000000..48f6f06c --- /dev/null +++ b/backend/__tests__/integration/categoryReorder.test.js @@ -0,0 +1,211 @@ +/** + * Layered per-event category ordering (#782). + * + * Two ordering layers, resolved per event: + * - GLOBAL default — photo_categories.display_order (migration 159), + * set via POST /reorder-global; applies everywhere. + * - PER-EVENT override — event_category_order (migration 160), set via + * POST /reorder; overrides the default for one gallery. + * - DELETE /reorder/:eventId clears an event's override. + * + * Verified against a real SQLite DB with the full core-migration set applied. + */ +const request = require('supertest'); +const { + bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp, +} = require('./helpers/crmDb'); + +jest.setTimeout(30000); + +describe('category ordering (#782)', () => { + let db; + let cleanup; + let token; + let app; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + const { adminId } = await seedMinimal(db); + await assignAdminRole(db, adminId, 'super_admin'); + token = mintAdminToken(adminId); + app = buildRouteApp('/api/admin/categories', require('../../src/routes/adminCategories')); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + const auth = (r) => r.set('Authorization', `Bearer ${token}`); + + async function insertEvent(slug) { + await db('events').insert({ + event_type: 'wedding', password_hash: 'x', + expires_at: new Date(Date.now() + 9e9).toISOString(), + is_active: true, is_archived: false, slug, share_link: slug, + event_name: slug, event_date: '2026-01-01', + }); + return (await db('events').where({ slug }).first()).id; + } + + async function insertCat(name, { is_global = false, event_id = null, display_order = 0 } = {}) { + const res = await db('photo_categories').insert({ + name, + slug: name.toLowerCase().replace(/\s+/g, '-'), + is_global: is_global ? 1 : 0, + event_id, + display_order, + }).returning('id'); + return res[0]?.id ?? res[0]; + } + + const getEvent = (eventId) => auth(request(app).get(`/api/admin/categories/event/${eventId}`)).expect(200); + + describe('migration 159 backfill', () => { + it('seeds display_order from alphabetical order, scoped per event', async () => { + const eventId = await insertEvent('backfill-ev'); + await insertCat('Reception', { event_id: eventId }); + await insertCat('Ceremony', { event_id: eventId }); + await insertCat('Pre-Ceremony', { event_id: eventId }); + + // Re-run the migration: addColumn is guarded (no-op); the backfill loop + // re-runs and assigns per-scope alphabetical order — what an upgrade does. + await require('../../migrations/core/159_add_category_display_order').up(db); + + const evCats = await db('photo_categories').where({ event_id: eventId }).orderBy('display_order', 'asc'); + expect(evCats.map((c) => c.name)).toEqual(['Ceremony', 'Pre-Ceremony', 'Reception']); + expect(evCats.map((c) => c.display_order)).toEqual([1, 2, 3]); + }); + }); + + describe('global default order (POST /reorder-global)', () => { + it('reverses the global order and every non-customised event follows it', async () => { + const before = (await auth(request(app).get('/api/admin/categories/global')).expect(200)).body; + expect(before.length).toBeGreaterThan(1); + const reversedIds = before.map((c) => c.id).reverse(); + + const res = await auth(request(app).post('/api/admin/categories/reorder-global')) + .send({ orderedIds: reversedIds }) + .expect(200); + expect(res.body.map((c) => c.id)).toEqual(reversedIds); + + // A fresh event (no override) shows globals in the new global order. + const eventId = await insertEvent('follows-global'); + const globalsInEvent = (await getEvent(eventId)).body.filter((c) => c.is_global).map((c) => c.id); + expect(globalsInEvent).toEqual(reversedIds); + }); + }); + + describe('per-event override (POST /reorder)', () => { + it('pins a custom order for one event without affecting another', async () => { + const eventA = await insertEvent('override-a'); + const eventB = await insertEvent('override-b'); + const a1 = await insertCat('A-Ceremony', { event_id: eventA }); + const a2 = await insertCat('A-Reception', { event_id: eventA }); + + // Current resolved list for A (globals + A's two categories). + const listA = (await getEvent(eventA)).body; + // Put A-Reception first, then A-Ceremony, then the globals in their order. + const globalsA = listA.filter((c) => c.is_global).map((c) => c.id); + const desired = [a2, a1, ...globalsA]; + + const res = await auth(request(app).post('/api/admin/categories/reorder')) + .send({ event_id: eventA, orderedIds: desired }) + .expect(200); + expect(res.body.map((c) => c.id)).toEqual(desired); + // override_position is set on every row for a customised event. + expect(res.body.every((c) => c.override_position != null)).toBe(true); + + // Event B is untouched — no override, follows the global default. + const listB = (await getEvent(eventB)).body; + expect(listB.every((c) => c.override_position == null)).toBe(true); + }); + + it('accepts global ids but rejects another event’s category', async () => { + const eventId = await insertEvent('scope-ev'); + const own = await insertCat('Own', { event_id: eventId }); + const global = (await db('photo_categories').where('is_global', 1).first()).id; + const foreign = await insertCat('Foreign', { event_id: await insertEvent('other-ev') }); + + // A global id is allowed (globals can be arranged per event). + await auth(request(app).post('/api/admin/categories/reorder')) + .send({ event_id: eventId, orderedIds: [own, global] }) + .expect(200); + + // A foreign event's category is out of scope. + await auth(request(app).post('/api/admin/categories/reorder')) + .send({ event_id: eventId, orderedIds: [own, foreign] }) + .expect(400); + }); + }); + + describe('reset (DELETE /reorder/:eventId)', () => { + it('clears the override and reverts to the global default', async () => { + const eventId = await insertEvent('reset-ev'); + const c1 = await insertCat('R-One', { event_id: eventId }); + const list = (await getEvent(eventId)).body; + const globals = list.filter((c) => c.is_global).map((c) => c.id); + + await auth(request(app).post('/api/admin/categories/reorder')) + .send({ event_id: eventId, orderedIds: [c1, ...globals] }) + .expect(200); + expect((await getEvent(eventId)).body.some((c) => c.override_position != null)).toBe(true); + + const res = await auth(request(app).delete(`/api/admin/categories/reorder/${eventId}`)).expect(200); + expect(res.body.every((c) => c.override_position == null)).toBe(true); + expect(await db('event_category_order').where({ event_id: eventId }).first()).toBeUndefined(); + }); + }); + + describe('event ownership (PR #790 review)', () => { + let limitedToken; + let foreignEventId; + + beforeAll(async () => { + const bcrypt = require('bcrypt'); + // A non-super_admin role that DOES hold settings.view + settings.edit — + // the exact case the review flagged (settings.edit is grantable). + const roleRes = await db('roles').insert({ name: 'gallery-mgr', display_name: 'Gallery Mgr' }).returning('id'); + const roleId = roleRes[0]?.id ?? roleRes[0]; + const permIds = await db('permissions').whereIn('name', ['settings.view', 'settings.edit']).pluck('id'); + await db('role_permissions').insert(permIds.map((permission_id) => ({ role_id: roleId, permission_id }))); + + const a2 = await db('admin_users').insert({ + username: 'limited', email: 'limited@example.com', + password_hash: await bcrypt.hash('x', 4), role_id: roleId, + must_change_password: false, created_at: new Date(), + }).returning('id'); + limitedToken = mintAdminToken(a2[0]?.id ?? a2[0]); + + // An event owned by a DIFFERENT admin (the seeded super_admin). + const owner = (await db('admin_users').where({ username: 'tester' }).first()).id; + await db('events').insert({ + event_type: 'wedding', password_hash: 'x', + expires_at: new Date(Date.now() + 9e9).toISOString(), + is_active: true, is_archived: false, slug: 'owned-ev', share_link: 'owned-ev', + event_name: 'Owned', event_date: '2026-01-01', created_by: owner, + }); + foreignEventId = (await db('events').where({ slug: 'owned-ev' }).first()).id; + }); + + const limitedAuth = (r) => r.set('Authorization', `Bearer ${limitedToken}`); + + it('blocks a non-owner from reading, reordering or resetting another event', async () => { + await limitedAuth(request(app).get(`/api/admin/categories/event/${foreignEventId}`)).expect(403); + await limitedAuth(request(app).post('/api/admin/categories/reorder')) + .send({ event_id: foreignEventId, orderedIds: [1] }).expect(403); + await limitedAuth(request(app).delete(`/api/admin/categories/reorder/${foreignEventId}`)).expect(403); + }); + }); + + describe('POST / (create) appends to the end of its scope', () => { + it('assigns display_order = max + 1 within the event', async () => { + const eventId = await insertEvent('append-ev'); + await insertCat('First', { event_id: eventId, display_order: 1 }); + await insertCat('Second', { event_id: eventId, display_order: 2 }); + + const res = await auth(request(app).post('/api/admin/categories')) + .send({ name: 'Third', is_global: false, event_id: eventId }) + .expect(200); + + expect(res.body.display_order).toBe(3); + }); + }); +}); diff --git a/backend/migrations/core/159_add_category_display_order.js b/backend/migrations/core/159_add_category_display_order.js new file mode 100644 index 00000000..78c5cbf8 --- /dev/null +++ b/backend/migrations/core/159_add_category_display_order.js @@ -0,0 +1,57 @@ +/** + * Migration 159: 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') + ); + } +}; diff --git a/backend/migrations/core/160_add_event_category_order.js b/backend/migrations/core/160_add_event_category_order.js new file mode 100644 index 00000000..f244d230 --- /dev/null +++ b/backend/migrations/core/160_add_event_category_order.js @@ -0,0 +1,46 @@ +/** + * Migration 160: per-event category order override (#782). + * + * Builds on migration 159 (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'); + } +}; diff --git a/backend/src/routes/adminCategories.js b/backend/src/routes/adminCategories.js index 99bd811f..809cbd43 100644 --- a/backend/src/routes/adminCategories.js +++ b/backend/src/routes/adminCategories.js @@ -4,6 +4,8 @@ const { db, logActivity } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +const { requireEventOwnership } = require('../middleware/ownership'); +const { getEventCategoriesOrdered } = require('../utils/categoryOrder'); const logger = require('../utils/logger'); const router = express.Router(); @@ -12,8 +14,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 +24,12 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req, } }); -// Get categories for a specific event (global + event-specific) -router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => { +// 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'), requireEventOwnership, 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 +77,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 +265,133 @@ 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)); + + // Event ownership (event_id comes from the body, so requireEventOwnership — + // which reads req.params — can't be used here). Mirror it: super_admins + // bypass; other admins may only reorder events they own (ownerless + // legacy/system events allowed). + if (req.admin.roleName !== 'super_admin') { + const event = await db('events').where('id', eventId).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + if (event.created_by && event.created_by !== req.admin.id) { + return res.status(403).json({ error: 'Access denied' }); + } + } + + // 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'), requireEventOwnership, 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; \ No newline at end of file diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index cdb082df..13e206e6 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -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, diff --git a/backend/src/utils/categoryOrder.js b/backend/src/utils/categoryOrder.js new file mode 100644 index 00000000..2cabddd7 --- /dev/null +++ b/backend/src/utils/categoryOrder.js @@ -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 159); + * 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 }; diff --git a/frontend/src/components/admin/CategoryManager.tsx b/frontend/src/components/admin/CategoryManager.tsx index efc1ee2e..786d28ad 100644 --- a/frontend/src/components/admin/CategoryManager.tsx +++ b/frontend/src/components/admin/CategoryManager.tsx @@ -1,6 +1,6 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react'; +import { Plus, Edit2, Trash2, Loader2, ArrowUp, ArrowDown } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { categoriesService, type PhotoCategory } from '../../services/categories.service'; import { Button } from '../common'; @@ -13,12 +13,36 @@ export const CategoryManager: React.FC = () => { const [newCategoryName, setNewCategoryName] = useState(''); const [editingName, setEditingName] = useState(''); - // Fetch global categories + // Fetch global categories (ordered by the global default display_order) const { data: categories = [], isLoading } = useQuery({ queryKey: ['global-categories'], queryFn: categoriesService.getGlobalCategories, }); + // Local copy so the up/down reorder buttons feel instant; resynced when the + // query data changes. + const [ordered, setOrdered] = useState(categories); + useEffect(() => { + setOrdered(categories); + }, [categories]); + + // Set the GLOBAL default order (#782). Applies to every gallery that hasn't + // set its own per-event override. + const reorderMutation = useMutationWithToast({ + mutationFn: (orderedIds: number[]) => categoriesService.reorderGlobalCategories(orderedIds), + invalidateKeys: [['global-categories']], + errorMessage: t('categories.failedToReorder', 'Failed to update category order'), + }); + + const handleMove = (index: number, dir: -1 | 1) => { + const target = index + dir; + if (target < 0 || target >= ordered.length) return; + const next = [...ordered]; + [next[index], next[target]] = [next[target], next[index]]; + setOrdered(next); // optimistic + reorderMutation.mutate(next.map((c) => c.id)); + }; + // Create category mutation const createMutation = useMutationWithToast({ mutationFn: (name: string) => @@ -144,12 +168,12 @@ export const CategoryManager: React.FC = () => { {/* Categories list */}
- {categories.length === 0 ? ( + {ordered.length === 0 ? (

{t('categories.noCategoriesYet')}

) : ( - categories.map((category) => ( + ordered.map((category, index) => (
{
) : ( <> -
-

{category.name}

-

/{category.slug}

+
+ {/* Global default order (#782). The gallery uses this order + unless a specific event overrides it. */} +
+ + +
+
+

{category.name}

+

/{category.slug}

+
- )} +
+

{t('categories.galleryOrder', 'Gallery order')}

+
+ {isCustomised && ( + + )} + {!addingModal.isOpen && ( + + )} +
- {/* Hint about hero photo fallback */} + {/* Explain the two ordering layers */}

- {t('categories.categoryHeroHint')} + {isCustomised + ? t('categories.orderCustomisedHint', 'This gallery uses a custom order. Reset to follow the global default (Settings → Photo Categories).') + : t('categories.orderDefaultHint', 'Use the arrows to set the order for this gallery. Otherwise it follows the global default (Settings → Photo Categories).')}

{/* Add new category form */} @@ -152,11 +198,7 @@ export const EventCategoryManager: React.FC = ({ even onClick={handleCreate} disabled={!newCategoryName.trim() || createMutation.isPending} > - {createMutation.isPending ? ( - - ) : ( - t('common.add') - )} + {createMutation.isPending ? : t('common.add')}
)} - {/* Event categories list */} - {eventCategories.length === 0 ? ( + {/* Combined, reorderable category list (globals + event-specific) */} + {ordered.length === 0 ? (

{t('categories.noEventSpecificCategories')}

) : (
- {eventCategories.map((category) => { + {ordered.map((category, index) => { const heroPhoto = category.hero_photo_id ? photos.find(p => p.id === category.hero_photo_id) : null; @@ -187,7 +229,30 @@ export const EventCategoryManager: React.FC = ({ even key={category.id} className="flex items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md" > -
+
+ {/* Reorder controls (#782). The gallery renders categories in + this order; changes here override the global default for + this event only. */} +
+ + +
{/* Hero photo thumbnail */} {category.name} + {category.is_global && ( + + {t('categories.sharedBadge', 'Shared')} + + )}
- {/* Per-category downloads toggle (#640). Green DownloadCloud - icon when on, struck-through outline when off. The - event-level `allow_downloads` AND's with this — if the - whole event has downloads off, this toggle is cosmetic. */} - - + {/* Download toggle + delete apply to event-specific categories + only. Global categories are managed in Settings. */} + {!category.is_global && ( + <> + + + + )}
); @@ -257,41 +329,10 @@ export const EventCategoryManager: React.FC = ({ even
)} - {/* Show available global categories */} -
-

{t('categories.globalCategoriesAlwaysAvailable')}

-
- {categories - .filter(cat => cat.is_global) - .map(cat => { - const heroPhoto = cat.hero_photo_id - ? photos.find(p => p.id === cat.hero_photo_id) - : null; - return ( -
- - {cat.name} -
- ); - })} -
-
+ {/* Hint about hero photo fallback */} +

+ {t('categories.categoryHeroHint')} +

{/* Hero Photo Picker Modal */} {heroPickerCategoryId !== null && ( @@ -317,7 +358,7 @@ export const EventCategoryManager: React.FC = ({ even ) : (
{photos.map((photo) => { - const currentCategory = categories.find(c => c.id === heroPickerCategoryId); + const currentCategory = ordered.find(c => c.id === heroPickerCategoryId); const isSelected = photo.id === currentCategory?.hero_photo_id; return (
= ({ even />
{isSelected && ( -
+
)} @@ -352,7 +393,7 @@ export const EventCategoryManager: React.FC = ({ even
- {categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && ( + {ordered.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (