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
+12 -1
View File
@@ -3,9 +3,20 @@ const { db } = require('../database/db');
const logger = require('../utils/logger');
/**
* Generate a unique identifier for the guest
* Generate a unique identifier for the guest.
*
* In guest identity mode, `req.guest.identifier` is a server-issued UUID
* unique per person per event (set by the resolveGuest middleware). When
* present it takes precedence, so rate limits and deduplication become
* per-person instead of per-device.
*
* In simple (legacy) mode, the identifier falls back to a hash of IP + UA,
* matching prior behavior.
*/
function generateGuestIdentifier(req) {
if (req.guest && req.guest.identifier) {
return req.guest.identifier;
}
const ip = req.ip || req.connection.remoteAddress || 'unknown';
const userAgent = req.headers['user-agent'] || 'unknown';
return crypto
+105
View File
@@ -0,0 +1,105 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getGuestTokenFromRequest } = require('../utils/tokenUtils');
/**
* Non-blocking middleware. Reads an optional guest token from the request and,
* if present and valid, populates req.guest with { id, identifier, name, eventId }.
*
* If the token is missing, malformed, or expired → req.guest = null and the
* request continues. Downstream handlers (e.g. feedback submission) enforce
* presence explicitly based on event feedback settings (identity_mode).
*/
async function resolveGuest(req, res, next) {
try {
const slug = req.params?.slug;
const token = getGuestTokenFromRequest(req, slug);
if (!token) {
req.guest = null;
return next();
}
let decoded;
try {
const verified = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true,
});
decoded = verified.payload;
} catch (err) {
// Invalid or expired guest tokens are silently ignored so that public
// gallery browsing continues to work even if the token is stale.
logger.debug('Invalid guest token', { reason: err.message });
req.guest = null;
return next();
}
if (decoded.type !== 'guest') {
req.guest = null;
return next();
}
// Verify the guest row still exists and is not soft-deleted.
const guest = await db('gallery_guests')
.where({ id: decoded.guestId, event_id: decoded.eventId, is_deleted: false })
.first();
if (!guest) {
req.guest = null;
return next();
}
req.guest = {
id: guest.id,
eventId: guest.event_id,
identifier: guest.identifier,
name: guest.name,
email: guest.email || null,
};
return next();
} catch (error) {
logger.error('resolveGuest middleware error', { error: error.message });
req.guest = null;
return next();
}
}
/**
* Blocking middleware that 401s if no guest identity was resolved.
* Use this on endpoints that require a valid guest session.
*/
function requireGuest(req, res, next) {
if (!req.guest) {
return res.status(401).json({ error: 'Guest identity required' });
}
return next();
}
/**
* Sign a new guest JWT. Scoped to a specific event and guest row.
* Expiry matches the gallery token default (24h).
*/
function signGuestToken({ guestId, eventId, identifier, name }, expiresIn = '24h') {
return jwt.sign(
{
type: 'guest',
guestId,
eventId,
identifier,
name,
},
process.env.JWT_SECRET,
{
issuer: 'picpeak-auth',
expiresIn,
}
);
}
module.exports = {
resolveGuest,
requireGuest,
signGuestToken,
};