feat(feedback): per-guest favorite + like caps with mobile-friendly limit modal (#655)

Reporter @Duecki1 wants to stop telling guests "pick only 5 photos" by
hand. Per-event cap, enforced server-side, with a clear popup when the
11th click would exceed the limit. Per-guest scope matches the "every
couple picks their top 10" mental model; per-gallery aggregate is
explicitly NOT in scope (creates weird "first 10 visitors use up all
slots" race conditions).

## Schema (migration 141)

Two nullable columns on `event_feedback_settings`:
  - `max_favorites_per_guest`
  - `max_likes_per_guest`

null / 0 = unlimited (preserves current behaviour for every existing
install — operator must opt in). Both shipped together because the
code path is identical; photographers can cap either, both, or neither.

## Backend

- `feedbackService.submitFeedback` cap check on the INSERT branch only.
  Toggle-off (un-favoriting) is always allowed, so a guest at 10/10
  can free a slot by un-clicking an existing favorite.
- New `countGuestFeedback(eventId, type, guestId, guestIdentifier)` —
  matches the exact same guest-key shape the existing duplicate-check
  uses (guest_id when present, fallback to guest_identifier in simple
  identity mode).
- Limit reduction grandfathers: admin lowering 20→10 keeps existing
  rows in place; new adds blocked until the guest removes some.
- Route layer (`galleryFeedback.js` POST) translates a `limit_reached`
  service-return into a structured 403 with `code:
  'FAVORITE_LIMIT_REACHED'` / `'LIKE_LIMIT_REACHED'`, `limit`, and
  `current_count`. Stable UI contract.
- `feedback-settings` GET exposes the caps so the gallery UI can
  optionally render a counter near the heart icon (UI extension TBD;
  the modal alone is the contract this PR commits to).
- `feedbackValidation`: range guard `0..10000`, null allowed,
  per-field error messages.

## Frontend — the popup

New `FeedbackLimitReachedModal` component renders via a `createPortal`
to `document.body` so it escapes any lightbox / sticky parent stacking
context and reliably sits above everything else.

Mobile-first responsive:
  - `items-end sm:items-center` — slides up from the bottom on phones
    (native action-sheet feel), centers on desktop (familiar modal).
  - `w-full sm:max-w-md` — full-width on phones, clamps to 420px on
    desktop.
  - `rounded-2xl sm:rounded-xl` — more rounded on phones for the
    sheet feel.
  - `pb-[env(safe-area-inset-bottom)]` — respects the iOS home indicator
    and Android gesture bar.
  - `z-[60]` — above the lightbox's z-50.

Title + body + "8 of 10 used" pill + "Got it" button. Backdrop click +
Escape both dismiss. Focus management lands on the OK button so
keyboard / screen-reader users can dismiss immediately.

New `useFeedbackLimitModal()` hook is the shared API: components
on every submit-feedback site wire `onError: (err) => handleError(err)`
and render `{limitModal}` in their JSX. Returns `true` from
`handleError` when the error is a structured cap-reached 403 (so the
caller can skip its generic error toast). PhotoFavorites + PhotoLikes
+ PhotoLightbox all wire through the hook — every favorite/like submit
path is covered, including the lightbox's three different submit
sites (guest mode, simple mode, post-identity-modal-confirm).

## Admin UI

`FeedbackSettings` card gets a new "Per-guest limits" section that
only renders when at least one of `allow_favorites` / `allow_likes` is
on. Two numeric inputs (0 / empty = unlimited) side-by-side on
desktop, stacked on mobile. Hint text covers the limit-reduction
grandfathering semantics so admins aren't surprised.

## i18n

EN + DE for:
  - Modal title + body (parameterized with `{{limit}}`)
  - Counter pill (parameterized with `{{current}}` / `{{limit}}`)
  - OK button label
  - Admin field labels + hints + section header + grandfathering note

## Tests

**Backend** (`__tests__/utils/feedbackPerGuestLimit.test.js`, 8 cases):
  - null cap → unlimited (back-compat)
  - 0 cap → unlimited (UI convenience)
  - cap=10: rows 1-10 succeed, 11 returns limit_reached
  - toggle-off frees a slot at the cap
  - limit reduction grandfathers existing rows
  - per-guest scope: guest A's cap doesn't affect guest B
  - favorite cap doesn't block likes (per-type)
  - like cap returns LIKE_LIMIT_REACHED-shaped payload

**Frontend** (`__tests__/useFeedbackLimitModal.test.ts`, 7 cases):
  - Non-axios errors → null
  - Non-403 axios errors → null
  - 403 with wrong code → null
  - FAVORITE_LIMIT_REACHED parsed
  - LIKE_LIMIT_REACHED parsed
  - Falls back to code-implied type when feedback_type missing
  - Missing numeric fields → 0 (not NaN)

All 15 pass. tsc --noEmit clean. eslint clean on changed files.

Closes #655.
This commit is contained in:
Paul Nothaft
2026-06-22 22:02:13 +02:00
parent e1111b3848
commit f2814e4a4c
15 changed files with 861 additions and 31 deletions
+30 -4
View File
@@ -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);
+62 -10
View File
@@ -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,
+12 -1
View File
@@ -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'),
];
/**