fix: wire admin photo feedback filters into grid query (#293)

The Has Likes / Has Favorites / Has Comments checkboxes in the admin
Event > Photos tab updated local state but never affected the visible
photo grid, because the feedbackFilters state was only wired to the
export menu and the backend /admin/photos/:eventId/photos endpoint had
no support for these params.

Fixes:
- backend/src/routes/adminPhotos.js: extend GET /:eventId/photos to
  accept has_likes, has_favorites, has_comments, min_rating, and logic
  (AND/OR) query params and apply them via where-clause groups using
  the existing denormalized like_count/favorite_count/comment_count/
  average_rating columns.
- frontend/src/services/photos.service.ts: add hasLikes, hasFavorites,
  hasComments, minRating, logic to the PhotoFilters interface and
  append them as query params in getEventPhotos.
- frontend/src/pages/admin/EventDetailsPage.tsx: merge feedbackFilters
  into combinedPhotoFilters (via useMemo) and key the admin-event-photos
  query on it, so toggling any checkbox refetches with the new params.

Verified end-to-end against local Docker: seeded event with a known
feedback distribution and confirmed
- Has Likes → 4 photos
- Has Favorites → 3 photos
- Likes AND Favorites → 1 photo
- Likes OR Favorites → 6 photos
- Has Comments → 2 photos
- network requests carry the exact query params
This commit is contained in:
Paul Nothaft
2026-04-11 07:46:32 +02:00
parent fe46e4268d
commit d4b4dc628f
3 changed files with 67 additions and 8 deletions
+42 -6
View File
@@ -819,14 +819,15 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
const { category_id, type, search, sort = 'date' } = req.query;
const { category_id, type, search, sort = 'date', has_likes, has_favorites, has_comments, min_rating } = req.query;
const order = ['asc', 'desc'].includes(req.query.order) ? req.query.order : 'desc';
const logic = req.query.logic === 'OR' ? 'OR' : 'AND';
let query = db('photos')
.where({ 'photos.event_id': eventId })
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.select('photos.*', 'photo_categories.name as pc_name', 'photo_categories.slug as pc_slug');
// Filter by category_id
if (category_id !== undefined && category_id !== '' && category_id !== '0') {
if (category_id === 'individual' || category_id === 'collage') {
@@ -843,18 +844,53 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
}
}
}
// Keep type filter for backwards compatibility
if (type) {
query = query.where({ 'photos.type': type });
}
// Search by filename
if (search) {
const escapedSearch = escapeLikePattern(search);
query = query.where('photos.filename', 'like', `%${escapedSearch}%`);
}
// Feedback filters (has likes / favorites / comments / min rating) with AND/OR logic
const feedbackConditions = [];
if (has_likes === 'true' || has_likes === true) {
feedbackConditions.push(qb => qb.where('photos.like_count', '>', 0));
}
if (has_favorites === 'true' || has_favorites === true) {
feedbackConditions.push(qb => qb.where('photos.favorite_count', '>', 0));
}
if (has_comments === 'true' || has_comments === true) {
feedbackConditions.push(qb => qb.where('photos.comment_count', '>', 0));
}
if (min_rating !== undefined && min_rating !== null && min_rating !== '') {
const minRatingNum = parseFloat(min_rating);
if (!isNaN(minRatingNum)) {
feedbackConditions.push(qb => qb.where('photos.average_rating', '>=', minRatingNum));
}
}
if (feedbackConditions.length > 0) {
if (logic === 'OR') {
query = query.where(builder => {
feedbackConditions.forEach((cond, idx) => {
if (idx === 0) {
cond(builder);
} else {
builder.orWhere(sub => cond(sub));
}
});
});
} else {
feedbackConditions.forEach(cond => {
query = query.where(builder => cond(builder));
});
}
}
// Sorting
let orderByColumn = 'photos.uploaded_at';
if (sort === 'name') {