diff --git a/backend/migrations/core/078_add_guest_identity.js b/backend/migrations/core/078_add_guest_identity.js new file mode 100644 index 00000000..ef01f93d --- /dev/null +++ b/backend/migrations/core/078_add_guest_identity.js @@ -0,0 +1,120 @@ +/** + * Add guest identity layer for per-person photo selections (issue #292). + * + * Adds: + * - gallery_guests — persistent guest profiles per event + * - guest_invites — pre-minted invite tokens (Phase 3.3) + * - guest_verification_codes — email-based identity recovery (Phase 3.2) + * - event_feedback_settings.identity_mode ('simple' | 'guest', default 'simple') + * - photo_feedback.guest_id FK — links feedback to gallery_guests (nullable) + * + * All changes are additive. Existing events default to 'simple' mode so behavior + * is unchanged. Legacy photo_feedback rows keep NULL guest_id. + */ + +exports.up = async function(knex) { + // 1. gallery_guests — persistent per-person identity within an event. + const hasGalleryGuests = await knex.schema.hasTable('gallery_guests'); + if (!hasGalleryGuests) { + await knex.schema.createTable('gallery_guests', (table) => { + table.increments('id').primary(); + table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE'); + table.string('name', 100).notNullable(); + table.string('email', 255); + table.string('identifier', 64).notNullable(); // UUIDv4 issued server-side + table.string('ip_address_last', 45); + table.text('user_agent_last'); + table.timestamp('email_verified_at'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('last_seen_at').defaultTo(knex.fn.now()); + table.boolean('is_deleted').defaultTo(false); + + table.unique(['event_id', 'identifier']); + table.index(['event_id']); + table.index(['event_id', 'email']); + }); + } + + // 2. guest_invites — pre-minted one-time-use tokens for invited guests. + const hasGuestInvites = await knex.schema.hasTable('guest_invites'); + if (!hasGuestInvites) { + await knex.schema.createTable('guest_invites', (table) => { + table.increments('id').primary(); + table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE'); + table.integer('guest_id').notNullable().references('id').inTable('gallery_guests').onDelete('CASCADE'); + table.string('token', 64).notNullable().unique(); + table.integer('created_by_admin_id').references('id').inTable('admin_users'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('redeemed_at'); + table.timestamp('revoked_at'); + + table.index(['event_id']); + table.index(['guest_id']); + }); + } + + // 3. guest_verification_codes — short-lived codes for email-based recovery. + const hasGuestVerificationCodes = await knex.schema.hasTable('guest_verification_codes'); + if (!hasGuestVerificationCodes) { + await knex.schema.createTable('guest_verification_codes', (table) => { + table.increments('id').primary(); + table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE'); + table.string('email', 255).notNullable(); + table.string('code_hash', 128).notNullable(); // bcrypt hash of 6-digit code + table.integer('attempts').defaultTo(0); + table.timestamp('expires_at').notNullable(); + table.timestamp('consumed_at'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + table.index(['event_id', 'email']); + table.index(['expires_at']); + }); + } + + // 4. event_feedback_settings.identity_mode + const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode'); + if (!hasIdentityMode) { + await knex.schema.alterTable('event_feedback_settings', (table) => { + table.string('identity_mode', 16).notNullable().defaultTo('simple'); + }); + if (knex.client.config.client === 'pg') { + await knex.raw(` + ALTER TABLE event_feedback_settings + ADD CONSTRAINT event_feedback_settings_identity_mode_check + CHECK (identity_mode IN ('simple','guest')) + `); + } + } + + // 5. photo_feedback.guest_id FK + const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id'); + if (!hasGuestIdColumn) { + await knex.schema.alterTable('photo_feedback', (table) => { + table.integer('guest_id').references('id').inTable('gallery_guests').onDelete('SET NULL'); + table.index(['guest_id']); + }); + } +}; + +exports.down = async function(knex) { + const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id'); + if (hasGuestIdColumn) { + await knex.schema.alterTable('photo_feedback', (table) => { + table.dropColumn('guest_id'); + }); + } + + if (knex.client.config.client === 'pg') { + await knex.raw('ALTER TABLE event_feedback_settings DROP CONSTRAINT IF EXISTS event_feedback_settings_identity_mode_check'); + } + const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode'); + if (hasIdentityMode) { + await knex.schema.alterTable('event_feedback_settings', (table) => { + table.dropColumn('identity_mode'); + }); + } + + await knex.schema.dropTableIfExists('guest_verification_codes'); + await knex.schema.dropTableIfExists('guest_invites'); + await knex.schema.dropTableIfExists('gallery_guests'); +}; diff --git a/backend/server.js b/backend/server.js index 5e977a40..5bd6f1b4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -512,12 +512,14 @@ app.use('/api/auth', authRoutes); // Gallery routes - main routes first, then feedback routes app.use('/api/gallery', galleryRoutes); app.use('/api/gallery', require('./src/routes/galleryFeedback')); +app.use('/api/gallery', require('./src/routes/galleryGuests')); app.use('/api/admin', adminRoutes); app.use('/api/admin/auth', adminAuthRoutes); app.use('/api/admin/system', require('./src/routes/adminSystem')); app.use('/api/admin/backup', require('./src/routes/adminBackup')); app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup')); app.use('/api/admin/feedback', require('./src/routes/adminFeedback')); +app.use('/api/admin', require('./src/routes/adminGuests')); app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity')); app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails')); app.use('/api/admin/photos', require('./src/routes/adminPhotoDimensions')); diff --git a/backend/src/middleware/feedbackRateLimit.js b/backend/src/middleware/feedbackRateLimit.js index 2f38b74d..1c2bd13b 100644 --- a/backend/src/middleware/feedbackRateLimit.js +++ b/backend/src/middleware/feedbackRateLimit.js @@ -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 diff --git a/backend/src/middleware/guestAuth.js b/backend/src/middleware/guestAuth.js new file mode 100644 index 00000000..f50e9d1d --- /dev/null +++ b/backend/src/middleware/guestAuth.js @@ -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, +}; diff --git a/backend/src/routes/adminGuests.js b/backend/src/routes/adminGuests.js new file mode 100644 index 00000000..0cf6eacd --- /dev/null +++ b/backend/src/routes/adminGuests.js @@ -0,0 +1,609 @@ +const express = require('express'); +const crypto = require('crypto'); +const archiver = require('archiver'); +const router = express.Router(); +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const { requireEventOwnership } = require('../middleware/ownership'); +const feedbackService = require('../services/feedbackService'); +const logger = require('../utils/logger'); + +const FRONTEND_URL = process.env.FRONTEND_URL || ''; + +// ---------------------------------------------------------------------------- +// Helpers +// ---------------------------------------------------------------------------- + +async function loadGuestOr404(eventId, guestId, res) { + const guest = await db('gallery_guests') + .where({ id: guestId, event_id: eventId, is_deleted: false }) + .first(); + if (!guest) { + res.status(404).json({ error: 'Guest not found' }); + return null; + } + return guest; +} + +function serializeGuest(row) { + return { + id: row.id, + name: row.name, + email: row.email, + created_at: row.created_at, + last_seen_at: row.last_seen_at, + email_verified_at: row.email_verified_at, + is_deleted: row.is_deleted, + }; +} + +function escapeCsvCell(value) { + const str = value == null ? '' : String(value); + if (/[,"\n\r]/.test(str)) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +// ---------------------------------------------------------------------------- +// GET /admin/events/:eventId/guests — list guests with aggregated counts +// ---------------------------------------------------------------------------- + +router.get( + '/events/:eventId/guests', + adminAuth, + requirePermission('events.view'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId } = req.params; + + const rows = await db('gallery_guests') + .leftJoin('photo_feedback', function () { + this.on('photo_feedback.guest_id', '=', 'gallery_guests.id'); + }) + .where('gallery_guests.event_id', eventId) + .where('gallery_guests.is_deleted', false) + .groupBy('gallery_guests.id') + .select( + 'gallery_guests.id', + 'gallery_guests.name', + 'gallery_guests.email', + 'gallery_guests.created_at', + 'gallery_guests.last_seen_at', + 'gallery_guests.email_verified_at', + db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'like' THEN 1 END) AS likes"), + db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'favorite' THEN 1 END) AS favorites"), + db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'comment' THEN 1 END) AS comments"), + db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'rating' THEN 1 END) AS ratings"), + db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos') + ) + .orderBy('gallery_guests.created_at', 'desc'); + + const guests = rows.map((r) => ({ + ...serializeGuest(r), + stats: { + likes: parseInt(r.likes, 10) || 0, + favorites: parseInt(r.favorites, 10) || 0, + comments: parseInt(r.comments, 10) || 0, + ratings: parseInt(r.ratings, 10) || 0, + distinct_photos: parseInt(r.distinct_photos, 10) || 0, + }, + })); + + res.json({ guests }); + } catch (error) { + logger.error('Error listing guests:', error); + res.status(500).json({ error: 'Failed to list guests' }); + } + } +); + +// ---------------------------------------------------------------------------- +// GET /admin/events/:eventId/guests/aggregate — photos sorted by distinct +// guest pick count (Phase 2 aggregate view) +// ---------------------------------------------------------------------------- + +router.get( + '/events/:eventId/guests/aggregate', + adminAuth, + requirePermission('events.view'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId } = req.params; + + const photos = await db('photos') + .leftJoin('photo_feedback', function () { + this.on('photo_feedback.photo_id', '=', 'photos.id') + .andOn(db.raw("photo_feedback.feedback_type IN ('like','favorite')")) + .andOnNotNull('photo_feedback.guest_id'); + }) + .where('photos.event_id', eventId) + .groupBy('photos.id') + .select( + 'photos.id', + 'photos.filename', + 'photos.original_filename', + db.raw('COUNT(DISTINCT photo_feedback.guest_id) AS picker_count') + ) + .orderBy('picker_count', 'desc') + .orderBy('photos.id', 'desc'); + + res.json({ + photos: photos + .filter((p) => parseInt(p.picker_count, 10) > 0) + .map((p) => ({ + id: p.id, + filename: p.filename, + original_filename: p.original_filename, + url: `/admin/photos/${eventId}/photo/${p.id}`, + thumbnail_url: `/admin/photos/${eventId}/thumbnail/${p.id}`, + picker_count: parseInt(p.picker_count, 10), + })), + }); + } catch (error) { + logger.error('Error fetching aggregate view:', error); + res.status(500).json({ error: 'Failed to fetch aggregate view' }); + } + } +); + +// ---------------------------------------------------------------------------- +// GET /admin/events/:eventId/guests/invites — list pre-minted invites +// ---------------------------------------------------------------------------- + +router.get( + '/events/:eventId/guests/invites', + adminAuth, + requirePermission('events.view'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId } = req.params; + const event = await db('events').where({ id: eventId }).first(); + + const rows = await db('guest_invites') + .leftJoin('gallery_guests', 'gallery_guests.id', 'guest_invites.guest_id') + .where('guest_invites.event_id', eventId) + .select( + 'guest_invites.id', + 'guest_invites.token', + 'guest_invites.created_at', + 'guest_invites.redeemed_at', + 'guest_invites.revoked_at', + 'gallery_guests.id as guest_id', + 'gallery_guests.name as guest_name', + 'gallery_guests.email as guest_email' + ) + .orderBy('guest_invites.created_at', 'desc'); + + const invites = rows.map((r) => ({ + id: r.id, + token: r.token, + url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${r.token}`, + created_at: r.created_at, + redeemed_at: r.redeemed_at, + revoked_at: r.revoked_at, + status: r.revoked_at ? 'revoked' : r.redeemed_at ? 'redeemed' : 'pending', + guest: { + id: r.guest_id, + name: r.guest_name, + email: r.guest_email, + }, + })); + + res.json({ invites }); + } catch (error) { + logger.error('Error listing invites:', error); + res.status(500).json({ error: 'Failed to list invites' }); + } + } +); + +// ---------------------------------------------------------------------------- +// POST /admin/events/:eventId/guests/invites — create guest + invite +// Body: { name, email? } +// ---------------------------------------------------------------------------- + +router.post( + '/events/:eventId/guests/invites', + adminAuth, + requirePermission('events.edit'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId } = req.params; + const name = String(req.body?.name || '').trim().slice(0, 100); + const email = String(req.body?.email || '').trim().slice(0, 255).toLowerCase(); + if (!name) { + return res.status(400).json({ error: 'Name is required' }); + } + + const identifier = crypto.randomUUID(); + const inviteToken = crypto.randomBytes(24).toString('hex'); + + let guestId; + let inviteId; + await db.transaction(async (trx) => { + const [guestRow] = await trx('gallery_guests') + .insert({ + event_id: eventId, + name, + email: email || null, + identifier, + }) + .returning(['id']); + guestId = guestRow.id; + + const [inviteRow] = await trx('guest_invites') + .insert({ + event_id: eventId, + guest_id: guestId, + token: inviteToken, + created_by_admin_id: req.admin.id, + }) + .returning(['id']); + inviteId = inviteRow.id; + }); + + await logActivity( + 'guest_invite_created', + { event_id: eventId, guest_id: guestId, invite_id: inviteId }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + const event = await db('events').where({ id: eventId }).first(); + res.json({ + invite: { + id: inviteId, + token: inviteToken, + url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${inviteToken}`, + status: 'pending', + guest: { id: guestId, name, email: email || null }, + }, + }); + } catch (error) { + logger.error('Error creating invite:', error); + res.status(500).json({ error: 'Failed to create invite' }); + } + } +); + +// ---------------------------------------------------------------------------- +// DELETE /admin/events/:eventId/guests/invites/:inviteId — revoke +// ---------------------------------------------------------------------------- + +router.delete( + '/events/:eventId/guests/invites/:inviteId', + adminAuth, + requirePermission('events.edit'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId, inviteId } = req.params; + const updated = await db('guest_invites') + .where({ id: inviteId, event_id: eventId }) + .whereNull('revoked_at') + .update({ revoked_at: db.fn.now() }); + + if (!updated) { + return res.status(404).json({ error: 'Invite not found or already revoked' }); + } + + await logActivity( + 'guest_invite_revoked', + { event_id: eventId, invite_id: inviteId }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ success: true }); + } catch (error) { + logger.error('Error revoking invite:', error); + res.status(500).json({ error: 'Failed to revoke invite' }); + } + } +); + +// ---------------------------------------------------------------------------- +// GET /admin/events/:eventId/guests/export-all — ZIP of per-guest exports +// Query: format=txt|csv|json (default: csv) +// ---------------------------------------------------------------------------- + +router.get( + '/events/:eventId/guests/export-all', + adminAuth, + requirePermission('events.view'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId } = req.params; + const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'csv'; + + const guests = await db('gallery_guests') + .where({ event_id: eventId, is_deleted: false }) + .select('id', 'name', 'email'); + + if (guests.length === 0) { + return res.status(404).json({ error: 'No guests to export' }); + } + + res.setHeader('Content-Type', 'application/zip'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="event-${eventId}-guests.zip"` + ); + + const archive = archiver('zip', { zlib: { level: 9 } }); + archive.on('error', (err) => { + logger.error('Archive error:', err); + res.status(500).end(); + }); + archive.pipe(res); + + for (const g of guests) { + const selections = await db('photo_feedback') + .join('photos', 'photo_feedback.photo_id', 'photos.id') + .where('photo_feedback.guest_id', g.id) + .whereIn('photo_feedback.feedback_type', ['like', 'favorite']) + .select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type'); + + const safeName = g.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${g.id}`; + const filename = `${safeName}.${format}`; + + let body; + if (format === 'json') { + body = JSON.stringify({ guest: g, selections }, null, 2); + } else if (format === 'csv') { + const header = 'filename,original_filename,feedback_type'; + const rows = selections.map( + (s) => + `${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}` + ); + body = [header, ...rows].join('\n'); + } else { + // txt — just filenames + body = selections.map((s) => s.original_filename || s.filename).join('\n'); + } + archive.append(body, { name: filename }); + } + + await archive.finalize(); + } catch (error) { + logger.error('Error exporting all guests:', error); + if (!res.headersSent) { + res.status(500).json({ error: 'Failed to export guests' }); + } + } + } +); + +// ---------------------------------------------------------------------------- +// GET /admin/events/:eventId/guests/:guestId — guest detail with selections +// (Phase 2) +// ---------------------------------------------------------------------------- + +router.get( + '/events/:eventId/guests/:guestId', + adminAuth, + requirePermission('events.view'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId, guestId } = req.params; + const guest = await loadGuestOr404(eventId, guestId, res); + if (!guest) return; + + const feedback = await db('photo_feedback') + .join('photos', 'photo_feedback.photo_id', 'photos.id') + .where('photo_feedback.guest_id', guestId) + .select( + 'photo_feedback.id as feedback_id', + 'photo_feedback.feedback_type', + 'photo_feedback.rating', + 'photo_feedback.comment_text', + 'photo_feedback.created_at', + 'photos.id as photo_id', + 'photos.filename', + 'photos.original_filename', + 'photos.type' + ) + .orderBy('photo_feedback.created_at', 'desc'); + + const photoFor = (row) => ({ + id: row.photo_id, + filename: row.filename, + original_filename: row.original_filename, + type: row.type, + url: `/admin/photos/${eventId}/photo/${row.photo_id}`, + thumbnail_url: `/admin/photos/${eventId}/thumbnail/${row.photo_id}`, + }); + + const selections = { + liked: [], + favorited: [], + rated: [], + commented: [], + }; + for (const row of feedback) { + if (row.feedback_type === 'like') { + selections.liked.push(photoFor(row)); + } else if (row.feedback_type === 'favorite') { + selections.favorited.push(photoFor(row)); + } else if (row.feedback_type === 'rating') { + selections.rated.push({ photo: photoFor(row), rating: row.rating }); + } else if (row.feedback_type === 'comment') { + selections.commented.push({ + photo: photoFor(row), + comment: row.comment_text, + created_at: row.created_at, + }); + } + } + + res.json({ + guest: { + ...serializeGuest(guest), + stats: { + likes: selections.liked.length, + favorites: selections.favorited.length, + comments: selections.commented.length, + ratings: selections.rated.length, + }, + }, + selections, + }); + } catch (error) { + logger.error('Error fetching guest detail:', error); + res.status(500).json({ error: 'Failed to fetch guest detail' }); + } + } +); + +// ---------------------------------------------------------------------------- +// GET /admin/events/:eventId/guests/:guestId/export — per-guest export +// Query: format=txt|csv|json +// ---------------------------------------------------------------------------- + +router.get( + '/events/:eventId/guests/:guestId/export', + adminAuth, + requirePermission('events.view'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId, guestId } = req.params; + const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'txt'; + const guest = await loadGuestOr404(eventId, guestId, res); + if (!guest) return; + + const selections = await db('photo_feedback') + .join('photos', 'photo_feedback.photo_id', 'photos.id') + .where('photo_feedback.guest_id', guestId) + .whereIn('photo_feedback.feedback_type', ['like', 'favorite']) + .select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type'); + + const safeName = guest.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${guest.id}`; + const filename = `${safeName}.${format}`; + + if (format === 'json') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + return res.send(JSON.stringify({ guest: serializeGuest(guest), selections }, null, 2)); + } + if (format === 'csv') { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + const header = 'filename,original_filename,feedback_type'; + const rows = selections.map( + (s) => + `${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}` + ); + return res.send([header, ...rows].join('\n')); + } + // txt — one filename per line + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + return res.send(selections.map((s) => s.original_filename || s.filename).join('\n')); + } catch (error) { + logger.error('Error exporting guest:', error); + res.status(500).json({ error: 'Failed to export guest' }); + } + } +); + +// ---------------------------------------------------------------------------- +// DELETE /admin/events/:eventId/guests/:guestId — anonymize (soft delete) +// ---------------------------------------------------------------------------- + +router.delete( + '/events/:eventId/guests/:guestId', + adminAuth, + requirePermission('events.edit'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId, guestId } = req.params; + const guest = await loadGuestOr404(eventId, guestId, res); + if (!guest) return; + + const result = await feedbackService.anonymizeGuestFeedback(guestId); + + await db('gallery_guests').where({ id: guestId }).update({ + is_deleted: true, + name: 'Removed', + email: null, + last_seen_at: db.fn.now(), + }); + + await logActivity( + 'guest_deleted', + { event_id: eventId, guest_id: guestId, anonymized: result.anonymized }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ success: true, ...result }); + } catch (error) { + logger.error('Error deleting guest:', error); + res.status(500).json({ error: 'Failed to delete guest' }); + } + } +); + +// ---------------------------------------------------------------------------- +// POST /admin/events/:eventId/guests/:keepId/merge — merge guests (Phase 3.4) +// Body: { mergeIds: number[] } +// ---------------------------------------------------------------------------- + +router.post( + '/events/:eventId/guests/:keepId/merge', + adminAuth, + requirePermission('events.edit'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId, keepId } = req.params; + const mergeIds = Array.isArray(req.body?.mergeIds) ? req.body.mergeIds : []; + + if (mergeIds.length === 0) { + return res.status(400).json({ error: 'mergeIds is required' }); + } + if (mergeIds.includes(Number(keepId))) { + return res.status(400).json({ error: 'Cannot merge a guest into itself' }); + } + + // Sanity check: all guests belong to this event. + const all = await db('gallery_guests') + .whereIn('id', [Number(keepId), ...mergeIds.map(Number)]) + .where({ event_id: eventId }); + if (all.length !== mergeIds.length + 1) { + return res.status(400).json({ error: 'All guests must belong to the same event' }); + } + + const result = await feedbackService.mergeGuestFeedback(Number(keepId), mergeIds.map(Number)); + + // Soft-delete the merged (source) guests. + await db('gallery_guests') + .whereIn('id', mergeIds.map(Number)) + .update({ is_deleted: true, last_seen_at: db.fn.now() }); + + await logActivity( + 'guest_merged', + { event_id: eventId, keep_id: keepId, merged_ids: mergeIds }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ success: true, ...result }); + } catch (error) { + logger.error('Error merging guests:', error); + res.status(500).json({ error: 'Failed to merge guests' }); + } + } +); + +module.exports = router; diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index ebaa35c2..716b085a 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -1199,10 +1199,12 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => res.json({ feedback_enabled: settings.feedback_enabled || false, allow_ratings: settings.allow_ratings, - allow_likes: settings.allow_likes, + allow_likes: settings.allow_likes, allow_comments: settings.allow_comments, allow_favorites: settings.allow_favorites, - show_feedback_to_guests: settings.show_feedback_to_guests + show_feedback_to_guests: settings.show_feedback_to_guests, + require_name_email: settings.require_name_email || false, + identity_mode: settings.identity_mode || 'simple' }); } catch (error) { console.error('Error fetching feedback settings:', error); diff --git a/backend/src/routes/galleryFeedback.js b/backend/src/routes/galleryFeedback.js index a65ef327..b8028509 100644 --- a/backend/src/routes/galleryFeedback.js +++ b/backend/src/routes/galleryFeedback.js @@ -3,6 +3,7 @@ const router = express.Router(); const { photoAuth } = require('../middleware/photoAuth'); const { verifyGalleryAccess } = require('../middleware/gallery'); const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit'); +const { resolveGuest } = require('../middleware/guestAuth'); const feedbackService = require('../services/feedbackService'); const feedbackModeration = require('../services/feedbackModeration'); const { db, logActivity } = require('../database/db'); @@ -22,7 +23,7 @@ router.get('/:slug/feedback-settings', try { const event = req.event; const settings = await feedbackService.getEventFeedbackSettings(event.id); - + // Only send relevant settings to guests // Convert SQLite boolean values (0/1) to proper booleans const guestSettings = { @@ -32,9 +33,10 @@ router.get('/:slug/feedback-settings', allow_comments: Boolean(settings.allow_comments), allow_favorites: Boolean(settings.allow_favorites), require_name_email: Boolean(settings.require_name_email), - show_feedback_to_guests: Boolean(settings.show_feedback_to_guests) + show_feedback_to_guests: Boolean(settings.show_feedback_to_guests), + identity_mode: settings.identity_mode || 'simple' }; - + res.json(guestSettings); } catch (error) { logger.error('Error getting feedback settings:', error); @@ -46,6 +48,7 @@ router.get('/:slug/feedback-settings', // Get feedback for a specific photo router.get('/:slug/photos/:photoId/feedback', verifyGalleryAccess, + resolveGuest, validatePhotoId, checkValidation, async (req, res) => { @@ -137,6 +140,7 @@ router.get('/:slug/photos/:photoId/feedback', // Submit feedback for a photo router.post('/:slug/photos/:photoId/feedback', verifyGalleryAccess, + resolveGuest, validatePhotoId, validateFeedbackSubmission, checkValidation, @@ -144,15 +148,28 @@ router.post('/:slug/photos/:photoId/feedback', try { const { photoId } = req.params; const event = req.event; - const guestIdentifier = generateGuestIdentifier(req); - - // Get feedback settings + + // Get feedback settings first so we can enforce identity_mode. const settings = await feedbackService.getEventFeedbackSettings(event.id); - + if (!settings.feedback_enabled) { return res.status(403).json({ error: 'Feedback is not enabled for this event' }); } - + + // In guest identity mode, a valid guest token is required. The server + // never trusts guest_name/guest_email from the body in this mode — it + // reads them from the verified token via req.guest. + if (settings.identity_mode === 'guest') { + if (!req.guest || req.guest.eventId !== event.id) { + return res.status(401).json({ + error: 'Guest identity required', + code: 'GUEST_IDENTITY_REQUIRED' + }); + } + } + + const guestIdentifier = generateGuestIdentifier(req); + // Check if specific feedback type is allowed const feedbackType = req.body.feedback_type; const typeAllowed = { @@ -161,29 +178,32 @@ router.post('/:slug/photos/:photoId/feedback', comment: settings.allow_comments, favorite: settings.allow_favorites }; - + if (!typeAllowed[feedbackType]) { return res.status(403).json({ error: `${feedbackType} feedback is not enabled` }); } - + // Verify photo belongs to event const photo = await db('photos') .where({ id: photoId, event_id: event.id }) .first(); - + if (!photo) { return res.status(404).json({ error: 'Photo not found' }); } - - // Validate guest requirements - const guestValidation = await validateGuestRequirements(settings, req.body); - if (!guestValidation.valid) { - return res.status(400).json({ - error: 'Guest information required', - errors: guestValidation.errors - }); + + // Validate guest requirements only in simple mode. In guest mode, the + // identity is already provided via the token and verified above. + if (settings.identity_mode !== 'guest') { + const guestValidation = await validateGuestRequirements(settings, req.body); + if (!guestValidation.valid) { + return res.status(400).json({ + error: 'Guest information required', + errors: guestValidation.errors + }); + } } - + // Apply rate limiting based on feedback type const rateLimitMiddleware = feedbackRateLimit(feedbackType); await new Promise((resolve, reject) => { @@ -192,17 +212,19 @@ router.post('/:slug/photos/:photoId/feedback', else resolve(); }); }); - + // If we got here and response was sent (rate limited), return if (res.headersSent) return; - - // Prepare feedback data + + // Prepare feedback data. In guest mode, use the verified token as the + // source of truth for name/email — never the body. const feedbackData = { feedback_type: feedbackType, rating: req.body.rating, comment_text: req.body.comment_text, - guest_name: req.body.guest_name, - guest_email: req.body.guest_email, + guest_name: req.guest?.name ?? req.body.guest_name, + guest_email: req.guest?.email ?? req.body.guest_email, + guest_id: req.guest?.id ?? null, ip_address: req.ip || req.connection.remoteAddress, user_agent: (req.headers['user-agent'] || '').replace(/[<>&"']/g, '').substring(0, 255), moderate_comments: settings.moderate_comments @@ -316,22 +338,32 @@ router.get('/:slug/feedback-summary', // Get user's own feedback for all photos router.get('/:slug/my-feedback', verifyGalleryAccess, + resolveGuest, async (req, res) => { try { const event = req.event; - const guestIdentifier = generateGuestIdentifier(req); - - const myFeedback = await db('photo_feedback') + + const query = db('photo_feedback') .join('photos', 'photo_feedback.photo_id', 'photos.id') - .where('photo_feedback.event_id', event.id) - .where('photo_feedback.guest_identifier', guestIdentifier) + .where('photo_feedback.event_id', event.id); + + // Prefer guest_id lookup when a verified guest token is present + // (per-person identity). Fall back to the device hash otherwise. + if (req.guest?.id) { + query.where('photo_feedback.guest_id', req.guest.id); + } else { + const guestIdentifier = generateGuestIdentifier(req); + query.where('photo_feedback.guest_identifier', guestIdentifier); + } + + const myFeedback = await query .select( 'photo_feedback.*', 'photos.filename', 'photos.path' ) .orderBy('photo_feedback.created_at', 'desc'); - + res.json(myFeedback); } catch (error) { logger.error('Error getting user feedback:', error); diff --git a/backend/src/routes/galleryGuests.js b/backend/src/routes/galleryGuests.js new file mode 100644 index 00000000..2be4b5e8 --- /dev/null +++ b/backend/src/routes/galleryGuests.js @@ -0,0 +1,409 @@ +const express = require('express'); +const crypto = require('crypto'); +const router = express.Router(); +const { db } = require('../database/db'); +const logger = require('../utils/logger'); +const { verifyGalleryAccess } = require('../middleware/gallery'); +const { resolveGuest, requireGuest, signGuestToken } = require('../middleware/guestAuth'); +const feedbackService = require('../services/feedbackService'); +const guestRecovery = require('../services/guestRecoveryService'); + +const MAX_NAME_LEN = 100; +const MAX_EMAIL_LEN = 255; +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +// In-memory rate limit for guest registration (20 per hour per IP). Simple +// sliding window; on process restart the counters reset which is acceptable. +const registrationAttempts = new Map(); +const REGISTRATION_WINDOW_MS = 60 * 60 * 1000; +const REGISTRATION_MAX = 20; + +function checkRegistrationRate(ip) { + const now = Date.now(); + const entry = registrationAttempts.get(ip) || { count: 0, windowStart: now }; + if (now - entry.windowStart > REGISTRATION_WINDOW_MS) { + entry.count = 0; + entry.windowStart = now; + } + entry.count += 1; + registrationAttempts.set(ip, entry); + return entry.count <= REGISTRATION_MAX; +} + +function sanitizeName(value) { + if (typeof value !== 'string') return ''; + // Strip HTML/control chars, collapse whitespace. + const cleaned = value + .replace(/[<>&"']/g, '') + .replace(/[\u0000-\u001F\u007F]/g, '') + .replace(/\s+/g, ' ') + .trim(); + return cleaned.slice(0, MAX_NAME_LEN); +} + +function sanitizeEmail(value) { + if (typeof value !== 'string') return ''; + return value.trim().slice(0, MAX_EMAIL_LEN).toLowerCase(); +} + +/** + * POST /gallery/:slug/guest + * Body: { name, email? } + * + * Registers a new per-person guest identity for this gallery. Returns a JWT + * that the frontend must send as the x-guest-token header on subsequent + * feedback requests. + */ +router.post('/:slug/guest', verifyGalleryAccess, async (req, res) => { + try { + const ip = req.ip || req.connection.remoteAddress || 'unknown'; + if (!checkRegistrationRate(ip)) { + return res.status(429).json({ error: 'Too many registration attempts' }); + } + + const event = req.event; + const settings = await feedbackService.getEventFeedbackSettings(event.id); + + // Guest registration is only meaningful when feedback is enabled. + if (!settings.feedback_enabled) { + return res.status(403).json({ error: 'Feedback is not enabled for this gallery' }); + } + + const name = sanitizeName(req.body?.name); + if (!name || name.length < 1) { + return res.status(400).json({ error: 'Name is required', field: 'name' }); + } + + let email = sanitizeEmail(req.body?.email); + if (email && !EMAIL_REGEX.test(email)) { + return res.status(400).json({ error: 'Invalid email format', field: 'email' }); + } + if (settings.require_name_email && !email) { + return res.status(400).json({ error: 'Email is required', field: 'email' }); + } + + const identifier = crypto.randomUUID(); + const userAgent = (req.headers['user-agent'] || '').substring(0, 500); + + const [row] = await db('gallery_guests') + .insert({ + event_id: event.id, + name, + email: email || null, + identifier, + ip_address_last: ip.substring(0, 45), + user_agent_last: userAgent, + }) + .returning(['id', 'name', 'email', 'identifier', 'created_at']); + + const token = signGuestToken({ + guestId: row.id, + eventId: event.id, + identifier: row.identifier, + name: row.name, + }); + + logger.info('Guest registered', { + eventId: event.id, + guestId: row.id, + name: row.name, + }); + + return res.json({ + guest: { + id: row.id, + name: row.name, + email: row.email, + identifier: row.identifier, + }, + token, + }); + } catch (error) { + logger.error('Guest registration failed', { error: error.message }); + return res.status(500).json({ error: 'Failed to register guest' }); + } +}); + +/** + * GET /gallery/:slug/guest/me + * Returns the current guest profile from a valid guest token. 401 otherwise. + */ +router.get('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => { + try { + if (req.guest.eventId !== req.event.id) { + return res.status(403).json({ error: 'Guest token does not match gallery' }); + } + + // Update last_seen_at on each profile fetch (cheap and useful for admin). + await db('gallery_guests') + .where({ id: req.guest.id }) + .update({ + last_seen_at: db.fn.now(), + ip_address_last: (req.ip || '').substring(0, 45), + user_agent_last: (req.headers['user-agent'] || '').substring(0, 500), + }); + + return res.json({ + guest: { + id: req.guest.id, + name: req.guest.name, + email: req.guest.email, + identifier: req.guest.identifier, + }, + }); + } catch (error) { + logger.error('Guest profile fetch failed', { error: error.message }); + return res.status(500).json({ error: 'Failed to fetch guest profile' }); + } +}); + +/** + * DELETE /gallery/:slug/guest/me + * + * "Forget me" — soft-deletes the guest row and anonymizes their feedback so + * aggregate counts remain stable but personal data is removed. + */ +router.delete('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => { + try { + if (req.guest.eventId !== req.event.id) { + return res.status(403).json({ error: 'Guest token does not match gallery' }); + } + + await feedbackService.anonymizeGuestFeedback(req.guest.id); + + await db('gallery_guests') + .where({ id: req.guest.id }) + .update({ + is_deleted: true, + name: 'Removed', + email: null, + last_seen_at: db.fn.now(), + }); + + logger.info('Guest self-forgot', { + eventId: req.event.id, + guestId: req.guest.id, + }); + + return res.json({ success: true }); + } catch (error) { + logger.error('Guest forget-me failed', { error: error.message }); + return res.status(500).json({ error: 'Failed to forget guest' }); + } +}); + +// --------------------------------------------------------------------------- +// Phase 3.2 — Email-based identity recovery +// --------------------------------------------------------------------------- + +// Simple in-memory rate limit for recover/verify (5 per hour per IP). +const recoveryAttempts = new Map(); +const VERIFY_WINDOW_MS = 60 * 60 * 1000; +const VERIFY_MAX = 20; +function checkRecoveryRate(ip) { + const now = Date.now(); + const entry = recoveryAttempts.get(ip) || { count: 0, windowStart: now }; + if (now - entry.windowStart > VERIFY_WINDOW_MS) { + entry.count = 0; + entry.windowStart = now; + } + entry.count += 1; + recoveryAttempts.set(ip, entry); + return entry.count <= VERIFY_MAX; +} + +/** + * POST /gallery/:slug/guest/recover + * Body: { email } + * + * Sends a 6-digit code to the email if it matches an existing guest. Returns + * 200 regardless of whether a matching guest exists (prevents enumeration). + */ +router.post('/:slug/guest/recover', verifyGalleryAccess, async (req, res) => { + try { + const ip = req.ip || 'unknown'; + if (!checkRecoveryRate(ip)) { + return res.status(429).json({ error: 'Too many recovery attempts' }); + } + + const email = sanitizeEmail(req.body?.email); + if (!email || !EMAIL_REGEX.test(email)) { + // Still return 200 to avoid leaking validity of the email field. + return res.json({ success: true }); + } + + const event = req.event; + const settings = await feedbackService.getEventFeedbackSettings(event.id); + if (!settings.feedback_enabled || settings.identity_mode !== 'guest') { + return res.json({ success: true }); + } + + const guest = await db('gallery_guests') + .where({ event_id: event.id, email, is_deleted: false }) + .first(); + + if (guest) { + try { + const code = await guestRecovery.createCode(event.id, email); + await guestRecovery.sendRecoveryEmail(email, code, event.event_name || 'your gallery'); + } catch (sendError) { + logger.error('Failed to send recovery email', { error: sendError.message }); + // Still return 200 so clients can't distinguish failures. + } + } + + return res.json({ success: true }); + } catch (error) { + logger.error('Guest recovery request failed', { error: error.message }); + return res.json({ success: true }); + } +}); + +/** + * POST /gallery/:slug/guest/verify + * Body: { email, code } + * + * Exchanges a valid verification code for a guest token. Reuses the existing + * guest row associated with the email (the guest continues where they left + * off, cross-device). + */ +router.post('/:slug/guest/verify', verifyGalleryAccess, async (req, res) => { + try { + const ip = req.ip || 'unknown'; + if (!checkRecoveryRate(ip)) { + return res.status(429).json({ error: 'Too many verification attempts' }); + } + + const email = sanitizeEmail(req.body?.email); + const code = String(req.body?.code || '').trim(); + if (!email || !code) { + return res.status(400).json({ error: 'Email and code are required' }); + } + + const event = req.event; + const verifyResult = await guestRecovery.verifyCode(event.id, email, code); + if (!verifyResult.ok) { + return res.status(401).json({ error: 'Invalid or expired code', reason: verifyResult.reason }); + } + + const guest = await db('gallery_guests') + .where({ event_id: event.id, email, is_deleted: false }) + .first(); + if (!guest) { + return res.status(404).json({ error: 'Guest not found' }); + } + + await db('gallery_guests') + .where({ id: guest.id }) + .update({ + email_verified_at: guest.email_verified_at || db.fn.now(), + last_seen_at: db.fn.now(), + ip_address_last: (req.ip || '').substring(0, 45), + }); + + const token = signGuestToken({ + guestId: guest.id, + eventId: event.id, + identifier: guest.identifier, + name: guest.name, + }); + + logger.info('Guest recovered via email', { eventId: event.id, guestId: guest.id }); + + return res.json({ + guest: { + id: guest.id, + name: guest.name, + email: guest.email, + identifier: guest.identifier, + }, + token, + }); + } catch (error) { + logger.error('Guest verify failed', { error: error.message }); + return res.status(500).json({ error: 'Failed to verify code' }); + } +}); + +// --------------------------------------------------------------------------- +// Phase 3.3 — Invite token redemption +// --------------------------------------------------------------------------- + +/** + * POST /gallery/:slug/guest/redeem + * Body: { inviteToken } + * + * Redeems a pre-minted invite token (created by admin). Single use. + */ +router.post('/:slug/guest/redeem', verifyGalleryAccess, async (req, res) => { + try { + const inviteToken = String(req.body?.inviteToken || '').trim(); + if (!inviteToken) { + return res.status(400).json({ error: 'Invite token required' }); + } + + const event = req.event; + + const result = await db.transaction(async (trx) => { + const invite = await trx('guest_invites') + .where({ token: inviteToken, event_id: event.id }) + .first(); + if (!invite) return { error: 'not_found' }; + if (invite.revoked_at) return { error: 'revoked' }; + if (invite.redeemed_at) return { error: 'already_redeemed' }; + + const guest = await trx('gallery_guests') + .where({ id: invite.guest_id, is_deleted: false }) + .first(); + if (!guest) return { error: 'guest_missing' }; + + await trx('guest_invites') + .where({ id: invite.id }) + .update({ redeemed_at: trx.fn.now() }); + + await trx('gallery_guests') + .where({ id: guest.id }) + .update({ + last_seen_at: trx.fn.now(), + ip_address_last: (req.ip || '').substring(0, 45), + user_agent_last: (req.headers['user-agent'] || '').substring(0, 500), + }); + + return { guest }; + }); + + if (result.error) { + const statusMap = { + not_found: 404, + revoked: 410, + already_redeemed: 409, + guest_missing: 404, + }; + return res.status(statusMap[result.error] || 400).json({ error: result.error }); + } + + const token = signGuestToken({ + guestId: result.guest.id, + eventId: event.id, + identifier: result.guest.identifier, + name: result.guest.name, + }); + + logger.info('Invite redeemed', { eventId: event.id, guestId: result.guest.id }); + + return res.json({ + guest: { + id: result.guest.id, + name: result.guest.name, + email: result.guest.email, + identifier: result.guest.identifier, + }, + token, + }); + } catch (error) { + logger.error('Invite redemption failed', { error: error.message }); + return res.status(500).json({ error: 'Failed to redeem invite' }); + } +}); + +module.exports = router; diff --git a/backend/src/services/feedbackService.js b/backend/src/services/feedbackService.js index e254b430..0e8c3a05 100644 --- a/backend/src/services/feedbackService.js +++ b/backend/src/services/feedbackService.js @@ -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(); \ No newline at end of file diff --git a/backend/src/services/guestRecoveryService.js b/backend/src/services/guestRecoveryService.js new file mode 100644 index 00000000..971783ce --- /dev/null +++ b/backend/src/services/guestRecoveryService.js @@ -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 = ` +
Enter this code to recover your picks in the gallery:
++ This code expires in 15 minutes. If you did not request it, you can safely ignore this email. +
+{guest.email}
+ )} +{c.comment}
+| } + | + {t('admin.guests.columns.name', 'Name')} + | ++ {t('admin.guests.columns.email', 'Email')} + | ++ {t('admin.guests.columns.likes', 'Likes')} + | ++ {t('admin.guests.columns.favorites', 'Favorites')} + | ++ {t('admin.guests.columns.comments', 'Comments')} + | ++ {t('admin.guests.columns.ratings', 'Ratings')} + | ++ {t('admin.guests.columns.lastSeen', 'Last seen')} + | ++ |
|---|---|---|---|---|---|---|---|---|
| + toggleMergeSelection(guest.id)} + className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500" + /> + | + )} ++ {guest.name} + {guest.email_verified_at && ( + ✓ + )} + | ++ {guest.email || '—'} + | ++ {guest.stats.likes} + | ++ {guest.stats.favorites} + | ++ {guest.stats.comments} + | ++ {guest.stats.ratings} + | ++ {new Date(guest.last_seen_at).toLocaleDateString()} + | +
+
+
+
+
+
+
+
+
+ {(['csv', 'txt', 'json'] as const).map((fmt) => (
+
+ ))}
+
+ |
+
+ {t( + 'admin.guests.aggregate.description', + 'Photos sorted by how many distinct guests liked or favorited them.' + )} +
++ {t( + 'gallery.guestPrompt.description', + 'Your picks will be saved under this name so the photographer knows which photos you love.' + )} +
+ + ++ {step === 'email' + ? t( + 'gallery.guestRecovery.emailStepDescription', + 'Enter the email you used before. We will send a 6-digit verification code.' + ) + : t( + 'gallery.guestRecovery.codeStepDescription', + 'Enter the 6-digit code we sent to your email.' + )} +
+ + {info && step === 'code' && ( +