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:
@@ -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: '[email protected]',
|
||||
admin_email: '[email protected]',
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user