* 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:
co-authored by
Paul Nothaft
parent
435c558704
commit
6a048d08bd
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user