feat(feedback): let guests remove their star rating (#884) (#893)

* feat(feedback): let guests remove their star rating (#884)

Clicking your current rating again clears it. rating: 0 is the wire
contract: the validator now accepts 0, and the service deletes the
guest's rating row (instead of storing a 0 that would drag the photo
average down) and recalculates photo stats. The lightbox stars send 0
on a same-star click; PhotoRating already did, but the backend rejected
it with a 400 until now.

* fix(feedback): harden the rating-clear path (#884 review round)

External review follow-ups: numerically normalize the clear sentinel so
a numeric-string "0" can't slip into the update/insert paths (validator
now also toInt()s), delete the full guest-scoped rating set on clear so
racy duplicate rows can't survive in the average (same defense as the
reaction path), and refresh the visible average/count after the
identity-modal submit path like the direct paths do.

* fix(feedback): round-2 review fixes for rating clear (#884)

- Clear sentinel matches only an explicit 0 / "0" — malformed input
  (undefined, NaN, garbage strings) can no longer delete a rating.
- Lightbox survives the photo list shrinking while open (clearing your
  rating under the Rated filter drops the photo on refetch): index is
  re-anchored and the lightbox closes when the list empties, instead of
  crashing on an out-of-range index.
- Story layout gets the same same-star-to-clear behavior, keyed off the
  session-local my-rating map, and an explicit 0 no longer falls back to
  displaying the photo average.

* fix(feedback): refresh guest-scoped caches after rating changes (#884 review round 3)

- GalleryView's onFeedbackChange now also invalidates ['my-feedback',
  slug]: in guest identity mode the Rated/Liked filter membership and
  chip counts come from that query (#538), so a cleared rating never
  left the Rated filter until the 30s staleTime lapsed.
- PhotoRating invalidates gallery-photos + my-feedback on success: the
  parent refetch fires optimistically in onMutate and could capture
  pre-mutation state, with nothing refreshing after the server accepted.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-07-29 11:34:23 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 435c558704
commit 6a048d08bd
7 changed files with 285 additions and 16 deletions
+32
View File
@@ -117,6 +117,12 @@ class FeedbackService {
throw new Error('Invalid reaction');
}
// Rating 0 clears the guest's rating (#884). Only the explicit zero
// sentinel (0, or "0" from callers that skip the route validator's
// toInt) triggers the destructive path — malformed input (undefined,
// NaN, null) must never delete an existing rating.
const isRatingClear = feedback_type === 'rating' && (rating === 0 || rating === '0');
// 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.
@@ -135,6 +141,26 @@ class FeedbackService {
const existing = await duplicateQuery.first();
if (existing) {
// Rating 0 clears the guest's rating (#884) — delete rather
// than store 0, which would drag the photo's average down and
// still count in total_ratings. Delete the full guest-scoped
// set, not existing.id: the check-then-insert above can race
// into duplicate rows (same defense as the reaction path), and
// clearing must not leave a stray duplicate in the average.
if (isRatingClear) {
const clearScope = db('photo_feedback').where({
photo_id: photoId,
event_id: eventId,
feedback_type: 'rating',
});
if (guest_id) clearScope.where('guest_id', guest_id);
else clearScope.where('guest_identifier', guestIdentifier);
await clearScope.delete();
await this.updatePhotoFeedbackStats(photoId);
return { removed: true };
}
if (feedback_type === 'rating' && rating !== existing.rating) {
// Update existing rating
await db('photo_feedback')
@@ -199,6 +225,12 @@ class FeedbackService {
}
}
// Rating 0 with no existing rating: nothing to clear — never insert a
// 0-rating row (#884).
if (isRatingClear) {
return { removed: 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.
+6 -3
View File
@@ -137,11 +137,14 @@ const validateFeedbackSubmission = [
.isIn(['rating', 'like', 'comment', 'favorite', 'reaction'])
.withMessage('Invalid feedback type'),
// Conditional validation based on feedback type
// Conditional validation based on feedback type. 0 clears the guest's
// existing rating (#884). toInt so a numeric string "0" reaches the
// service as a real 0 and hits the removal path.
body('rating')
.if(body('feedback_type').equals('rating'))
.isInt({ min: 1, max: 5 })
.withMessage('Rating must be between 1 and 5'),
.isInt({ min: 0, max: 5 })
.withMessage('Rating must be between 0 and 5')
.toInt(),
// Reactions (#839): fixed curated set only — no free-form emoji.
body('reaction')