diff --git a/backend/__tests__/utils/feedbackReactions.test.js b/backend/__tests__/utils/feedbackReactions.test.js new file mode 100644 index 00000000..b45f2861 --- /dev/null +++ b/backend/__tests__/utils/feedbackReactions.test.js @@ -0,0 +1,202 @@ +/** + * Emoji reactions (#839) — pins the contract of the `reaction` feedback type: + * - only emojis from the fixed curated set are accepted + * - one reaction per guest per photo: same emoji again toggles OFF, + * a different emoji SWITCHES the existing row (never a second row) + * - per-guest scoping mirrors likes: guest_id when present, else the + * device-hash guest_identifier — two token-guests on one device react + * independently + * - denormalized photos.reaction_count and the per-emoji tallies follow + * visibility: hidden-by-moderator reactions disappear from both + * - the long and pivoted exports carry the reaction + */ + +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-reactions-')), 'db.sqlite', +); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-reactions-test-secret'; + +const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb'); + +const feedbackService = require('../../src/services/feedbackService'); +const { REACTION_EMOJIS } = require('../../src/constants/reactions'); + +const EVENT_SLUG = 'reactions-test-event'; +const GUEST_A = 'guest-a-identifier'; +const GUEST_B = 'guest-b-identifier'; + +let db; +let cleanup; +let eventId; +let photoIds; + +async function react(photoId, emoji, { guestIdentifier = GUEST_A, guestId = null } = {}) { + return feedbackService.submitFeedback(photoId, eventId, { + feedback_type: 'reaction', + reaction: emoji, + guest_id: guestId, + ip_address: '127.0.0.1', + user_agent: 'jest', + }, guestIdentifier); +} + +async function reactionCountOf(photoId) { + const row = await db('photos').where('id', photoId).first(); + return Number(row.reaction_count) || 0; +} + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + await seedMinimal(db); + const inserted = await db('events').insert({ + slug: EVENT_SLUG, + event_type: 'wedding', + event_name: 'Reactions Test', + event_date: '2026-07-20', + host_email: 'host@example.com', + admin_email: 'admin@example.com', + password_hash: 'x', + share_link: `/gallery/${EVENT_SLUG}/share`, + share_token: 'reactions-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]; + + photoIds = []; + for (let i = 0; i < 3; i++) { + const photo = await db('photos').insert({ + event_id: eventId, + filename: `photo-${i}.jpg`, + path: `events/reactions/${i}.jpg`, + type: 'individual', + uploaded_at: new Date().toISOString(), + }).returning('id'); + photoIds.push(photo[0]?.id ?? photo[0]); + } +}, 120000); + +afterAll(async () => { + if (cleanup) await cleanup(); +}); + +describe('reaction submission (#839)', () => { + it('rejects emojis outside the curated set', async () => { + await expect(react(photoIds[0], '🦄')).rejects.toThrow('Invalid reaction'); + await expect(react(photoIds[0], undefined)).rejects.toThrow('Invalid reaction'); + expect(await reactionCountOf(photoIds[0])).toBe(0); + }); + + it('creates a reaction row and maintains the denormalized count', async () => { + const result = await react(photoIds[0], '❤️'); + expect(result.created).toBe(true); + + const row = await db('photo_feedback') + .where({ photo_id: photoIds[0], feedback_type: 'reaction' }) + .first(); + expect(row.reaction).toBe('❤️'); + expect(await reactionCountOf(photoIds[0])).toBe(1); + expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({ '❤️': 1 }); + }); + + it('switches to another emoji in place — never a second row per guest', async () => { + const result = await react(photoIds[0], '🎉'); + expect(result.updated).toBe(true); + + const rows = await db('photo_feedback') + .where({ photo_id: photoIds[0], feedback_type: 'reaction' }); + expect(rows).toHaveLength(1); + expect(rows[0].reaction).toBe('🎉'); + expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({ '🎉': 1 }); + }); + + it('tallies different guests per emoji', async () => { + await react(photoIds[0], '🎉', { guestIdentifier: GUEST_B }); + expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({ '🎉': 2 }); + expect(await reactionCountOf(photoIds[0])).toBe(2); + }); + + it('toggles off with the same emoji', async () => { + const result = await react(photoIds[0], '🎉'); + expect(result.removed).toBe(true); + expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({ '🎉': 1 }); // GUEST_B remains + expect(await reactionCountOf(photoIds[0])).toBe(1); + }); + + it('scopes per guest_id when present — two token-guests on one device stay independent', async () => { + const first = await react(photoIds[1], '😍', { guestIdentifier: GUEST_A, guestId: 101 }); + const second = await react(photoIds[1], '👏', { guestIdentifier: GUEST_A, guestId: 102 }); + expect(first.created).toBe(true); + expect(second.created).toBe(true); // NOT treated as guest 101's switch + expect(await feedbackService.getPhotoReactionCounts(photoIds[1])).toEqual({ '😍': 1, '👏': 1 }); + }); + + it('accepts every emoji of the curated set', async () => { + for (const emoji of REACTION_EMOJIS) { + const res = await react(photoIds[2], emoji, { guestIdentifier: `guest-${emoji}` }); + expect(res.created).toBe(true); + } + const counts = await feedbackService.getPhotoReactionCounts(photoIds[2]); + expect(Object.keys(counts)).toHaveLength(REACTION_EMOJIS.length); + }); + + it('hidden reactions leave both the per-emoji tallies and reaction_count', async () => { + const row = await db('photo_feedback') + .where({ photo_id: photoIds[0], feedback_type: 'reaction' }) + .first(); + await feedbackService.moderateFeedback(row.id, 'hide', 1); + + expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({}); + expect(await reactionCountOf(photoIds[0])).toBe(0); + + await feedbackService.moderateFeedback(row.id, 'approve', 1); + expect(await reactionCountOf(photoIds[0])).toBe(1); + }); + + it('toggle and switch collapse racy duplicate rows for the same guest', async () => { + // Simulate the check-then-insert race: two rows for one guest+photo. + const mk = (emoji) => ({ + photo_id: photoIds[1], event_id: eventId, feedback_type: 'reaction', + reaction: emoji, guest_identifier: 'dup-guest', is_approved: true, is_hidden: false, + created_at: new Date(), updated_at: new Date(), + }); + await db('photo_feedback').insert([mk('❤️'), mk('❤️')]); + + // Switching converges to exactly ONE row with the new emoji… + const switched = await react(photoIds[1], '🎉', { guestIdentifier: 'dup-guest' }); + expect(switched.updated).toBe(true); + let rows = await db('photo_feedback') + .where({ photo_id: photoIds[1], feedback_type: 'reaction', guest_identifier: 'dup-guest' }); + expect(rows).toHaveLength(1); + expect(rows[0].reaction).toBe('🎉'); + + // …and toggle-off removes the full guest-scoped set. + await db('photo_feedback').insert(mk('🎉')); + const removed = await react(photoIds[1], '🎉', { guestIdentifier: 'dup-guest' }); + expect(removed.removed).toBe(true); + rows = await db('photo_feedback') + .where({ photo_id: photoIds[1], feedback_type: 'reaction', guest_identifier: 'dup-guest' }); + expect(rows).toHaveLength(0); + }); + + it('summary and exports carry reactions', async () => { + const summary = await feedbackService.getEventFeedbackSummary(eventId); + expect(Number(summary.stats.total_reactions)).toBeGreaterThan(0); + + const longRows = await feedbackService.exportEventFeedback(eventId); + const longReaction = longRows.find((r) => r.feedback_type === 'reaction'); + expect(longReaction.reaction).toBeTruthy(); + + const pivotRows = await feedbackService.exportEventFeedbackPivoted(eventId); + const pivotWithReaction = pivotRows.find((r) => r.reaction); + expect(REACTION_EMOJIS).toContain(pivotWithReaction.reaction); + }); +}); diff --git a/backend/migrations/core/164_add_emoji_reactions.js b/backend/migrations/core/164_add_emoji_reactions.js new file mode 100644 index 00000000..8b0a0b4f --- /dev/null +++ b/backend/migrations/core/164_add_emoji_reactions.js @@ -0,0 +1,55 @@ +/** + * Emoji reactions on photos (#839). + * + * - event_feedback_settings.allow_reactions: per-event toggle next to + * allow_likes / allow_ratings / allow_comments. Defaults TRUE for parity + * with the sibling toggles — the master feedback_enabled gate (default + * false, opt-in per event) still decides whether any feedback UI shows. + * - photo_feedback.reaction: the emoji value for feedback_type='reaction' + * rows (validated against the fixed set in constants/reactions.js). + * - photos.reaction_count: denormalized total, maintained by + * updatePhotoFeedbackStats alongside like_count / favorite_count. + */ + +exports.up = async function (knex) { + const hasAllowReactions = await knex.schema.hasColumn('event_feedback_settings', 'allow_reactions'); + if (!hasAllowReactions) { + await knex.schema.alterTable('event_feedback_settings', (table) => { + table.boolean('allow_reactions').defaultTo(true); + }); + } + + const hasReaction = await knex.schema.hasColumn('photo_feedback', 'reaction'); + if (!hasReaction) { + await knex.schema.alterTable('photo_feedback', (table) => { + // 16 chars: emoji are multi-byte/multi-codepoint (variation selectors), + // but well under 16 characters each. + table.string('reaction', 16); + }); + } + + const hasReactionCount = await knex.schema.hasColumn('photos', 'reaction_count'); + if (!hasReactionCount) { + await knex.schema.alterTable('photos', (table) => { + table.integer('reaction_count').defaultTo(0); + }); + } +}; + +exports.down = async function (knex) { + if (await knex.schema.hasColumn('photos', 'reaction_count')) { + await knex.schema.alterTable('photos', (table) => { + table.dropColumn('reaction_count'); + }); + } + if (await knex.schema.hasColumn('photo_feedback', 'reaction')) { + await knex.schema.alterTable('photo_feedback', (table) => { + table.dropColumn('reaction'); + }); + } + if (await knex.schema.hasColumn('event_feedback_settings', 'allow_reactions')) { + await knex.schema.alterTable('event_feedback_settings', (table) => { + table.dropColumn('allow_reactions'); + }); + } +}; diff --git a/backend/src/constants/reactions.js b/backend/src/constants/reactions.js new file mode 100644 index 00000000..513904ad --- /dev/null +++ b/backend/src/constants/reactions.js @@ -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 }; diff --git a/backend/src/middleware/feedbackRateLimit.js b/backend/src/middleware/feedbackRateLimit.js index 1c2bd13b..7dad144d 100644 --- a/backend/src/middleware/feedbackRateLimit.js +++ b/backend/src/middleware/feedbackRateLimit.js @@ -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 } }; } } diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index 56b8252f..85f4f295 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -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, diff --git a/backend/src/routes/adminFeedback.js b/backend/src/routes/adminFeedback.js index 6519a8a1..74954378 100644 --- a/backend/src/routes/adminFeedback.js +++ b/backend/src/routes/adminFeedback.js @@ -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 diff --git a/backend/src/routes/adminGuests.js b/backend/src/routes/adminGuests.js index 2f24b2f7..7cbb5888 100644 --- a/backend/src/routes/adminGuests.js +++ b/backend/src/routes/adminGuests.js @@ -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, diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 6289909e..59d3771b 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -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' diff --git a/backend/src/routes/galleryFeedback.js b/backend/src/routes/galleryFeedback.js index 9ca5f00d..f973b13a 100644 --- a/backend/src/routes/galleryFeedback.js +++ b/backend/src/routes/galleryFeedback.js @@ -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 }); diff --git a/backend/src/services/feedbackService.js b/backend/src/services/feedbackService.js index 97bfb43e..5647945b 100644 --- a/backend/src/services/feedbackService.js +++ b/backend/src/services/feedbackService.js @@ -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; diff --git a/backend/src/utils/feedbackValidation.js b/backend/src/utils/feedbackValidation.js index eacace87..ac21c3ee 100644 --- a/backend/src/utils/feedbackValidation.js +++ b/backend/src/utils/feedbackValidation.js @@ -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(), diff --git a/frontend/src/components/admin/AdminGuestDetail.tsx b/frontend/src/components/admin/AdminGuestDetail.tsx index b1a1c60f..58594f3b 100644 --- a/frontend/src/components/admin/AdminGuestDetail.tsx +++ b/frontend/src/components/admin/AdminGuestDetail.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { X, Heart, Bookmark, Star, MessageCircle } from 'lucide-react'; +import { X, Heart, Bookmark, Star, MessageCircle, Smile } from 'lucide-react'; import { Loading } from '../common'; import { guestsService, AdminGuest } from '../../services/guests.service'; import { AuthenticatedImage } from '../common/AuthenticatedImage'; @@ -14,7 +14,7 @@ interface AdminGuestDetailProps { onClose: () => void; } -type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented'; +type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented' | 'reacted'; export const AdminGuestDetail: React.FC = ({ eventId, guest, onClose }) => { const { t } = useTranslation(); @@ -31,6 +31,7 @@ export const AdminGuestDetail: React.FC = ({ eventId, gue const favorited = selections?.favorited || []; const rated = selections?.rated || []; const commented = selections?.commented || []; + const reacted = selections?.reacted || []; // "all" view combines the three visual selection types. type GridItem = { photo: { id: number; filename: string; thumbnail_url: string }; badges: string[] }; @@ -48,6 +49,7 @@ export const AdminGuestDetail: React.FC = ({ eventId, gue liked.forEach((p) => add(p, 'like')); favorited.forEach((p) => add(p, 'favorite')); rated.forEach((r) => add(r.photo, 'rating')); + reacted.forEach((r) => add(r.photo, r.reaction)); const visibleItems: GridItem[] = tab === 'all' @@ -58,6 +60,8 @@ export const AdminGuestDetail: React.FC = ({ eventId, gue ? favorited.map((p) => ({ photo: p, badges: ['favorite'] })) : tab === 'rated' ? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}★`] })) + : tab === 'reacted' + ? reacted.map((r) => ({ photo: r.photo, badges: [r.reaction] })) : []; return ( @@ -87,7 +91,7 @@ export const AdminGuestDetail: React.FC = ({ eventId, gue ) : (
{/* Stats */} -
+
{liked.length} @@ -124,11 +128,20 @@ export const AdminGuestDetail: React.FC = ({ eventId, gue {t('admin.guests.columns.comments', 'Comments')}
+
+
+ {reacted.length} +
+
+ + {t('admin.guests.columns.reactions', 'Reactions')} +
+
{/* Tabs */}
- {(['all', 'liked', 'favorited', 'rated', 'commented'] as const).map((k) => ( + {(['all', 'liked', 'favorited', 'rated', 'commented', 'reacted'] as const).map((k) => (
+ +
diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index b63eca31..aa4a8d51 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -721,6 +721,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { allowFavorites: !!feedbackSettings?.allow_favorites, allowRatings: !!feedbackSettings?.allow_ratings, allowComments: !!feedbackSettings?.allow_comments, + allowReactions: !!feedbackSettings?.allow_reactions, requireNameEmail: !!feedbackSettings?.require_name_email, }} isSelectionMode={isSelectionMode} @@ -967,6 +968,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { allowFavorites: !!feedbackSettings?.allow_favorites, allowRatings: !!feedbackSettings?.allow_ratings, allowComments: !!feedbackSettings?.allow_comments, + allowReactions: !!feedbackSettings?.allow_reactions, requireNameEmail: !!feedbackSettings?.require_name_email, }} isSelectionMode={isSelectionMode} diff --git a/frontend/src/components/gallery/PhotoFeedback.tsx b/frontend/src/components/gallery/PhotoFeedback.tsx index 3b74a697..a98ab36b 100644 --- a/frontend/src/components/gallery/PhotoFeedback.tsx +++ b/frontend/src/components/gallery/PhotoFeedback.tsx @@ -4,6 +4,7 @@ import { feedbackService } from '../../services/feedback.service'; import { PhotoRating } from './PhotoRating'; import { PhotoLikes } from './PhotoLikes'; import { PhotoFavorites } from './PhotoFavorites'; +import { PhotoReactions } from './PhotoReactions'; import { PhotoComments } from './PhotoComments'; import { Skeleton } from '../common'; @@ -45,6 +46,8 @@ export const PhotoFeedback: React.FC = ({ const [likeCount, setLikeCount] = useState(0); const [isFavorited, setIsFavorited] = useState(false); const [favoriteCount, setFavoriteCount] = useState(0); + const [myReaction, setMyReaction] = useState(null); + const [reactionCounts, setReactionCounts] = useState>({}); // Update local state when data loads useEffect(() => { @@ -54,6 +57,8 @@ export const PhotoFeedback: React.FC = ({ setLikeCount(Number(feedbackData.summary.like_count) || 0); setIsFavorited(Boolean(feedbackData.my_feedback.favorited)); setFavoriteCount(Number(feedbackData.summary.favorite_count) || 0); + setMyReaction(feedbackData.my_feedback.reaction || null); + setReactionCounts(feedbackData.reactions || {}); } }, [feedbackData]); @@ -75,6 +80,18 @@ export const PhotoFeedback: React.FC = ({ if (onFeedbackUpdate) onFeedbackUpdate(); }; + // Optimistic reaction switch: decrement the old emoji, increment the new. + const handleReactionChange = (reaction: string | null) => { + setReactionCounts(prev => { + const next = { ...prev }; + if (myReaction) next[myReaction] = Math.max(0, (next[myReaction] || 0) - 1); + if (reaction) next[reaction] = (next[reaction] || 0) + 1; + return next; + }); + setMyReaction(reaction); + if (onFeedbackUpdate) onFeedbackUpdate(); + }; + if (settingsLoading) { return (
@@ -89,7 +106,8 @@ export const PhotoFeedback: React.FC = ({ } const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes || - settings.allow_comments || settings.allow_favorites; + settings.allow_comments || settings.allow_favorites || + settings.allow_reactions; if (!hasAnyFeedbackType) { return null; @@ -140,6 +158,19 @@ export const PhotoFeedback: React.FC = ({
)} + {/* Emoji reactions (#839) */} + {settings.allow_reactions && ( + + )} + {/* Comments Section */} {settings.allow_comments && showComments && (
diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 618541e2..84f0052c 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -79,6 +79,7 @@ export const PhotoLightbox: React.FC = ({ allow_likes?: boolean; allow_ratings?: boolean; allow_comments?: boolean; + allow_reactions?: boolean; show_feedback_to_guests?: boolean; require_name_email?: boolean; } | null>(null); @@ -750,12 +751,11 @@ export const PhotoLightbox: React.FC = ({
)} - {/* Feedback button with indicator. Gated on allow_comments - because likes/ratings already have their own dedicated - toolbar buttons above — this MessageSquare button only - opens the comments panel, so it has nothing to do when - comments are off (#518). */} - {feedbackEnabled && feedbackSettings?.allow_comments && ( + {/* Feedback button with indicator. Likes/ratings have their + own dedicated toolbar buttons above, so this panel toggle + only has work to do when comments (#518) or the emoji + reaction bar (#839) live inside the panel. */} + {feedbackEnabled && (feedbackSettings?.allow_comments || feedbackSettings?.allow_reactions) && ( + ); + })} + + { setShowIdentityModal(false); setPendingEmoji(null); }} + onSubmit={handleIdentitySubmit} + feedbackType={t('feedback.reaction', 'reaction')} + /> + + ); +}; diff --git a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx index e0bff52d..c97bf163 100644 --- a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx @@ -29,6 +29,7 @@ export interface BaseGalleryLayoutProps { allowFavorites?: boolean; allowRatings?: boolean; allowComments?: boolean; + allowReactions?: boolean; requireNameEmail?: boolean; }; // Logout callback for full-page layouts diff --git a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.css b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.css index ed95e939..7c24d717 100644 --- a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.css +++ b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.css @@ -408,3 +408,17 @@ .gallery-premium-animate-in { animation: galleryPremiumFadeIn 0.4s ease forwards; } + +/* Emoji reaction bar over the lightbox (#839). Sits above YARL's container + (z-index 9999) and clear of the bottom thumbnail strip. */ +.gallery-premium-lightbox-reactions { + position: fixed; + left: 50%; + transform: translateX(-50%); + bottom: 122px; + z-index: 10000; + background: rgba(20, 20, 20, 0.75); + border-radius: 9999px; + padding: 6px 10px; + backdrop-filter: blur(6px); +} diff --git a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx index cebba08f..e6338214 100644 --- a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { MasonryPhotoAlbum } from 'react-photo-album'; import 'react-photo-album/masonry.css'; import Lightbox from 'yet-another-react-lightbox'; @@ -19,6 +20,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; import { AuthenticatedImage } from '../../common'; import { feedbackService } from '../../../services/feedback.service'; +import { PhotoReactions } from '../PhotoReactions'; import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext'; import { FeedbackIdentityModal } from '../FeedbackIdentityModal'; import { galleryService } from '../../../services/gallery.service'; @@ -208,6 +210,14 @@ export const GalleryPremiumLayout: React.FC = ({ likedSeededRef.current = true; }, [photos]); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); + // Emoji reactions (#839) inside the premium lightbox. This layout uses + // yet-another-react-lightbox instead of the shared PhotoLightbox, so the + // reaction bar is a fixed overlay fed by its own per-photo fetch. + const [reactionState, setReactionState] = useState<{ + photoId: number; + mine: string | null; + counts: Record; + } | null>(null); const guestIdentity = useGuestIdentityOptional(); const [showIdentityModal, setShowIdentityModal] = useState(false); const [pendingLikePhotoId, setPendingLikePhotoId] = useState(null); @@ -229,6 +239,41 @@ export const GalleryPremiumLayout: React.FC = ({ return photos.filter(photo => photo.category_name === activeCategory); }, [photos, activeCategory]); + const currentLightboxPhoto = lightboxIndex >= 0 ? filteredPhotos[lightboxIndex] : null; + const reactionsActive = feedbackEnabled && !!feedbackOptions?.allowReactions; + + // Fetch the current photo's reaction tallies + my selection when the + // lightbox lands on it. Optimistic updates below keep it fresh in place. + useEffect(() => { + if (!currentLightboxPhoto || !reactionsActive) { + setReactionState(null); + return undefined; + } + let alive = true; + feedbackService.getPhotoFeedback(slug, String(currentLightboxPhoto.id)) + .then((d) => { + if (!alive) return; + setReactionState({ + photoId: currentLightboxPhoto.id, + mine: d.my_feedback.reaction || null, + counts: d.reactions || {}, + }); + }) + .catch(() => { /* bar simply stays hidden for this photo */ }); + return () => { alive = false; }; + }, [currentLightboxPhoto?.id, reactionsActive, slug]); + + const handleReactionChange = useCallback((next: string | null) => { + setReactionState((prev) => { + if (!prev) return prev; + const counts = { ...prev.counts }; + if (prev.mine) counts[prev.mine] = Math.max(0, (counts[prev.mine] || 0) - 1); + if (next) counts[next] = (counts[next] || 0) + 1; + return { ...prev, mine: next, counts }; + }); + onFeedbackChange?.(); + }, [onFeedbackChange]); + // Get hero photo const heroPhoto = heroPhotoOverride || photos[0]; @@ -587,6 +632,26 @@ export const GalleryPremiumLayout: React.FC = ({ }} /> + {/* Emoji reaction bar over the lightbox (#839). Portaled to + document.body: inside the layout tree an ancestor stacking context + (framer-motion transforms) would paint it UNDER yarl's body-level + portal and its backdrop would swallow every click. As a body child + the z-index 10000 genuinely beats yarl's 9999. */} + {currentLightboxPhoto && reactionsActive && reactionState?.photoId === currentLightboxPhoto.id && createPortal( +
+ +
, + document.body + )} + {/* Identity Modal */} { allow_likes: true, allow_comments: true, allow_favorites: true, + allow_reactions: true, require_name_email: false, moderate_comments: true, show_feedback_to_guests: true, @@ -482,6 +484,7 @@ export const CreateEventPage: React.FC = () => { allow_likes: feedbackSettings.allow_likes, allow_comments: feedbackSettings.allow_comments, allow_favorites: feedbackSettings.allow_favorites, + allow_reactions: feedbackSettings.allow_reactions, require_name_email: feedbackSettings.require_name_email, moderate_comments: feedbackSettings.moderate_comments, show_feedback_to_guests: feedbackSettings.show_feedback_to_guests, diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index ac3fd15a..c661bbfe 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -45,6 +45,7 @@ export const EventDetailsPage: React.FC = () => { allow_likes: true, allow_comments: true, allow_favorites: true, + allow_reactions: true, require_name_email: false, moderate_comments: true, show_feedback_to_guests: true, diff --git a/frontend/src/pages/admin/EventFeedbackPage.tsx b/frontend/src/pages/admin/EventFeedbackPage.tsx index 28a57dfc..945b7868 100644 --- a/frontend/src/pages/admin/EventFeedbackPage.tsx +++ b/frontend/src/pages/admin/EventFeedbackPage.tsx @@ -6,6 +6,7 @@ import { MessageSquare, Star, Heart, + Smile, TrendingUp, Filter, Download, @@ -249,6 +250,7 @@ export const EventFeedbackPage: React.FC = () => { +