fix(gallery): a guest's own hidden feedback is hidden from them too (#1150) (#1153)

Everything in the system treats a hidden row as absent — getPhotoFeedback drops it even for the guest's own feedback, and updatePhotoFeedbackStats does not count it. The per-viewer is_liked heart and my_color_label badge read the row without looking at is_hidden, so a like the photographer had hidden still showed as liked on a photo whose like_count was zero.

Making those agree exposes why it had not been fixed: the duplicate check behind like/favorite toggling did not skip hidden rows either, so the now-empty heart, when clicked, found the hidden row and toggled it OFF — the click did nothing visible and the moderation was silently undone. Skipping hidden rows there makes the click create a fresh, visible row.

Review found four more surfaces still treating a hidden row as present: the per-guest caps (an at-cap guest with one hidden met their own click with limit_reached), /my-feedback (which drives the Liked/Favorited/Rated chips in guest identity mode), getEventFeedbackSummary (disagreeing with the photo counters in the same response), and unhide (leaving two visible rows for one guest). The rating-clear and single-value delete scopes are visible-only now, so a follow-up mutation no longer destroys the admin's hidden record, and the unhide collapse is skipped when there is no stable identity to scope by — that fallback was 'guest_identifier IS NULL', i.e. other visitors' rows.

Not taken: refusing to hide non-comment feedback, which the issue recommended. #839 and #1044 both ship hiding for reactions and colour labels with tests asserting a hidden one stops counting; only the admin UI's Hide button is comment-only.

