a4b4485d32
- 🔴 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.
397 lines
14 KiB
JavaScript
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; |