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') {
+13 -2
View File
@@ -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),
});
+12
View File
@@ -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();