Merged with admin privileges: the author cannot self-approve.
This commit is contained in:
Paul Nothaft
2026-08-23 22:09:07 +02:00
committed by GitHub
parent 00b20b2d72
commit 2c81888eaf
4 changed files with 363 additions and 12 deletions
+8 -2
View File
@@ -881,7 +881,12 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
const likedPhotoIds = new Set();
if (showFeedbackToGuests && photos.length > 0) {
const likeQuery = db('photo_feedback')
.where({ event_id: req.event.id, feedback_type: 'like' })
// Hidden rows are not there, for the viewer's OWN feedback as much as
// anyone's (#1150). getPhotoFeedback drops them, the filter drops them
// and updatePhotoFeedbackStats does not count them — leaving the heart
// filled was the one place that disagreed, so a like the photographer
// had hidden still showed as liked on a photo whose like_count was 0.
.where({ event_id: req.event.id, feedback_type: 'like', is_hidden: false })
.whereIn('photo_id', photos.map(p => p.id));
if (req.guest?.id) {
likeQuery.where('guest_id', req.guest.id);
@@ -899,7 +904,8 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
const myColorLabelByPhoto = {};
if (photos.length > 0) {
const colorQuery = db('photo_feedback')
.where({ event_id: req.event.id, feedback_type: 'color_label' })
// Same rule as the heart above (#1150).
.where({ event_id: req.event.id, feedback_type: 'color_label', is_hidden: false })
.whereIn('photo_id', photos.map(p => p.id));
if (req.guest?.id) {
colorQuery.where('guest_id', req.guest.id);
+8 -1
View File
@@ -414,7 +414,14 @@ router.get('/:slug/my-feedback',
const query = db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.event_id', event.id);
.where('photo_feedback.event_id', event.id)
// Hidden rows are absent for the guest who left them too (#1150). In
// guest identity mode GalleryView builds its Liked/Favorited/Rated
// chips and their filters from THIS array rather than from is_liked,
// so without this a hidden like left an empty heart while the Liked
// chip still counted it and still surfaced the photo. Unapproved rows
// stay: a comment in the moderation queue is still the guest's own.
.where('photo_feedback.is_hidden', false);
// Prefer guest_id lookup when a verified guest token is present
// (per-person identity). Fall back to the device hash otherwise.
+64 -9
View File
@@ -147,7 +147,12 @@ class FeedbackService {
*/
async countGuestFeedback(eventId, feedbackType, guestId, guestIdentifier) {
const query = db('photo_feedback')
.where({ event_id: eventId, feedback_type: feedbackType });
// Hidden rows do not count against the guest's cap (#1150). They are
// absent everywhere else — the heart is empty, the tallies skip them,
// and submitFeedback now treats one as room for a fresh row. Counting
// them here would meet that fresh row with limit_reached and leave the
// control dead until the guest un-likes something they can still see.
.where({ event_id: eventId, feedback_type: feedbackType, is_hidden: false });
if (guestId) {
query.where('guest_id', guestId);
} else {
@@ -192,6 +197,13 @@ class FeedbackService {
photo_id: photoId,
event_id: eventId,
feedback_type,
// A hidden row is not there (#1150). Without this the guest saw an
// empty heart — every read surface treats hidden as absent — and
// clicking it found the hidden row and TOGGLED IT OFF, so the
// click appeared to do nothing and it took two more to get back to
// a filled heart. Skipping it makes the click create a fresh,
// visible row, which is what the guest is asking for.
is_hidden: false,
});
if (guest_id) {
duplicateQuery.where('guest_id', guest_id);
@@ -208,10 +220,15 @@ class FeedbackService {
// into duplicate rows (same defense as the reaction path), and
// clearing must not leave a stray duplicate in the average.
if (isRatingClear) {
// Visible rows only (#1150). A hidden row can now sit alongside
// the guest's replacement, and it is the admin's moderation
// record — clearing a rating must not destroy it, or there is
// nothing left to review or unhide.
const clearScope = db('photo_feedback').where({
photo_id: photoId,
event_id: eventId,
feedback_type: 'rating',
is_hidden: false,
});
if (guest_id) clearScope.where('guest_id', guest_id);
else clearScope.where('guest_identifier', guestIdentifier);
@@ -246,11 +263,16 @@ class FeedbackService {
const singleValueColumn = SINGLE_VALUE_COLUMNS[feedback_type];
if (singleValueColumn) {
const submittedValue = feedback_type === 'reaction' ? reaction : color_label;
// Visible rows only, same reason as the rating clear above: the
// toggle-off and the duplicate collapse below both DELETE over
// this scope, and a hidden original is the admin's record rather
// than a racy duplicate of the guest's own.
const singleValueScope = () => {
const q = db('photo_feedback').where({
photo_id: photoId,
event_id: eventId,
feedback_type,
is_hidden: false,
});
if (guest_id) q.where('guest_id', guest_id);
else q.where('guest_identifier', guestIdentifier);
@@ -406,6 +428,11 @@ class FeedbackService {
const totalStats = await db('photo_feedback')
.where('event_id', eventId)
// Hidden rows do not count, the same rule the photo counters above
// already apply — without this the two halves of THIS response
// disagreed, and a hidden row preserved beside its replacement (#1150)
// is counted twice.
.where('is_hidden', false)
.select(
db.raw('COUNT(DISTINCT CASE WHEN feedback_type = ? THEN guest_identifier END) as unique_raters', ['rating']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_ratings', ['rating']),
@@ -555,6 +582,11 @@ class FeedbackService {
*/
async moderateFeedback(feedbackId, action, adminId) {
try {
const target = await db('photo_feedback').where('id', feedbackId).first();
if (!target) {
throw new Error('Feedback not found');
}
const updates = {
updated_at: new Date()
};
@@ -569,17 +601,40 @@ class FeedbackService {
updates.is_hidden = true;
}
const feedback = await db('photo_feedback')
.where('id', feedbackId)
.first();
if (!feedback) {
throw new Error('Feedback not found');
}
const feedback = target;
await db('photo_feedback')
.where('id', feedbackId)
.update(updates);
// Unhiding can collide with a replacement (#1150). A hidden row reads as
// absent, so the guest may well have re-added the same feedback in the
// meantime; making the original visible again would leave TWO visible
// rows for one guest on one photo — double-counted in the tallies, and
// needing two toggles to clear because each one deletes a single row.
//
// Converge on the row the admin acted on, the same way the submit path
// collapses racy duplicates. Comments are exempt: several from one guest
// on one photo is normal.
// Needs a stable identity to scope the collapse to ONE guest. With
// neither id nor identifier the fallback degrades to
// `guest_identifier IS NULL`, which is every identifier-less row on the
// photo — other people's, deleted. Nothing to converge in that case, so
// leave it alone.
const collapseIdentity = feedback.guest_id || feedback.guest_identifier;
if (updates.is_hidden === false && feedback.feedback_type !== 'comment' && collapseIdentity) {
const superseded = db('photo_feedback')
.where({
photo_id: feedback.photo_id,
event_id: feedback.event_id,
feedback_type: feedback.feedback_type,
is_hidden: false,
})
.whereNot('id', feedbackId);
if (feedback.guest_id) superseded.where('guest_id', feedback.guest_id);
else superseded.where('guest_identifier', feedback.guest_identifier);
await superseded.delete();
}
// Update photo stats if visibility changed
await this.updatePhotoFeedbackStats(feedback.photo_id);