diff --git a/backend/__tests__/utils/feedbackPerGuestLimit.test.js b/backend/__tests__/utils/feedbackPerGuestLimit.test.js new file mode 100644 index 00000000..40826e2d --- /dev/null +++ b/backend/__tests__/utils/feedbackPerGuestLimit.test.js @@ -0,0 +1,240 @@ +/** + * Unit tests for the per-guest favorite/like cap (#655). + * + * Pins the contract of `feedbackService.submitFeedback` around the cap: + * - null / 0 cap means unlimited (back-compat for installs that don't + * enable the feature). + * - At-cap ADD returns `{ limit_reached, limit, current_count }` rather + * than inserting — the route layer translates that into the structured + * 403 the UI listens for. + * - Toggle-off (un-favoriting) is ALWAYS allowed, regardless of cap state. + * A guest at 10/10 can still free a slot. + * - Limit reduction (admin lowers 20 → 10 while a guest has 15 already) + * grandfathers existing rows — new adds blocked, removals always allowed. + * - Caps are per-feedback-type: filling the favorite quota doesn't block + * likes on the same photo, and vice versa. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +process.env.NODE_ENV = 'test'; +process.env.TEST_DATABASE_PATH = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-feedback-limit-')), 'db.sqlite', +); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-limit-test-secret'; + +const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb'); + +const feedbackService = require('../../src/services/feedbackService'); + +const EVENT_SLUG = 'cap-test-event'; +const GUEST_A = 'guest-a-identifier'; +const GUEST_B = 'guest-b-identifier'; + +let db; +let cleanup; +let eventId; +let photoIds; + +async function setEventFeedbackSettings(overrides) { + const base = { + feedback_enabled: 1, + allow_ratings: 1, + allow_likes: 1, + allow_comments: 0, + allow_favorites: 1, + require_name_email: 0, + moderate_comments: 0, + show_feedback_to_guests: 1, + identity_mode: 'simple', + max_favorites_per_guest: null, + max_likes_per_guest: null, + ...overrides, + }; + const existing = await db('event_feedback_settings').where('event_id', eventId).first(); + if (existing) { + await db('event_feedback_settings').where('event_id', eventId).update(base); + } else { + await db('event_feedback_settings').insert({ + event_id: eventId, + ...base, + created_at: new Date(), + updated_at: new Date(), + }); + } +} + +async function favorite(photoId, guestIdentifier = GUEST_A) { + return feedbackService.submitFeedback(photoId, eventId, { + feedback_type: 'favorite', + ip_address: '127.0.0.1', + user_agent: 'jest', + }, guestIdentifier); +} + +async function like(photoId, guestIdentifier = GUEST_A) { + return feedbackService.submitFeedback(photoId, eventId, { + feedback_type: 'like', + ip_address: '127.0.0.1', + user_agent: 'jest', + }, guestIdentifier); +} + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + await seedMinimal(db); + const inserted = await db('events').insert({ + slug: EVENT_SLUG, + event_type: 'wedding', + event_name: 'Cap Test', + event_date: '2026-06-22', + host_email: 'host@example.com', + admin_email: 'admin@example.com', + password_hash: 'x', + share_link: `/gallery/${EVENT_SLUG}/share`, + share_token: 'cap-test-share', + expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(), + is_active: 1, + is_archived: 0, + is_draft: 0, + created_at: new Date().toISOString(), + }).returning('id'); + eventId = inserted[0]?.id ?? inserted[0]; + + // Seed 15 photos so we can test caps comfortably up to that count. + photoIds = []; + for (let i = 1; i <= 15; i += 1) { + const r = await db('photos').insert({ + event_id: eventId, + filename: `photo-${i}.jpg`, + path: `events/cap/${i}.jpg`, + type: 'individual', + uploaded_at: new Date().toISOString(), + }).returning('id'); + photoIds.push(r[0]?.id ?? r[0]); + } +}, 30000); + +afterAll(async () => { if (cleanup) await cleanup(); }); + +beforeEach(async () => { + await db('photo_feedback').where('event_id', eventId).del(); +}); + +describe('per-guest favorite cap (#655)', () => { + test('null cap = unlimited (back-compat for installs without #655)', async () => { + await setEventFeedbackSettings({ max_favorites_per_guest: null }); + for (const id of photoIds.slice(0, 12)) { + const r = await favorite(id); + expect(r.limit_reached).toBeFalsy(); + expect(r.created).toBe(true); + } + }); + + test('cap = 0 also = unlimited (UI convenience for "no limit")', async () => { + await setEventFeedbackSettings({ max_favorites_per_guest: 0 }); + for (const id of photoIds.slice(0, 12)) { + const r = await favorite(id); + expect(r.limit_reached).toBeFalsy(); + } + }); + + test('cap = 10: favorites 1..10 succeed, 11 returns limit_reached', async () => { + await setEventFeedbackSettings({ max_favorites_per_guest: 10 }); + for (const id of photoIds.slice(0, 10)) { + const r = await favorite(id); + expect(r.created).toBe(true); + } + const r11 = await favorite(photoIds[10]); + expect(r11.limit_reached).toBe(true); + expect(r11.limit).toBe(10); + expect(r11.current_count).toBe(10); + expect(r11.feedback_type).toBe('favorite'); + }); + + test('toggle-off at the cap frees a slot (un-favoriting always allowed)', async () => { + await setEventFeedbackSettings({ max_favorites_per_guest: 5 }); + for (const id of photoIds.slice(0, 5)) { + await favorite(id); + } + const blocked = await favorite(photoIds[5]); + expect(blocked.limit_reached).toBe(true); + + // Un-favorite one — toggle off path returns { removed: true } + const removed = await favorite(photoIds[0]); + expect(removed.removed).toBe(true); + + // Now the previously-blocked slot fits + const after = await favorite(photoIds[5]); + expect(after.created).toBe(true); + }); + + test('limit reduction grandfathers existing rows; new adds blocked', async () => { + await setEventFeedbackSettings({ max_favorites_per_guest: 10 }); + for (const id of photoIds.slice(0, 10)) { + await favorite(id); + } + // Admin lowers the cap to 5 while the guest already has 10 + await setEventFeedbackSettings({ max_favorites_per_guest: 5 }); + // Existing 10 stay + const count = await db('photo_feedback') + .where({ event_id: eventId, feedback_type: 'favorite', guest_identifier: GUEST_A }) + .count('* as c').first(); + expect(parseInt(count.c, 10)).toBe(10); + // New adds blocked + const blocked = await favorite(photoIds[10]); + expect(blocked.limit_reached).toBe(true); + expect(blocked.limit).toBe(5); + expect(blocked.current_count).toBe(10); + // Removals still allowed + const removed = await favorite(photoIds[0]); + expect(removed.removed).toBe(true); + }); + + test('cap is per-guest: guest B is unaffected by guest A hitting the cap', async () => { + await setEventFeedbackSettings({ max_favorites_per_guest: 3 }); + for (const id of photoIds.slice(0, 3)) { + await favorite(id, GUEST_A); + } + expect((await favorite(photoIds[3], GUEST_A)).limit_reached).toBe(true); + + // Guest B starts at 0 + for (const id of photoIds.slice(0, 3)) { + const r = await favorite(id, GUEST_B); + expect(r.created).toBe(true); + } + expect((await favorite(photoIds[3], GUEST_B)).limit_reached).toBe(true); + }); +}); + +describe('per-guest like cap (#655)', () => { + test('favorite cap does NOT block likes on the same photo (per-type)', async () => { + await setEventFeedbackSettings({ + max_favorites_per_guest: 3, + max_likes_per_guest: null, + }); + for (const id of photoIds.slice(0, 3)) { + await favorite(id); + } + expect((await favorite(photoIds[3])).limit_reached).toBe(true); + + // Likes still unlimited + for (const id of photoIds.slice(0, 10)) { + const r = await like(id); + expect(r.created).toBe(true); + } + }); + + test('like cap returns LIKE_LIMIT_REACHED-shaped payload', async () => { + await setEventFeedbackSettings({ max_likes_per_guest: 2 }); + await like(photoIds[0]); + await like(photoIds[1]); + const r = await like(photoIds[2]); + expect(r.limit_reached).toBe(true); + expect(r.feedback_type).toBe('like'); + expect(r.limit).toBe(2); + expect(r.current_count).toBe(2); + }); +}); diff --git a/backend/migrations/core/141_add_feedback_per_guest_limits.js b/backend/migrations/core/141_add_feedback_per_guest_limits.js new file mode 100644 index 00000000..1388dca4 --- /dev/null +++ b/backend/migrations/core/141_add_feedback_per_guest_limits.js @@ -0,0 +1,43 @@ +/** + * Migration 141: Per-guest favorite + like caps (#655). + * + * Reporter wants to cap how many photos a guest can favorite per event — + * the classic photographer-culling workflow ("pick your top 10 for the + * album"). Currently the photographer has to enforce this with a verbal + * instruction; this column lets the gallery enforce it server-side so + * the 11th favorite click returns a clear "limit reached" response. + * + * Two columns, one per feedback type: favorites + likes. Both nullable + + * additive — null/0 = unlimited, preserving current behaviour for every + * existing install with no operator action. The route layer enforces in + * `feedbackService.submitFeedback` (on the INSERT branch only, so a + * guest at the cap can still toggle off an existing favorite and free a + * slot). Limit *reduction* (e.g. admin lowers 20 → 10) grandfathers any + * over-cap rows already in place — new adds blocked, removals always + * allowed — to avoid surprising bulk-deletes on the admin save. + * + * Hooks into the existing per-event `event_feedback_settings` table + * alongside `allow_favorites` / `allow_likes`, so the admin surface is + * the same Event → Feedback settings card. + */ +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('event_feedback_settings'))) return; + const hasFav = await knex.schema.hasColumn('event_feedback_settings', 'max_favorites_per_guest'); + const hasLike = await knex.schema.hasColumn('event_feedback_settings', 'max_likes_per_guest'); + if (hasFav && hasLike) return; + await knex.schema.alterTable('event_feedback_settings', (table) => { + if (!hasFav) table.integer('max_favorites_per_guest').nullable(); + if (!hasLike) table.integer('max_likes_per_guest').nullable(); + }); +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('event_feedback_settings'))) return; + const hasFav = await knex.schema.hasColumn('event_feedback_settings', 'max_favorites_per_guest'); + const hasLike = await knex.schema.hasColumn('event_feedback_settings', 'max_likes_per_guest'); + if (!hasFav && !hasLike) return; + await knex.schema.alterTable('event_feedback_settings', (table) => { + if (hasFav) table.dropColumn('max_favorites_per_guest'); + if (hasLike) table.dropColumn('max_likes_per_guest'); + }); +}; diff --git a/backend/src/routes/galleryFeedback.js b/backend/src/routes/galleryFeedback.js index 10600999..db58bc8a 100644 --- a/backend/src/routes/galleryFeedback.js +++ b/backend/src/routes/galleryFeedback.js @@ -34,7 +34,12 @@ router.get('/:slug/feedback-settings', allow_favorites: Boolean(settings.allow_favorites), require_name_email: Boolean(settings.require_name_email), show_feedback_to_guests: Boolean(settings.show_feedback_to_guests), - identity_mode: settings.identity_mode || 'simple' + identity_mode: settings.identity_mode || 'simple', + // Per-guest caps (#655). The UI uses these to disable the heart / + // thumbs-up at the limit and render an "8 / 10" counter. null = no + // cap; positive integer = enforced. + max_favorites_per_guest: settings.max_favorites_per_guest || null, + max_likes_per_guest: settings.max_likes_per_guest || null, }; res.json(guestSettings); @@ -262,7 +267,24 @@ router.post('/:slug/photos/:photoId/feedback', feedbackData, guestIdentifier ); - + + // Per-guest cap reached (#655). Surface as a structured 403 so the + // frontend can show an explicit popup with the actual cap value and + // remaining-slots count, rather than a generic toast. Code is the + // stable contract the UI listens for. + if (result && result.limit_reached) { + const code = result.feedback_type === 'favorite' + ? 'FAVORITE_LIMIT_REACHED' + : 'LIKE_LIMIT_REACHED'; + return res.status(403).json({ + error: `${result.feedback_type === 'favorite' ? 'Favorite' : 'Like'} limit reached`, + code, + limit: result.limit, + current_count: result.current_count, + feedback_type: result.feedback_type, + }); + } + // Log activity await logActivity(`guest_feedback_${feedbackType}`, { photo_id: photoId, @@ -272,11 +294,11 @@ router.post('/:slug/photos/:photoId/feedback', id: guestIdentifier.substring(0, 16), name: req.body.guest_name || 'Anonymous' }); - + res.json({ success: true, ...result, - message: feedbackType === 'comment' && !feedbackData.is_approved ? + message: feedbackType === 'comment' && !feedbackData.is_approved ? 'Your comment has been submitted for moderation' : undefined }); } catch (error) { @@ -365,6 +387,10 @@ router.get('/:slug/my-feedback', ) .orderBy('photo_feedback.created_at', 'desc'); + // Array-shaped response preserved for back-compat with existing + // consumers (`GalleryView` iterates the array directly). The per-event + // caps for #655 are exposed via the `/feedback-settings` endpoint; + // the running counts can be derived client-side from this array. res.json(myFeedback); } catch (error) { logger.error('Error getting user feedback:', error); diff --git a/backend/src/services/feedbackService.js b/backend/src/services/feedbackService.js index f2c018d1..97bfb43e 100644 --- a/backend/src/services/feedbackService.js +++ b/backend/src/services/feedbackService.js @@ -24,7 +24,9 @@ class FeedbackService { require_name_email: false, moderate_comments: true, show_feedback_to_guests: true, - identity_mode: 'simple' + identity_mode: 'simple', + max_favorites_per_guest: null, + max_likes_per_guest: null, }; } @@ -32,6 +34,10 @@ class FeedbackService { if (!settings.identity_mode) { settings.identity_mode = 'simple'; } + // Per-guest caps (#655). NULL on existing rows = unlimited; the route + // layer treats null/0/missing identically. + settings.max_favorites_per_guest = settings.max_favorites_per_guest ?? null; + settings.max_likes_per_guest = settings.max_likes_per_guest ?? null; return settings; } catch (error) { logger.error('Error getting feedback settings:', error); @@ -76,15 +82,33 @@ class FeedbackService { /** * Submit feedback for a photo */ + /** + * Count how many existing feedback rows of `feedback_type` a single guest + * has on a given event, matching the same guest-key shape submitFeedback's + * duplicate-check uses (guest_id when present, fall back to + * guest_identifier). Used for the per-guest favorite/like caps (#655). + */ + async countGuestFeedback(eventId, feedbackType, guestId, guestIdentifier) { + const query = db('photo_feedback') + .where({ event_id: eventId, feedback_type: feedbackType }); + if (guestId) { + query.where('guest_id', guestId); + } else { + query.where('guest_identifier', guestIdentifier); + } + const result = await query.count('* as count').first(); + return parseInt(result?.count, 10) || 0; + } + async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) { try { const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData; - + // Validate feedback type if (!['rating', 'like', 'comment', 'favorite'].includes(feedback_type)) { throw new Error('Invalid feedback type'); } - + // Check if similar feedback already exists (prevent duplicates). // When a per-person guest_id is present, scope the check to that guest // so two guests on the same device can independently like a photo. @@ -101,7 +125,7 @@ class FeedbackService { duplicateQuery.where('guest_identifier', guestIdentifier); } const existing = await duplicateQuery.first(); - + if (existing) { if (feedback_type === 'rating' && rating !== existing.rating) { // Update existing rating @@ -111,25 +135,53 @@ class FeedbackService { rating, updated_at: new Date() }); - + await this.updatePhotoFeedbackStats(photoId); return { id: existing.id, updated: true }; } - - // For likes and favorites, toggle off if already exists + + // For likes and favorites, toggle off if already exists. + // Toggle-off always allowed — the cap below is on adds only, so a + // guest at the limit can still free a slot by un-favoriting (#655). if (feedback_type === 'like' || feedback_type === 'favorite') { await db('photo_feedback') .where('id', existing.id) .delete(); - + await this.updatePhotoFeedbackStats(photoId); return { removed: true }; } - + return { id: existing.id, exists: true }; } } - + + // Per-guest cap enforcement (#655). Only checked on ADD; toggle-off is + // always allowed. NULL or 0 stored in the column means "unlimited" — + // the photographer hasn't opted in to a cap for this event. + if (feedback_type === 'favorite' || feedback_type === 'like') { + const settings = await this.getEventFeedbackSettings(eventId); + const cap = feedback_type === 'favorite' + ? settings.max_favorites_per_guest + : settings.max_likes_per_guest; + if (cap && cap > 0) { + const currentCount = await this.countGuestFeedback( + eventId, feedback_type, guest_id, guestIdentifier, + ); + if (currentCount >= cap) { + // Don't insert; surface a structured payload so the route layer + // can return a 403 with `code` + `limit` + `current_count` and + // the UI can render an explicit popup with the cap value. + return { + limit_reached: true, + feedback_type, + limit: cap, + current_count: currentCount, + }; + } + } + } + // Insert new feedback const result = await db('photo_feedback').insert({ photo_id: photoId, diff --git a/backend/src/utils/feedbackValidation.js b/backend/src/utils/feedbackValidation.js index ba619fcc..eacace87 100644 --- a/backend/src/utils/feedbackValidation.js +++ b/backend/src/utils/feedbackValidation.js @@ -187,7 +187,18 @@ const validateFeedbackSettings = [ body('moderate_comments').optional().isBoolean(), body('show_feedback_to_guests').optional().isBoolean(), body('identity_mode').optional().isIn(['simple', 'guest']) - .withMessage('identity_mode must be "simple" or "guest"') + .withMessage('identity_mode must be "simple" or "guest"'), + // Per-guest caps (#655). null / 0 = unlimited; positive integers enforced. + // Upper bound is intentionally generous — operators occasionally run + // "everyone, pick everything you like" galleries. + body('max_favorites_per_guest') + .optional({ nullable: true }) + .custom((v) => v === null || (Number.isInteger(v) && v >= 0 && v <= 10000)) + .withMessage('max_favorites_per_guest must be null or an integer between 0 and 10000'), + body('max_likes_per_guest') + .optional({ nullable: true }) + .custom((v) => v === null || (Number.isInteger(v) && v >= 0 && v <= 10000)) + .withMessage('max_likes_per_guest must be null or an integer between 0 and 10000'), ]; /** diff --git a/frontend/src/components/admin/FeedbackSettings.tsx b/frontend/src/components/admin/FeedbackSettings.tsx index 6610bc1f..06a21cbf 100644 --- a/frontend/src/components/admin/FeedbackSettings.tsx +++ b/frontend/src/components/admin/FeedbackSettings.tsx @@ -22,6 +22,9 @@ interface FeedbackSettings { rate_limit_window_minutes?: number; rate_limit_max_requests?: number; identity_mode?: 'simple' | 'guest'; + // Per-guest caps (#655). null/0 = unlimited. + max_favorites_per_guest?: number | null; + max_likes_per_guest?: number | null; } export const FeedbackSettings: React.FC = ({ @@ -219,6 +222,69 @@ export const FeedbackSettings: React.FC = ({ + {/* Per-guest caps (#655). Two numeric inputs; 0 / empty = unlimited. + Only renders when the matching toggle is on — the cap is + meaningless if the type itself is disabled. */} + {(settings.allow_favorites || settings.allow_likes) && ( +
+

+ {t('feedback.settings.perGuestLimits', 'Per-guest limits')} +

+

+ {t( + 'feedback.settings.perGuestLimitsDesc', + 'Cap how many photos each guest can favorite or like — useful for "pick your top N for the album" workflows. Leave at 0 for no limit. Lowering a cap below an existing guest\'s count keeps their existing rows; only new adds are blocked until they remove some.', + )} +

+
+ {settings.allow_favorites && ( +
+ + onChange({ + ...settings, + max_favorites_per_guest: Math.max(0, parseInt(e.target.value, 10) || 0), + })} + className="w-32 px-2 py-1 text-sm border border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100" + /> +

+ {t('feedback.settings.maxFavoritesPerGuestHint', '0 = unlimited')} +

+
+ )} + {settings.allow_likes && ( +
+ + onChange({ + ...settings, + max_likes_per_guest: Math.max(0, parseInt(e.target.value, 10) || 0), + })} + className="w-32 px-2 py-1 text-sm border border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100" + /> +

+ {t('feedback.settings.maxLikesPerGuestHint', '0 = unlimited')} +

+
+ )} +
+
+ )} +
{/* Privacy & Moderation */} diff --git a/frontend/src/components/gallery/FeedbackLimitReachedModal.tsx b/frontend/src/components/gallery/FeedbackLimitReachedModal.tsx new file mode 100644 index 00000000..13a4e77b --- /dev/null +++ b/frontend/src/components/gallery/FeedbackLimitReachedModal.tsx @@ -0,0 +1,156 @@ +import React, { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { Heart, Bookmark, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +/** + * Per-guest cap modal (#655). Shown when the guest clicks the heart or + * thumbs-up on a photo that would exceed the per-event cap set by the + * photographer. Mobile-first responsive: full-width card with safe inset + * on phones, 420px centered card on desktop. High z-index because the + * lightbox sits at z-50; we render above at z-[60]. + * + * Single OK button rather than confirm/cancel — this is an + * acknowledgement, not a decision. Backdrop click + Escape both dismiss + * for users who want to recover quickly. + */ +export interface FeedbackLimitReachedModalProps { + open: boolean; + feedbackType: 'favorite' | 'like'; + limit: number; + currentCount: number; + onClose: () => void; +} + +export const FeedbackLimitReachedModal: React.FC = ({ + open, + feedbackType, + limit, + currentCount, + onClose, +}) => { + const { t } = useTranslation(); + const okButtonRef = useRef(null); + + useEffect(() => { + if (!open) return; + // Focus the OK button so keyboard / screen-reader users can dismiss + // straight away with Enter or Space. + okButtonRef.current?.focus(); + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [open, onClose]); + + if (!open) return null; + + const isFavorite = feedbackType === 'favorite'; + const Icon = isFavorite ? Bookmark : Heart; + const title = isFavorite + ? t('feedback.limit.favoriteTitle', 'Favorite limit reached') + : t('feedback.limit.likeTitle', 'Like limit reached'); + const body = isFavorite + ? t( + 'feedback.limit.favoriteBody', + 'You can favorite up to {{limit}} photos in this gallery. Remove one to add a new one.', + { limit }, + ) + : t( + 'feedback.limit.likeBody', + 'You can like up to {{limit}} photos in this gallery. Remove one to add a new one.', + { limit }, + ); + + const node = ( +
{ + // Backdrop click only — don't dismiss when clicking inside the card. + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+
+
+
+

+ {title} +

+

+ {body} +

+

+ {t('feedback.limit.counter', '{{current}} of {{limit}} used', { + current: currentCount, + limit, + })} +

+
+ +
+ + {/* Footer */} +
+ +
+
+
+ ); + + // Portal to body so the modal escapes any lightbox / sticky parent stacking + // context and reliably sits above everything else. + return createPortal(node, document.body); +}; diff --git a/frontend/src/components/gallery/PhotoFavorites.tsx b/frontend/src/components/gallery/PhotoFavorites.tsx index da63ea5c..063c6fdd 100644 --- a/frontend/src/components/gallery/PhotoFavorites.tsx +++ b/frontend/src/components/gallery/PhotoFavorites.tsx @@ -6,6 +6,7 @@ import { feedbackService } from '../../services/feedback.service'; import { toast } from 'react-toastify'; import { FeedbackIdentityModal } from './FeedbackIdentityModal'; import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext'; +import { useFeedbackLimitModal } from '../../hooks/useFeedbackLimitModal'; interface PhotoFavoritesProps { photoId: string; @@ -29,6 +30,7 @@ export const PhotoFavorites: React.FC = ({ const { t } = useTranslation(); const queryClient = useQueryClient(); const guestIdentity = useGuestIdentityOptional(); + const { modal: limitModal, handleError: handleLimitError } = useFeedbackLimitModal(); const [isSubmitting, setIsSubmitting] = useState(false); const [animating, setAnimating] = useState(false); const [showIdentityModal, setShowIdentityModal] = useState(false); @@ -58,6 +60,8 @@ export const PhotoFavorites: React.FC = ({ if (onFavoriteChange) { onFavoriteChange(isFavorited); } + // Per-guest cap reached (#655) — surface the modal instead of a toast. + if (handleLimitError(error)) return; if (error.response?.status === 429) { toast.error(t('feedback.rateLimited', 'Please wait before favoriting again')); } else { @@ -130,6 +134,7 @@ export const PhotoFavorites: React.FC = ({ onSubmit={handleIdentitySubmit} feedbackType={t('feedback.favorite', 'favorite')} /> + {limitModal} ); }; diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index e210be06..618541e2 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -9,6 +9,7 @@ import { feedbackService } from '../../services/feedback.service'; import { FeedbackIdentityModal } from './FeedbackIdentityModal'; import { VideoPlayer } from './VideoPlayer'; import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext'; +import { useFeedbackLimitModal } from '../../hooks/useFeedbackLimitModal'; interface PhotoLightboxProps { photos: Photo[]; @@ -91,6 +92,9 @@ export const PhotoLightbox: React.FC = ({ const [pendingAction, setPendingAction] = useState(null); const guestIdentity = useGuestIdentityOptional(); const isGuestMode = guestIdentity?.identityMode === 'guest'; + // Per-guest cap modal (#655) — shared across every submitFeedback call site + // in the lightbox (guest mode, simple mode, identity-modal-confirm path). + const { modal: limitModal, handleError: handleLimitError } = useFeedbackLimitModal(); useEffect(() => { const onResize = () => setIsSmallScreen(window.innerWidth < 640); @@ -257,6 +261,8 @@ export const PhotoLightbox: React.FC = ({ }); if (onFeedbackChange) onFeedbackChange(); } catch (err) { + // Per-guest cap reached (#655) surfaces the shared modal. + if (handleLimitError(err)) return; // eslint-disable-next-line no-console console.warn('Like submit failed', err); } @@ -270,16 +276,22 @@ export const PhotoLightbox: React.FC = ({ setShowIdentityModal(true); return; } - await feedbackService.submitFeedback(slug, String(currentPhoto.id), { - feedback_type: 'like', - guest_name: savedIdentity?.name, - guest_email: savedIdentity?.email, - }); - setMyLiked(prev => { - const next = !prev; - setLikeCount(c => Math.max(0, c + (next ? 1 : -1))); - return next; - }); + try { + await feedbackService.submitFeedback(slug, String(currentPhoto.id), { + feedback_type: 'like', + guest_name: savedIdentity?.name, + guest_email: savedIdentity?.email, + }); + setMyLiked(prev => { + const next = !prev; + setLikeCount(c => Math.max(0, c + (next ? 1 : -1))); + return next; + }); + } catch (err) { + if (handleLimitError(err)) return; + // eslint-disable-next-line no-console + console.warn('Like submit failed', err); + } }; const submitRating = async (value: number) => { @@ -978,12 +990,17 @@ export const PhotoLightbox: React.FC = ({ setSavedIdentity({ name, email }); setShowIdentityModal(false); if (pendingAction?.type === 'like') { - await feedbackService.submitFeedback(slug, String(currentPhoto.id), { - feedback_type: 'like', - guest_name: name, - guest_email: email, - }); - setMyLiked(true); + try { + await feedbackService.submitFeedback(slug, String(currentPhoto.id), { + feedback_type: 'like', + guest_name: name, + guest_email: email, + }); + setMyLiked(true); + } catch (err) { + // Per-guest cap reached (#655) on the post-identity-modal submit. + if (!handleLimitError(err)) throw err; + } } else if (pendingAction?.type === 'rating' && pendingAction.rating) { await feedbackService.submitFeedback(slug, String(currentPhoto.id), { feedback_type: 'rating', @@ -997,6 +1014,9 @@ export const PhotoLightbox: React.FC = ({ }} feedbackType={pendingAction?.type === 'rating' ? 'rating' : 'like'} /> + {/* Per-guest cap modal (#655). Single instance fires for any of the + lightbox's submitFeedback paths via the shared hook. */} + {limitModal}
); }; diff --git a/frontend/src/components/gallery/PhotoLikes.tsx b/frontend/src/components/gallery/PhotoLikes.tsx index b57d3382..07d7f185 100644 --- a/frontend/src/components/gallery/PhotoLikes.tsx +++ b/frontend/src/components/gallery/PhotoLikes.tsx @@ -6,6 +6,7 @@ import { feedbackService } from '../../services/feedback.service'; import { toast } from 'react-toastify'; import { FeedbackIdentityModal } from './FeedbackIdentityModal'; import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext'; +import { useFeedbackLimitModal } from '../../hooks/useFeedbackLimitModal'; interface PhotoLikesProps { photoId: string; @@ -29,6 +30,7 @@ export const PhotoLikes: React.FC = ({ const { t } = useTranslation(); const queryClient = useQueryClient(); const guestIdentity = useGuestIdentityOptional(); + const { modal: limitModal, handleError: handleLimitError } = useFeedbackLimitModal(); const [isSubmitting, setIsSubmitting] = useState(false); const [animating, setAnimating] = useState(false); const [showIdentityModal, setShowIdentityModal] = useState(false); @@ -58,6 +60,8 @@ export const PhotoLikes: React.FC = ({ if (onLikeChange) { onLikeChange(isLiked); } + // Per-guest cap reached (#655) — surface the modal instead of a toast. + if (handleLimitError(error)) return; if (error.response?.status === 429) { toast.error(t('feedback.rateLimited', 'Please wait before liking again')); } else { @@ -138,6 +142,7 @@ export const PhotoLikes: React.FC = ({ onSubmit={handleIdentitySubmit} feedbackType={t('feedback.like', 'like')} /> + {limitModal} ); }; diff --git a/frontend/src/hooks/__tests__/useFeedbackLimitModal.test.ts b/frontend/src/hooks/__tests__/useFeedbackLimitModal.test.ts new file mode 100644 index 00000000..589b4f0e --- /dev/null +++ b/frontend/src/hooks/__tests__/useFeedbackLimitModal.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { parseFeedbackLimitError } from '../useFeedbackLimitModal'; + +/** + * Tests for the error parser that decides when to surface the per-guest + * limit modal (#655). Hook integration is exercised via the gallery UI; + * here we pin the contract that drives `handleError`. + */ + +describe('parseFeedbackLimitError', () => { + it('returns null for non-axios errors', () => { + expect(parseFeedbackLimitError(new Error('boom'))).toBeNull(); + expect(parseFeedbackLimitError(null)).toBeNull(); + expect(parseFeedbackLimitError(undefined)).toBeNull(); + expect(parseFeedbackLimitError({})).toBeNull(); + }); + + it('returns null for non-403 axios errors', () => { + const err = { response: { status: 500, data: { code: 'FAVORITE_LIMIT_REACHED' } } }; + expect(parseFeedbackLimitError(err)).toBeNull(); + }); + + it('returns null for 403s without the expected code', () => { + const err = { response: { status: 403, data: { error: 'Forbidden' } } }; + expect(parseFeedbackLimitError(err)).toBeNull(); + const err2 = { response: { status: 403, data: { code: 'OTHER_FORBIDDEN' } } }; + expect(parseFeedbackLimitError(err2)).toBeNull(); + }); + + it('parses FAVORITE_LIMIT_REACHED', () => { + const err = { + response: { + status: 403, + data: { + code: 'FAVORITE_LIMIT_REACHED', + limit: 10, + current_count: 10, + feedback_type: 'favorite', + }, + }, + }; + expect(parseFeedbackLimitError(err)).toEqual({ + feedbackType: 'favorite', + limit: 10, + currentCount: 10, + }); + }); + + it('parses LIKE_LIMIT_REACHED', () => { + const err = { + response: { + status: 403, + data: { + code: 'LIKE_LIMIT_REACHED', + limit: 5, + current_count: 5, + feedback_type: 'like', + }, + }, + }; + expect(parseFeedbackLimitError(err)).toEqual({ + feedbackType: 'like', + limit: 5, + currentCount: 5, + }); + }); + + it('falls back to the code-implied type when feedback_type is missing', () => { + const err = { + response: { + status: 403, + data: { code: 'FAVORITE_LIMIT_REACHED', limit: 3, current_count: 3 }, + }, + }; + expect(parseFeedbackLimitError(err)?.feedbackType).toBe('favorite'); + }); + + it('treats missing numeric fields as 0 rather than NaN', () => { + const err = { response: { status: 403, data: { code: 'FAVORITE_LIMIT_REACHED' } } }; + expect(parseFeedbackLimitError(err)).toEqual({ + feedbackType: 'favorite', + limit: 0, + currentCount: 0, + }); + }); +}); diff --git a/frontend/src/hooks/useFeedbackLimitModal.tsx b/frontend/src/hooks/useFeedbackLimitModal.tsx new file mode 100644 index 00000000..f6f4291c --- /dev/null +++ b/frontend/src/hooks/useFeedbackLimitModal.tsx @@ -0,0 +1,85 @@ +import React, { useCallback, useState } from 'react'; +import { + FeedbackLimitReachedModal, +} from '../components/gallery/FeedbackLimitReachedModal'; + +/** + * Per-guest favorite/like cap (#655). Components that submit feedback wrap + * their mutation's onError with `handleError(err)` from this hook; when the + * backend returns the structured 403 (`code: 'FAVORITE_LIMIT_REACHED'` / + * `'LIKE_LIMIT_REACHED'`), the shared modal renders with the current count + * and the configured limit. + * + * Usage: + * const { modal, handleError } = useFeedbackLimitModal(); + * useMutation({ onError: (err) => { if (!handleError(err)) toast.error(...); } }); + * return (<> {modal} ); + * + * handleError returns true when the error was a limit-reached 403 (so the + * caller can skip its generic error toast). Returns false for any other + * error shape so existing toast/error paths still fire. + */ + +interface LimitState { + open: boolean; + feedbackType: 'favorite' | 'like'; + limit: number; + currentCount: number; +} + +function parseFeedbackLimitError(error: unknown): { feedbackType: 'favorite' | 'like'; limit: number; currentCount: number } | null { + const axiosErr = error as { + response?: { + status?: number; + data?: { + code?: string; + limit?: number; + current_count?: number; + feedback_type?: 'favorite' | 'like'; + }; + }; + }; + const data = axiosErr?.response?.data; + if (axiosErr?.response?.status !== 403 || !data) return null; + if (data.code !== 'FAVORITE_LIMIT_REACHED' && data.code !== 'LIKE_LIMIT_REACHED') { + return null; + } + const feedbackType: 'favorite' | 'like' = data.code === 'FAVORITE_LIMIT_REACHED' ? 'favorite' : 'like'; + return { + feedbackType: data.feedback_type === 'like' || data.feedback_type === 'favorite' + ? data.feedback_type + : feedbackType, + limit: typeof data.limit === 'number' ? data.limit : 0, + currentCount: typeof data.current_count === 'number' ? data.current_count : 0, + }; +} + +export function useFeedbackLimitModal() { + const [state, setState] = useState(null); + + const handleError = useCallback((error: unknown): boolean => { + const parsed = parseFeedbackLimitError(error); + if (!parsed) return false; + setState({ open: true, ...parsed }); + return true; + }, []); + + const close = useCallback(() => { + setState((prev) => (prev ? { ...prev, open: false } : prev)); + }, []); + + const modal = state ? ( + + ) : null; + + return { handleError, modal }; +} + +// Exported for unit tests; the hook above is the consumer-facing API. +export { parseFeedbackLimitError }; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index afaf7b03..f58ae48e 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3037,6 +3037,12 @@ "commentsDesc": "Textkommentare auf Fotos", "favorites": "Favoriten", "favoritesDesc": "Fotos als Favoriten markieren", + "perGuestLimits": "Limits pro Gast", + "perGuestLimitsDesc": "Begrenzen, wie viele Fotos jeder Gast favorisieren oder liken kann — praktisch für „Wählen Sie Ihre Top-N fürs Album"-Abläufe. 0 = unbegrenzt. Eine Senkung unter den aktuellen Stand eines Gasts lässt vorhandene Einträge bestehen; nur neue Hinzufügungen werden blockiert, bis er welche entfernt.", + "maxFavoritesPerGuest": "Max. Favoriten pro Gast", + "maxFavoritesPerGuestHint": "0 = unbegrenzt", + "maxLikesPerGuest": "Max. Likes pro Gast", + "maxLikesPerGuestHint": "0 = unbegrenzt", "identityMode": "Identitätsmodus", "identityModeSimple": "Einfaches Feedback", "identityModeSimpleDesc": "Anonym, gerätebasiert. Alle Besucher auf demselben Gerät teilen den Zustand.", @@ -3111,6 +3117,14 @@ "unlike": "Nicht mehr mögen", "like": "Gefällt mir", "favoriteError": "Favorit konnte nicht aktualisiert werden", + "limit": { + "favoriteTitle": "Favoritenlimit erreicht", + "favoriteBody": "Sie können in dieser Galerie bis zu {{limit}} Fotos favorisieren. Entfernen Sie einen, um einen neuen hinzuzufügen.", + "likeTitle": "Like-Limit erreicht", + "likeBody": "Sie können in dieser Galerie bis zu {{limit}} Fotos liken. Entfernen Sie einen, um einen neuen hinzuzufügen.", + "counter": "{{current}} von {{limit}} verwendet", + "ok": "Verstanden" + }, "unfavorite": "Aus Favoriten entfernen", "favorite": "Zu Favoriten hinzufügen", "invalidEmail": "Ungültige E-Mail-Adresse", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 600f953e..1eeee74f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3058,6 +3058,12 @@ "commentsDesc": "Text comments on photos", "favorites": "Favorites", "favoritesDesc": "Mark photos as favorites", + "perGuestLimits": "Per-guest limits", + "perGuestLimitsDesc": "Cap how many photos each guest can favorite or like — useful for \"pick your top N for the album\" workflows. Leave at 0 for no limit. Lowering a cap below an existing guest's count keeps their existing rows; only new adds are blocked until they remove some.", + "maxFavoritesPerGuest": "Max favorites per guest", + "maxFavoritesPerGuestHint": "0 = unlimited", + "maxLikesPerGuest": "Max likes per guest", + "maxLikesPerGuestHint": "0 = unlimited", "identityMode": "Identity Mode", "identityModeSimple": "Simple feedback", "identityModeSimpleDesc": "Anonymous, device-based. All visitors on the same device share state.", @@ -3134,6 +3140,14 @@ "favoriteError": "Failed to update favorite", "unfavorite": "Remove from favorites", "favorite": "Add to favorites", + "limit": { + "favoriteTitle": "Favorite limit reached", + "favoriteBody": "You can favorite up to {{limit}} photos in this gallery. Remove one to add a new one.", + "likeTitle": "Like limit reached", + "likeBody": "You can like up to {{limit}} photos in this gallery. Remove one to add a new one.", + "counter": "{{current}} of {{limit}} used", + "ok": "Got it" + }, "invalidEmail": "Invalid email address", "identityRequired": "Your Information Required", "identityReason": "Please provide your name and email to submit {{type}}.", diff --git a/frontend/src/services/feedback.service.ts b/frontend/src/services/feedback.service.ts index 72735ec9..596541a9 100644 --- a/frontend/src/services/feedback.service.ts +++ b/frontend/src/services/feedback.service.ts @@ -15,6 +15,13 @@ export interface FeedbackSettings { rate_limit_window_minutes?: number; rate_limit_max_requests?: number; identity_mode?: IdentityMode; + // Per-guest caps (#655). null or 0 = unlimited (preserves current + // behaviour for installs that haven't enabled the cap). Positive + // integers are enforced server-side — adds beyond the cap return a + // structured 403 with `code: 'FAVORITE_LIMIT_REACHED'` / + // `'LIKE_LIMIT_REACHED'`, surfaced to the guest as a modal. + max_favorites_per_guest?: number | null; + max_likes_per_guest?: number | null; } export interface PhotoFeedback {