Files
picpeak/backend/src/services/guestRecoveryService.js
T
Paul Nothaft ad4e5a7506 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.
2026-04-11 07:48:23 +02:00

132 lines
4.0 KiB
JavaScript

/**
* Guest identity recovery service (Phase 3.2).
*
* Sends a short-lived 6-digit verification code to a guest's email address
* so they can re-link their identity across devices. The code is stored as
* a bcrypt hash in `guest_verification_codes` with a 15-minute expiry.
*
* Uses the email transporter from emailProcessor — no new template row is
* needed; the email body is built inline so this works out of the box.
*/
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { initializeTransporter, wrapEmailHtml } = require('./emailProcessor');
const CODE_TTL_MS = 15 * 60 * 1000;
const MAX_ATTEMPTS = 5;
function generateCode() {
// 6 digits, zero-padded.
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
}
async function createCode(eventId, email) {
const code = generateCode();
const codeHash = await bcrypt.hash(code, 10);
const expiresAt = new Date(Date.now() + CODE_TTL_MS);
// Invalidate any previous unconsumed codes for this email+event.
await db('guest_verification_codes')
.where({ event_id: eventId, email: email.toLowerCase() })
.whereNull('consumed_at')
.update({ consumed_at: db.fn.now() });
await db('guest_verification_codes').insert({
event_id: eventId,
email: email.toLowerCase(),
code_hash: codeHash,
expires_at: expiresAt,
});
return code;
}
async function sendRecoveryEmail(toEmail, code, eventName = 'your gallery') {
const transporter = await initializeTransporter();
if (!transporter) {
throw new Error('Email service not configured');
}
const config = await db('email_configs').first();
if (!config) {
throw new Error('Email configuration not found');
}
const subject = `Your verification code: ${code}`;
const htmlBody = `
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px;">
<h2>Welcome back to ${eventName}</h2>
<p>Enter this code to recover your picks in the gallery:</p>
<div style="font-size: 32px; font-weight: bold; letter-spacing: 8px; background: #f5f5f5; padding: 20px; text-align: center; border-radius: 8px; margin: 20px 0;">
${code}
</div>
<p style="color: #666; font-size: 14px;">
This code expires in 15 minutes. If you did not request it, you can safely ignore this email.
</p>
</div>
`;
const styledHtml = await wrapEmailHtml(htmlBody, subject, 'en');
await transporter.sendMail({
from: `${config.from_name} <${config.from_email}>`,
to: toEmail,
subject,
html: styledHtml,
text: `Your verification code is ${code}. It expires in 15 minutes.`,
});
logger.info('Guest recovery code sent', { email: toEmail });
}
/**
* Verify a code. Returns true if valid + marks it consumed.
* Increments attempts on failure. Rejects after MAX_ATTEMPTS.
*/
async function verifyCode(eventId, email, submittedCode) {
const normalized = String(submittedCode || '').trim();
if (!/^\d{6}$/.test(normalized)) {
return { ok: false, reason: 'invalid_format' };
}
const row = await db('guest_verification_codes')
.where({ event_id: eventId, email: email.toLowerCase() })
.whereNull('consumed_at')
.andWhere('expires_at', '>', new Date())
.orderBy('created_at', 'desc')
.first();
if (!row) {
return { ok: false, reason: 'expired_or_missing' };
}
if (row.attempts >= MAX_ATTEMPTS) {
await db('guest_verification_codes').where('id', row.id).update({ consumed_at: db.fn.now() });
return { ok: false, reason: 'too_many_attempts' };
}
const matches = await bcrypt.compare(normalized, row.code_hash);
if (!matches) {
await db('guest_verification_codes')
.where('id', row.id)
.update({ attempts: row.attempts + 1 });
return { ok: false, reason: 'wrong_code' };
}
await db('guest_verification_codes')
.where('id', row.id)
.update({ consumed_at: db.fn.now() });
return { ok: true };
}
module.exports = {
createCode,
sendRecoveryEmail,
verifyCode,
CODE_TTL_MS,
MAX_ATTEMPTS,
};