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:
@@ -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();
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user