Files
picpeak/backend/src/routes/adminCategories.js
T
Luca a4b4485d32 fix(categories): address PR #790 review — event ownership, migration renumber, nits
- 🔴 Event ownership: GET /event/:eventId and DELETE /reorder/:eventId now use
  requireEventOwnership; POST /reorder (event_id in body) gets the equivalent
  inline check (super_admin bypasses; others limited to owned/ownerless events).
  New test covers a settings.edit-holding non-super_admin blocked (403) on all
  three per-event routes.
- 🔴 Migration renumber: 158→159, 159→160 (upstream #788 already took 158);
  headers + the test's require path updated.
- 🟢 Nits: stale inline "Drag the arrows" fallback → "Use the arrows" (matches
  en.json; control is click-only); invalid bg-accent-dark/150 → bg-accent-dark.
2026-07-10 20:10:40 +02:00

397 lines
14 KiB
JavaScript

const express = require('express');
const { body, validationResult } = require('express-validator');
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();
// Get all global categories
router.get('/global', adminAuth, requirePermission('settings.view'), async (req, res) => {
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);
res.status(500).json({ error: 'Failed to fetch categories' });
}
});
// 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 categories = await getEventCategoriesOrdered(req.params.eventId);
res.json(categories);
} catch (error) {
logger.error('Error fetching event categories:', error);
res.status(500).json({ error: 'Failed to fetch categories' });
}
});
// Create a new category
router.post('/', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().withMessage('Category name is required'),
body('slug').optional(),
body('is_global').optional().isBoolean(),
body('event_id').optional().isInt()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { name, slug, is_global = true, event_id = null } = req.body;
// Generate slug if not provided
const categorySlug = slug || name
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
// Check if slug already exists for this scope
const existing = await db('photo_categories')
.where('slug', categorySlug)
.where(function() {
if (is_global) {
this.where('is_global', formatBoolean(true));
} else {
this.where('event_id', event_id);
}
})
.first();
if (existing) {
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,
display_order: nextOrder
}).returning('id');
const categoryId = insertResult[0]?.id || insertResult[0];
const category = await db('photo_categories').where('id', categoryId).first();
// Log activity
await logActivity('category_created',
{ categoryName: name, isGlobal: is_global },
event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(category);
} catch (error) {
logger.error('Error creating category:', error);
res.status(500).json({ error: 'Failed to create category' });
}
});
// Update a category
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().withMessage('Category name is required'),
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
if (value === null || value === undefined) return true;
return Number.isInteger(Number(value));
}).withMessage('hero_photo_id must be an integer or null'),
body('allow_downloads').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { name, hero_photo_id } = req.body;
const category = await db('photo_categories').where('id', id).first();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
const updateData = {
name,
slug: name
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
};
// Update hero_photo_id if provided (including null to clear it)
if (Object.prototype.hasOwnProperty.call(req.body, 'hero_photo_id')) {
updateData.hero_photo_id = hero_photo_id || null;
}
// Per-category download permission (#640). AND with event-level allow_downloads.
if (Object.prototype.hasOwnProperty.call(req.body, 'allow_downloads')) {
updateData.allow_downloads = req.body.allow_downloads;
}
await db('photo_categories')
.where('id', id)
.update(updateData);
const updated = await db('photo_categories').where('id', id).first();
// Log activity
await logActivity('category_updated',
{ categoryName: name, heroPhotoId: hero_photo_id },
category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(updated);
} catch (error) {
logger.error('Error updating category:', error);
res.status(500).json({ error: 'Failed to update category' });
}
});
// Set category hero photo (#163)
router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
if (value === null || value === undefined) return true;
return Number.isInteger(Number(value));
}).withMessage('hero_photo_id must be an integer or null')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { hero_photo_id } = req.body;
const category = await db('photo_categories').where('id', id).first();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
// If hero_photo_id is provided, verify it belongs to a photo in this category
if (hero_photo_id) {
const photo = await db('photos').where('id', hero_photo_id).first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
}
await db('photo_categories')
.where('id', id)
.update({ hero_photo_id: hero_photo_id || null });
const updated = await db('photo_categories').where('id', id).first();
// Log activity
await logActivity('category_hero_updated',
{ categoryName: category.name, heroPhotoId: hero_photo_id },
category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(updated);
} catch (error) {
logger.error('Error updating category hero:', error);
res.status(500).json({ error: 'Failed to update category hero' });
}
});
// Delete a category
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const { id } = req.params;
const category = await db('photo_categories').where('id', id).first();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
// Check if category has photos
const photoCount = await db('photos').where('category_id', id).count('id as count').first();
if (photoCount.count > 0) {
return res.status(400).json({
error: 'Cannot delete category with photos. Please reassign photos first.'
});
}
await db('photo_categories').where('id', id).delete();
// Log activity
await logActivity('category_deleted',
{ categoryName: category.name },
category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Category deleted successfully' });
} catch (error) {
logger.error('Error deleting category:', error);
res.status(500).json({ error: 'Failed to delete category' });
}
});
// 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;