feat(feedback): emoji reactions on photos (#839) (#855)

* feat(feedback): emoji reactions on photos (#839)

Per-photo emoji reactions from a fixed curated set (❤️ 😂 😍 👏 🎉),
one reaction per guest per photo — same emoji toggles off, another
switches in place. Stored as feedback_type='reaction' rows with per-guest
scoping identical to likes (guest_id when present, device hash otherwise).

- migration 164: allow_reactions toggle (default on, still gated by the
  opt-in feedback_enabled master switch), photo_feedback.reaction value
  column, denormalized photos.reaction_count
- emoji whitelist enforced in the route validator AND the service
  (shared constants/reactions.js, mirrored in the frontend)
- per-emoji tallies + my_feedback.reaction in the photo feedback
  endpoint; hidden-by-moderator reactions leave all counts
- reactions ride the existing rate limiting (like-tier), guest identity
  modes, and moderation actions; long + pivot exports carry the emoji
- gallery: reaction bar in the photo feedback panel (grid lightbox);
  admin: allow_reactions toggle next to likes, analytics tile,
  create/duplicate event paths
- i18n for all 8 locales; 9 service-level tests

* fix(feedback): reach reactions without comments; numeric analytics totals (#839)

- the lightbox feedback-panel toggle was gated on allow_comments only —
  with comments off the new reaction bar was unreachable; the gate now
  opens for comments OR reactions
- the analytics summary now coerces Postgres string counts to numbers:
  total_feedback concatenated instead of adding ("00006")

* fix(feedback): harden reactions from review round 1 (#839)

- per-emoji tallies are gated on show_feedback_to_guests — with sharing
  off a guest sees only their own selection, no aggregate counts
- reaction toggle/switch operate on the guest-scoped row SET, so rows
  duplicated by the (like-parity) check-then-insert race collapse on the
  next interaction instead of counting twice
- rate-limit defaults merge UNDER the persisted settings object —
  stored rows predating the reaction key otherwise dropped it to the
  generic 100/h fallback
- optimistic revert uses the pre-mutation value via mutation context;
  the onError closure sees the post-optimistic render, so the old
  revert froze the wrong state on failed toggles

* fix(feedback): review round 2 — hide reaction_count with sharing off, admin list shows emoji (#839)

- summary.reaction_count is gated on show_feedback_to_guests like the
  per-emoji map, keeping the "no aggregates while sharing is off"
  promise consistent
- the admin feedback list renders the reaction emoji on reaction rows
  and the type filter gains a Reactions option (7 locales; es has no
  types block and falls back to EN defaults)

* fix(feedback): register reaction activity types with translated labels (#839)

photo_reaction / guest_feedback_reaction are logged by the submission
paths but were absent from the frontend activity-type union and the
admin.activities label maps — the recent-activity feed would have shown
the raw identifiers. All 8 locales.

* feat(feedback): reactions in guest CRM and the premium gallery layout (#839)

- guest CRM: per-guest reaction counts in the list aggregation and a
  Reacted tab (photo grid with emoji badges) + stats card in the guest
  detail modal; picks/aggregate/exports stay selection-only by design
- premium layout: its own yet-another-react-lightbox now gets a fixed
  reaction-bar overlay (per-photo fetch, optimistic switch) — reactions
  were otherwise unreachable in this layout since it bypasses the
  shared PhotoLightbox
- allowReactions threaded through the layout feedbackOptions; guest
  i18n keys for the 7 locales that carry the guests block

* fix(feedback): portal the premium reaction bar to document.body (#839)

Inside the layout tree an ancestor stacking context (framer-motion
transforms) painted the bar under yarl's body-level portal — visible
but unclickable, every tap landed on the slide image. As a direct body
child the z-index 10000 genuinely wins over yarl's 9999. Verified by
clicking through in the running app.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-07-22 20:59:43 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent f8a95d29d2
commit 3d6c9848dc
36 changed files with 885 additions and 54 deletions
+11
View File
@@ -0,0 +1,11 @@
/**
* Emoji reactions (#839): the fixed, curated reaction set. Guests pick ONE
* of these per photo (changeable). Kept as a shared constant so the
* validator, the service and the export layer can never drift apart.
*
* Mirrored in frontend/src/services/feedback.service.ts (REACTION_EMOJIS) —
* update both together.
*/
const REACTION_EMOJIS = ['❤️', '😂', '😍', '👏', '🎉'];
module.exports = { REACTION_EMOJIS };
+18 -11
View File
@@ -34,20 +34,26 @@ async function getRateLimitSettings() {
.where('setting_key', 'feedback_rate_limits')
.first();
if (settings && settings.setting_value) {
// setting_value is already a JSON object in PostgreSQL
return typeof settings.setting_value === 'string'
? JSON.parse(settings.setting_value)
: settings.setting_value;
}
// Default settings
return {
// Defaults FIRST, stored values override: persisted rows predate newer
// action types (`reaction`, #839) — returning the stored object alone
// would silently drop their intended defaults to the generic 100/h.
const defaults = {
rating: { max: 100, window: 3600 }, // 100 ratings per hour
comment: { max: 20, window: 3600 }, // 20 comments per hour
like: { max: 200, window: 3600 }, // 200 likes per hour
favorite: { max: 100, window: 3600 } // 100 favorites per hour
favorite: { max: 100, window: 3600 }, // 100 favorites per hour
reaction: { max: 200, window: 3600 } // reactions churn like likes (#839)
};
if (settings && settings.setting_value) {
// setting_value is already a JSON object in PostgreSQL
const stored = typeof settings.setting_value === 'string'
? JSON.parse(settings.setting_value)
: settings.setting_value;
return { ...defaults, ...stored };
}
return defaults;
} catch (error) {
logger.error('Error getting rate limit settings:', error);
// Return defaults on error
@@ -55,7 +61,8 @@ async function getRateLimitSettings() {
rating: { max: 100, window: 3600 },
comment: { max: 20, window: 3600 },
like: { max: 200, window: 3600 },
favorite: { max: 100, window: 3600 }
favorite: { max: 100, window: 3600 },
reaction: { max: 200, window: 3600 }
};
}
}
+3
View File
@@ -165,6 +165,7 @@ module.exports = (router) => {
allow_likes = true,
allow_comments = true,
allow_favorites = true,
allow_reactions = true,
require_name_email = false,
moderate_comments = true,
show_feedback_to_guests = true,
@@ -490,6 +491,7 @@ module.exports = (router) => {
allow_likes: formatBoolean(allow_likes),
allow_comments: formatBoolean(allow_comments),
allow_favorites: formatBoolean(allow_favorites),
allow_reactions: formatBoolean(allow_reactions),
require_name_email: formatBoolean(require_name_email),
moderate_comments: formatBoolean(moderate_comments),
show_feedback_to_guests: formatBoolean(show_feedback_to_guests),
@@ -1111,6 +1113,7 @@ module.exports = (router) => {
allow_likes: sourceFeedback.allow_likes,
allow_comments: sourceFeedback.allow_comments,
allow_favorites: sourceFeedback.allow_favorites,
allow_reactions: sourceFeedback.allow_reactions,
require_name_email: sourceFeedback.require_name_email,
moderate_comments: sourceFeedback.moderate_comments,
show_feedback_to_guests: sourceFeedback.show_feedback_to_guests,
+14 -9
View File
@@ -233,17 +233,22 @@ router.get('/events/:eventId/feedback-analytics',
.count('* as count')
.first();
// Number() everywhere: Postgres COUNT() comes back as a string, and
// string + string in the total_feedback sum concatenates ("00006").
const counts = {
total_ratings: Number(summaryData.stats?.total_ratings) || 0,
total_likes: Number(summaryData.stats?.total_likes) || 0,
total_comments: Number(summaryData.stats?.total_comments) || 0,
total_favorites: Number(summaryData.stats?.total_favorites) || 0,
total_reactions: Number(summaryData.stats?.total_reactions) || 0,
};
const summary = {
average_rating: parseFloat(avgRatingResult?.average_rating || 0),
total_ratings: summaryData.stats?.total_ratings || 0,
total_likes: summaryData.stats?.total_likes || 0,
total_comments: summaryData.stats?.total_comments || 0,
total_favorites: summaryData.stats?.total_favorites || 0,
pending_moderation: pendingModeration?.count || 0,
total_feedback: (summaryData.stats?.total_ratings || 0) +
(summaryData.stats?.total_likes || 0) +
(summaryData.stats?.total_comments || 0) +
(summaryData.stats?.total_favorites || 0)
...counts,
pending_moderation: Number(pendingModeration?.count) || 0,
total_feedback: counts.total_ratings + counts.total_likes +
counts.total_comments + counts.total_favorites +
counts.total_reactions
};
// Get top-rated photos
+7
View File
@@ -78,6 +78,7 @@ router.get(
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'favorite\' THEN 1 END) AS favorites'),
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'comment\' THEN 1 END) AS comments'),
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'rating\' THEN 1 END) AS ratings'),
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'reaction\' THEN 1 END) AS reactions'),
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
)
.orderBy('gallery_guests.created_at', 'desc');
@@ -89,6 +90,7 @@ router.get(
favorites: parseInt(r.favorites, 10) || 0,
comments: parseInt(r.comments, 10) || 0,
ratings: parseInt(r.ratings, 10) || 0,
reactions: parseInt(r.reactions, 10) || 0,
distinct_photos: parseInt(r.distinct_photos, 10) || 0,
},
}));
@@ -401,6 +403,7 @@ router.get(
'photo_feedback.feedback_type',
'photo_feedback.rating',
'photo_feedback.comment_text',
'photo_feedback.reaction',
'photo_feedback.created_at',
'photos.id as photo_id',
'photos.filename',
@@ -423,6 +426,7 @@ router.get(
favorited: [],
rated: [],
commented: [],
reacted: [],
};
for (const row of feedback) {
if (row.feedback_type === 'like') {
@@ -437,6 +441,8 @@ router.get(
comment: row.comment_text,
created_at: row.created_at,
});
} else if (row.feedback_type === 'reaction') {
selections.reacted.push({ photo: photoFor(row), reaction: row.reaction });
}
}
@@ -448,6 +454,7 @@ router.get(
favorites: selections.favorited.length,
comments: selections.commented.length,
ratings: selections.rated.length,
reactions: selections.reacted.length,
},
},
selections,
+1
View File
@@ -1956,6 +1956,7 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites,
allow_reactions: settings.allow_reactions,
show_feedback_to_guests: settings.show_feedback_to_guests,
require_name_email: settings.require_name_email || false,
identity_mode: settings.identity_mode || 'simple'
+17 -3
View File
@@ -31,6 +31,7 @@ router.get('/:slug/feedback-settings',
allow_likes: Boolean(settings.allow_likes),
allow_comments: Boolean(settings.allow_comments),
allow_favorites: Boolean(settings.allow_favorites),
allow_reactions: Boolean(settings.allow_reactions),
require_name_email: Boolean(settings.require_name_email),
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests),
identity_mode: settings.identity_mode || 'simple',
@@ -117,6 +118,9 @@ router.get('/:slug/photos/:photoId/feedback',
.then(r => r.count),
like_count: photo.like_count || 0,
favorite_count: photo.favorite_count || 0,
// Gated like the per-emoji map below — aggregate reaction data is
// a new surface, kept fully hidden while sharing is off.
reaction_count: settings.show_feedback_to_guests ? (photo.reaction_count || 0) : 0,
comment_count: await db('photo_feedback')
.where({
photo_id: photoId,
@@ -128,10 +132,17 @@ router.get('/:slug/photos/:photoId/feedback',
.first()
.then(r => r.count)
},
// Per-emoji tallies for the reaction bar (#839). Gated on
// show_feedback_to_guests: with sharing off a guest sees only their
// own selection (my_feedback.reaction below), no aggregate counts.
reactions: settings.show_feedback_to_guests
? await feedbackService.getPhotoReactionCounts(photoId)
: {},
my_feedback: {
rating: guestFeedback.find(f => f.feedback_type === 'rating')?.rating,
liked: !!guestFeedback.find(f => f.feedback_type === 'like'),
favorited: !!guestFeedback.find(f => f.feedback_type === 'favorite')
favorited: !!guestFeedback.find(f => f.feedback_type === 'favorite'),
reaction: guestFeedback.find(f => f.feedback_type === 'reaction')?.reaction || null
}
});
} catch (error) {
@@ -181,7 +192,8 @@ router.post('/:slug/photos/:photoId/feedback',
rating: settings.allow_ratings,
like: settings.allow_likes,
comment: settings.allow_comments,
favorite: settings.allow_favorites
favorite: settings.allow_favorites,
reaction: settings.allow_reactions
};
if (!typeAllowed[feedbackType]) {
@@ -227,6 +239,7 @@ router.post('/:slug/photos/:photoId/feedback',
feedback_type: feedbackType,
rating: req.body.rating,
comment_text: req.body.comment_text,
reaction: req.body.reaction,
guest_name: req.guest?.name ?? req.body.guest_name,
guest_email: req.guest?.email ?? req.body.guest_email,
guest_id: req.guest?.id ?? null,
@@ -346,7 +359,8 @@ router.get('/:slug/feedback-summary',
allow_ratings: settings.allow_ratings,
allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites
allow_favorites: settings.allow_favorites,
allow_reactions: settings.allow_reactions
},
summary: guestSummary
});
+85 -7
View File
@@ -1,6 +1,7 @@
const { db, logActivity } = require('../database/db');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
const { REACTION_EMOJIS } = require('../constants/reactions');
class FeedbackService {
/**
@@ -21,6 +22,7 @@ class FeedbackService {
allow_likes: true,
allow_comments: false,
allow_favorites: true,
allow_reactions: true,
require_name_email: false,
moderate_comments: true,
show_feedback_to_guests: true,
@@ -102,13 +104,19 @@ class FeedbackService {
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
try {
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
const { feedback_type, rating, comment_text, reaction, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
// Validate feedback type
if (!['rating', 'like', 'comment', 'favorite'].includes(feedback_type)) {
if (!['rating', 'like', 'comment', 'favorite', 'reaction'].includes(feedback_type)) {
throw new Error('Invalid feedback type');
}
// Reactions come from a fixed curated set — anything else is rejected
// (the route validator enforces this too; this is the last line).
if (feedback_type === 'reaction' && !REACTION_EMOJIS.includes(reaction)) {
throw new Error('Invalid reaction');
}
// 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.
@@ -140,6 +148,41 @@ class FeedbackService {
return { id: existing.id, updated: true };
}
// One reaction per guest per photo, changeable (#839): the same
// emoji again toggles it off; a different one switches the row.
// Toggle/switch act on the full guest-scoped QUERY, not existing.id:
// like the sibling like path, the check-then-insert above can race
// into duplicate rows — operating on the set makes the next
// interaction collapse them instead of leaving a phantom count.
const reactionScope = () => {
const q = db('photo_feedback').where({
photo_id: photoId,
event_id: eventId,
feedback_type: 'reaction',
});
if (guest_id) q.where('guest_id', guest_id);
else q.where('guest_identifier', guestIdentifier);
return q;
};
if (feedback_type === 'reaction') {
if (existing.reaction === reaction) {
await reactionScope().delete();
await this.updatePhotoFeedbackStats(photoId);
return { removed: true };
}
// Converge to exactly one row: drop any racy duplicates, then
// switch the surviving row's emoji.
await reactionScope().whereNot('id', existing.id).delete();
await db('photo_feedback')
.where('id', existing.id)
.update({
reaction,
updated_at: new Date()
});
await this.updatePhotoFeedbackStats(photoId);
return { id: existing.id, updated: true };
}
// 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).
@@ -189,6 +232,7 @@ class FeedbackService {
feedback_type,
rating: feedback_type === 'rating' ? rating : null,
comment_text: feedback_type === 'comment' ? comment_text : null,
reaction: feedback_type === 'reaction' ? reaction : null,
guest_name,
guest_email,
guest_identifier: guestIdentifier,
@@ -241,7 +285,7 @@ class FeedbackService {
const feedback = await query
.orderBy('created_at', 'desc')
.select('id', 'feedback_type', 'rating', 'comment_text', 'guest_name', 'created_at', 'is_approved', 'is_hidden');
.select('id', 'feedback_type', 'rating', 'comment_text', 'reaction', 'guest_name', 'created_at', 'is_approved', 'is_hidden');
return feedback;
} catch (error) {
@@ -257,7 +301,7 @@ class FeedbackService {
try {
const photos = await db('photos')
.where('event_id', eventId)
.select('id', 'filename', 'feedback_count', 'like_count', 'average_rating', 'favorite_count')
.select('id', 'filename', 'feedback_count', 'like_count', 'average_rating', 'favorite_count', 'reaction_count')
.orderBy('average_rating', 'desc')
.orderBy('like_count', 'desc');
@@ -268,7 +312,8 @@ class FeedbackService {
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_ratings', ['rating']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_likes', ['like']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_comments', ['comment']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_favorites', ['favorite'])
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_favorites', ['favorite']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_reactions', ['reaction'])
)
.first();
@@ -282,6 +327,31 @@ class FeedbackService {
}
}
/**
* Per-emoji reaction counts for one photo (#839): { '❤️': 3, '👏': 1 }.
* Only visible rows count — hidden-by-moderator reactions disappear from
* the tallies the same way hidden likes leave like_count.
*/
async getPhotoReactionCounts(photoId) {
try {
const rows = await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'reaction' })
.where('is_hidden', false)
.groupBy('reaction')
.select('reaction')
.count('id as count');
const counts = {};
for (const row of rows) {
if (row.reaction) counts[row.reaction] = Number(row.count) || 0;
}
return counts;
} catch (error) {
logger.error('Error getting photo reaction counts:', error);
throw error;
}
}
/**
* Update photo feedback statistics
*/
@@ -295,11 +365,12 @@ class FeedbackService {
db.raw('COUNT(CASE WHEN feedback_type = ? AND is_approved = ? THEN 1 END) as comment_count', ['comment', formatBoolean(true)]),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as like_count', ['like']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as favorite_count', ['favorite']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as reaction_count', ['reaction']),
db.raw('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']),
db.raw('COUNT(DISTINCT COALESCE(CAST(guest_id AS VARCHAR), guest_identifier)) as feedback_count')
)
.first();
// Update photo table
await db('photos')
.where('id', photoId)
@@ -307,7 +378,8 @@ class FeedbackService {
feedback_count: stats.feedback_count || 0,
like_count: stats.like_count || 0,
average_rating: stats.average_rating || 0,
favorite_count: stats.favorite_count || 0
favorite_count: stats.favorite_count || 0,
reaction_count: stats.reaction_count || 0
});
} catch (error) {
logger.error('Error updating photo feedback stats:', error);
@@ -443,6 +515,7 @@ class FeedbackService {
'photo_feedback.feedback_type',
'photo_feedback.rating',
'photo_feedback.comment_text',
'photo_feedback.reaction',
'photo_feedback.guest_name',
'photo_feedback.guest_email',
'photo_feedback.created_at'
@@ -480,6 +553,7 @@ class FeedbackService {
'photo_feedback.feedback_type',
'photo_feedback.rating',
'photo_feedback.comment_text',
'photo_feedback.reaction',
'photo_feedback.guest_name',
'photo_feedback.guest_email',
'photo_feedback.guest_identifier',
@@ -505,6 +579,7 @@ class FeedbackService {
is_liked: false,
star_rating: '',
comment: '',
reaction: '',
latest_at: row.created_at,
};
byKey.set(key, entry);
@@ -531,6 +606,9 @@ class FeedbackService {
entry.comment = row.comment_text;
}
break;
case 'reaction':
if (row.reaction) entry.reaction = row.reaction;
break;
default:
// Unknown feedback type — ignore so a future type doesn't break the export.
break;
+10 -2
View File
@@ -1,6 +1,7 @@
const { body, param, validationResult } = require('express-validator');
const validator = require('validator');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('./emailNormalization');
const { REACTION_EMOJIS } = require('../constants/reactions');
/**
* Validation rules for feedback submission
@@ -133,14 +134,20 @@ function getValidationRules(feedbackType) {
*/
const validateFeedbackSubmission = [
body('feedback_type')
.isIn(['rating', 'like', 'comment', 'favorite'])
.isIn(['rating', 'like', 'comment', 'favorite', 'reaction'])
.withMessage('Invalid feedback type'),
// Conditional validation based on feedback type
body('rating')
.if(body('feedback_type').equals('rating'))
.isInt({ min: 1, max: 5 })
.withMessage('Rating must be between 1 and 5'),
// Reactions (#839): fixed curated set only — no free-form emoji.
body('reaction')
.if(body('feedback_type').equals('reaction'))
.custom((value) => REACTION_EMOJIS.includes(value))
.withMessage('Invalid reaction'),
body('comment_text')
.if(body('feedback_type').equals('comment'))
@@ -183,6 +190,7 @@ const validateFeedbackSettings = [
body('allow_likes').optional().isBoolean(),
body('allow_comments').optional().isBoolean(),
body('allow_favorites').optional().isBoolean(),
body('allow_reactions').optional().isBoolean(),
body('require_name_email').optional().isBoolean(),
body('moderate_comments').optional().isBoolean(),
body('show_feedback_to_guests').optional().isBoolean(),