feat: guest selections with per-person identity (#292)

Introduces a new "Per-guest selections" identity mode for event
feedback, letting each visitor register under their own name so their
likes/favorites/comments/ratings are tracked independently. Includes
admin insights (list, per-guest detail, aggregate view, export) and
advanced identity features (forget-me, email recovery, invite tokens,
merge).

New event-level setting
- event_feedback_settings.identity_mode = 'simple' | 'guest' (default
  'simple' → zero behavior change for existing events).
- Admin UI radio under Feedback Settings to toggle per event.

Root cause of the previous "all guests share state" bug
- generateGuestIdentifier() was sha256(ip + userAgent), so every visitor
  on the same WiFi + similar device collided into one identity.
- Now: when a verified guest JWT is present (x-guest-token header),
  req.guest.identifier takes precedence — per-person rate limits and
  per-person deduplication.

Phase 1 — identity layer
- Migration 078: new gallery_guests, guest_invites, guest_verification_
  codes tables; identity_mode column + check constraint; nullable
  guest_id FK on photo_feedback.
- New guest JWT type scoped to (eventId, guestId).
- New middleware guestAuth.resolveGuest (non-blocking) + requireGuest.
- POST /gallery/:slug/guest, GET /guest/me, DELETE /guest/me.
- Gallery feedback route enforces guest identity in guest mode and
  reads name/email from the verified token (never from the body).
- Frontend GuestIdentityContext + GuestNamePromptModal; axios
  interceptor injects x-guest-token on gallery API calls.
- Feedback-only blocking: gallery opens freely, prompt only on first
  interactive feedback action.
- Admin "Guests" tab (conditional on identity_mode='guest') with the
  AdminGuestsList component.

Phase 2 — admin insights
- GET /admin/events/:eventId/guests list + aggregated counts.
- GET /admin/events/:eventId/guests/:guestId detail with per-type
  groupings; AdminGuestDetail modal with thumbnail grid + tabs.
- GET /admin/events/:eventId/guests/aggregate sorted by distinct guest
  pick count; GuestSelectionsAggregate component.
- Per-guest export (txt/csv/json) and bulk export-all ZIP.

Phase 3 — polish
- 3.1 Self-service forget-me link in gallery footer.
- 3.2 Email-based identity recovery: POST /guest/recover sends a
  6-digit code via the existing emailProcessor, POST /guest/verify
  exchanges it for a token (rate-limited, enumeration-safe).
- 3.3 Admin invite tokens: pre-mint identities, share URLs with
  ?invite=, single-use redemption stripping the param from history.
- 3.4 Admin merge endpoint reassigns feedback + soft-deletes sources.

Shared helper
- useGalleryFeedbackAction hook wraps the identity-check logic for
  inline like buttons across Masonry/Grid/Justified/Mosaic/Carousel/
  Timeline/Premium layouts.

Backwards compatibility
- Existing events default to 'simple' after migration; behavior
  unchanged.
- Legacy photo_feedback rows keep guest_id NULL; admin shows them in
  the generic feedback moderation view as before.
- feedback_count denormalized stat now uses COALESCE(guest_id,
  guest_identifier) so per-guest counts are accurate without touching
  legacy rows.

Verified end-to-end against local Docker
- Migration clean on existing data.
- Simple mode unchanged (no prompt, legacy flow).
- Guest mode: Alice registers on click, tokens persist in
  sessionStorage, feedback rows carry guest_id.
- Carol via invite link auto-redeems, sees Alice's "1 likes" badge.
- Admin Guests tab shows both with correct counts; detail modal
  displays thumbnail grid with badges; aggregate view sorts by picker
  count (photo 227 = 2, others = 1); CSV/JSON export matches DB.
- Merge Carol into Alice: feedback reassigned, Carol soft-deleted,
  Alice count = 4.
This commit is contained in:
Paul Nothaft
2026-04-11 07:48:23 +02:00
parent d4b4dc628f
commit ad4e5a7506
41 changed files with 3609 additions and 66 deletions
+90 -9
View File
@@ -23,10 +23,15 @@ class FeedbackService {
allow_favorites: true,
require_name_email: false,
moderate_comments: true,
show_feedback_to_guests: true
show_feedback_to_guests: true,
identity_mode: 'simple'
};
}
// Back-compat: rows created before migration 078 have NULL identity_mode.
if (!settings.identity_mode) {
settings.identity_mode = 'simple';
}
return settings;
} catch (error) {
logger.error('Error getting feedback settings:', error);
@@ -73,23 +78,29 @@ class FeedbackService {
*/
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
try {
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent } = feedbackData;
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
// Validate feedback type
if (!['rating', 'like', 'comment', 'favorite'].includes(feedback_type)) {
throw new Error('Invalid feedback type');
}
// Check if similar feedback already exists (prevent duplicates)
// 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.
if (feedback_type !== 'comment') {
const existing = await db('photo_feedback')
const duplicateQuery = db('photo_feedback')
.where({
photo_id: photoId,
event_id: eventId,
feedback_type,
guest_identifier: guestIdentifier
})
.first();
});
if (guest_id) {
duplicateQuery.where('guest_id', guest_id);
} else {
duplicateQuery.where('guest_identifier', guestIdentifier);
}
const existing = await duplicateQuery.first();
if (existing) {
if (feedback_type === 'rating' && rating !== existing.rating) {
@@ -129,6 +140,7 @@ class FeedbackService {
guest_name,
guest_email,
guest_identifier: guestIdentifier,
guest_id: guest_id || null,
ip_address,
user_agent,
is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments,
@@ -232,7 +244,7 @@ class FeedbackService {
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('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']),
db.raw('COUNT(DISTINCT guest_identifier) as feedback_count')
db.raw('COUNT(DISTINCT COALESCE(CAST(guest_id AS VARCHAR), guest_identifier)) as feedback_count')
)
.first();
@@ -460,6 +472,75 @@ class FeedbackService {
throw error;
}
}
/**
* Anonymize feedback belonging to a guest — sets guest_id to NULL on all
* their feedback rows and clears guest_name/guest_email for privacy, then
* recomputes denormalized photo counts on affected photos.
*
* Used by self-service "forget me" and admin guest deletion.
*/
async anonymizeGuestFeedback(guestId) {
try {
const affected = await db('photo_feedback')
.where('guest_id', guestId)
.select('photo_id');
const photoIds = [...new Set(affected.map((r) => r.photo_id))];
await db('photo_feedback')
.where('guest_id', guestId)
.update({
guest_id: null,
guest_name: null,
guest_email: null,
updated_at: new Date(),
});
for (const pid of photoIds) {
await this.updatePhotoFeedbackStats(pid);
}
return { anonymized: affected.length, photos: photoIds.length };
} catch (error) {
logger.error('Error anonymizing guest feedback:', error);
throw error;
}
}
/**
* Merge feedback rows from sourceGuestIds into keepGuestId. Used by admin
* guest merge and email-based identity recovery when a user re-registers.
* Recomputes denormalized counts on affected photos.
*/
async mergeGuestFeedback(keepGuestId, sourceGuestIds) {
try {
const sources = (sourceGuestIds || []).filter((id) => id && id !== keepGuestId);
if (sources.length === 0) {
return { merged: 0, photos: 0 };
}
const affected = await db('photo_feedback')
.whereIn('guest_id', sources)
.select('photo_id');
const photoIds = [...new Set(affected.map((r) => r.photo_id))];
await db('photo_feedback')
.whereIn('guest_id', sources)
.update({
guest_id: keepGuestId,
updated_at: new Date(),
});
for (const pid of photoIds) {
await this.updatePhotoFeedbackStats(pid);
}
return { merged: affected.length, photos: photoIds.length };
} catch (error) {
logger.error('Error merging guest feedback:', error);
throw error;
}
}
}
module.exports = new FeedbackService();