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
@@ -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');
});
};