Files
picpeak/backend/__tests__/utils/feedbackReactions.test.js
T
Paul Nothaft 3d6c9848dc 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 <paul@MacStudio-von-Paul.local>
2026-07-22 20:59:43 +02:00

203 lines
8.1 KiB
JavaScript

/**
* 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);
});
});