From d4b4dc628f28a303ff1c80ba6d8e5e768217ba51 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sat, 11 Apr 2026 07:46:32 +0200 Subject: [PATCH] fix: wire admin photo feedback filters into grid query (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/routes/adminPhotos.js | 48 ++++++++++++++++--- frontend/src/pages/admin/EventDetailsPage.tsx | 15 +++++- frontend/src/services/photos.service.ts | 12 +++++ 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 6882cddf..bcd98076 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -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') { diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 164bd8e8..70f2ae46 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -294,10 +294,21 @@ export const EventDetailsPage: React.FC = () => { // Statistics are now fetched with the event details from the admin API + // Merge feedback filters into photo query params so the grid reflects + // the Has Likes / Has Favorites / Has Comments / min rating checkboxes. + const combinedPhotoFilters: PhotoFilterParams = useMemo(() => ({ + ...photoFilters, + hasLikes: feedbackFilters.hasLikes || undefined, + hasFavorites: feedbackFilters.hasFavorites || undefined, + hasComments: feedbackFilters.hasComments || undefined, + minRating: feedbackFilters.minRating ?? undefined, + logic: feedbackFilters.logic, + }), [photoFilters, feedbackFilters]); + // Fetch photos (needed for both photos tab and hero photo selector) const { data: photos = [], isLoading: photosLoading, refetch: refetchPhotos } = useQuery({ - queryKey: ['admin-event-photos', id, photoFilters], - queryFn: () => photosService.getEventPhotos(parseInt(id!), photoFilters), + queryKey: ['admin-event-photos', id, combinedPhotoFilters], + queryFn: () => photosService.getEventPhotos(parseInt(id!), combinedPhotoFilters), enabled: !!id && (activeTab === 'photos' || isEditing), }); diff --git a/frontend/src/services/photos.service.ts b/frontend/src/services/photos.service.ts index 8d0f59e9..7fcbb340 100644 --- a/frontend/src/services/photos.service.ts +++ b/frontend/src/services/photos.service.ts @@ -32,6 +32,11 @@ export interface PhotoFilters { search?: string; sort?: 'date' | 'name' | 'size' | 'rating'; order?: 'asc' | 'desc'; + hasLikes?: boolean; + hasFavorites?: boolean; + hasComments?: boolean; + minRating?: number | null; + logic?: 'AND' | 'OR'; } class PhotosService { @@ -47,6 +52,13 @@ class PhotosService { if (filters.search) params.append('search', filters.search); if (filters.sort) params.append('sort', filters.sort); if (filters.order) params.append('order', filters.order); + if (filters.hasLikes) params.append('has_likes', 'true'); + if (filters.hasFavorites) params.append('has_favorites', 'true'); + if (filters.hasComments) params.append('has_comments', 'true'); + if (filters.minRating !== undefined && filters.minRating !== null) { + params.append('min_rating', filters.minRating.toString()); + } + if (filters.logic) params.append('logic', filters.logic); } const queryString = params.toString();