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
@@ -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');
};
+2
View File
@@ -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'));
+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,
};
+609
View File
@@ -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;
+4 -2
View File
@@ -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);
+63 -31
View File
@@ -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);
+409
View File
@@ -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;
+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();
@@ -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,
};
+3 -1
View File
@@ -184,7 +184,9 @@ const validateFeedbackSettings = [
body('allow_favorites').optional().isBoolean(),
body('require_name_email').optional().isBoolean(),
body('moderate_comments').optional().isBoolean(),
body('show_feedback_to_guests').optional().isBoolean()
body('show_feedback_to_guests').optional().isBoolean(),
body('identity_mode').optional().isIn(['simple', 'guest'])
.withMessage('identity_mode must be "simple" or "guest"')
];
/**
+29
View File
@@ -1,6 +1,7 @@
const ADMIN_COOKIE_NAME = 'admin_token';
const GALLERY_COOKIE_NAME = 'gallery_token';
const GALLERY_COOKIE_PREFIX = 'gallery_token_';
const GUEST_COOKIE_PREFIX = 'guest_token_';
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -114,10 +115,37 @@ function getGalleryTokenFromRequest(req, slug) {
return null;
}
function getGuestTokenFromRequest(req, slug) {
// Primary transport: custom header (set by frontend axios interceptor).
const headerToken = req.headers?.['x-guest-token'];
if (headerToken) {
return headerToken;
}
if (!req.cookies) {
return null;
}
if (slug) {
const cookieName = `${GUEST_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
if (req.cookies[cookieName]) {
return req.cookies[cookieName];
}
}
const prefixed = Object.keys(req.cookies).find((name) => name.startsWith(GUEST_COOKIE_PREFIX));
if (prefixed) {
return req.cookies[prefixed];
}
return null;
}
module.exports = {
ADMIN_COOKIE_NAME,
GALLERY_COOKIE_NAME,
GALLERY_COOKIE_PREFIX,
GUEST_COOKIE_PREFIX,
sanitizeSlugForCookie,
setAdminAuthCookie,
clearAdminAuthCookie,
@@ -125,4 +153,5 @@ module.exports = {
clearGalleryAuthCookies,
getAdminTokenFromRequest,
getGalleryTokenFromRequest,
getGuestTokenFromRequest,
};