Merge pull request #295 from the-luap/feat/guest-selections

feat: guest selections with per-person identity (#292)
This commit is contained in:
Paul Nothaft
2026-04-11 08:27:31 +02:00
committed by GitHub
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,
};
@@ -0,0 +1,205 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Heart, Bookmark, Star, MessageCircle } from 'lucide-react';
import { Loading } from '../common';
import { guestsService, AdminGuest } from '../../services/guests.service';
import { AuthenticatedImage } from '../common/AuthenticatedImage';
import { buildResourceUrl } from '../../utils/url';
interface AdminGuestDetailProps {
eventId: number;
guest: AdminGuest;
onClose: () => void;
}
type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented';
export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, guest, onClose }) => {
const { t } = useTranslation();
const [tab, setTab] = useState<Tab>('all');
const { data, isLoading } = useQuery({
queryKey: ['admin-guest-detail', eventId, guest.id],
queryFn: () => guestsService.getGuestDetail(eventId, guest.id),
});
const selections = data?.selections;
const liked = selections?.liked || [];
const favorited = selections?.favorited || [];
const rated = selections?.rated || [];
const commented = selections?.commented || [];
// "all" view combines the three visual selection types.
type GridItem = { photo: { id: number; filename: string; thumbnail_url: string }; badges: string[] };
const allItems: GridItem[] = [];
const seen = new Map<number, GridItem>();
const add = (photo: { id: number; filename: string; thumbnail_url: string }, badge: string) => {
if (!seen.has(photo.id)) {
const item: GridItem = { photo, badges: [badge] };
seen.set(photo.id, item);
allItems.push(item);
} else {
seen.get(photo.id)!.badges.push(badge);
}
};
liked.forEach((p) => add(p, 'like'));
favorited.forEach((p) => add(p, 'favorite'));
rated.forEach((r) => add(r.photo, 'rating'));
const visibleItems: GridItem[] =
tab === 'all'
? allItems
: tab === 'liked'
? liked.map((p) => ({ photo: p, badges: ['like'] }))
: tab === 'favorited'
? favorited.map((p) => ({ photo: p, badges: ['favorite'] }))
: tab === 'rated'
? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}`] }))
: [];
return (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto p-4 pt-16">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{guest.name}</h2>
{guest.email && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">{guest.email}</p>
)}
</div>
<button
type="button"
onClick={onClose}
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
>
<X className="w-5 h-5" />
</button>
</div>
{isLoading ? (
<div className="p-8">
<Loading size="lg" text={t('admin.guests.loadingDetail', 'Loading selections...')} />
</div>
) : (
<div className="overflow-y-auto p-4">
{/* Stats */}
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{liked.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<Heart className="w-3 h-3" />
{t('admin.guests.columns.likes', 'Likes')}
</div>
</div>
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{favorited.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<Bookmark className="w-3 h-3" />
{t('admin.guests.columns.favorites', 'Favorites')}
</div>
</div>
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{rated.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<Star className="w-3 h-3" />
{t('admin.guests.columns.ratings', 'Ratings')}
</div>
</div>
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{commented.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<MessageCircle className="w-3 h-3" />
{t('admin.guests.columns.comments', 'Comments')}
</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-700 mb-4">
{(['all', 'liked', 'favorited', 'rated', 'commented'] as const).map((k) => (
<button
key={k}
type="button"
onClick={() => setTab(k)}
className={`px-3 py-2 text-sm font-medium border-b-2 transition ${
tab === k
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100'
}`}
>
{t(`admin.guests.detail.${k}`, k)}
</button>
))}
</div>
{/* Content */}
{tab === 'commented' ? (
commented.length === 0 ? (
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
{t('admin.guests.detail.noComments', 'No comments')}
</div>
) : (
<div className="space-y-3">
{commented.map((c, idx) => (
<div key={idx} className="flex gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded">
<AuthenticatedImage
src={buildResourceUrl(c.photo.thumbnail_url)}
alt={c.photo.filename}
className="w-16 h-16 object-cover rounded flex-shrink-0"
/>
<div className="flex-1">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{c.photo.filename} · {new Date(c.created_at).toLocaleString()}
</div>
<p className="text-sm text-neutral-900 dark:text-neutral-100 mt-1">{c.comment}</p>
</div>
</div>
))}
</div>
)
) : visibleItems.length === 0 ? (
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
{t('admin.guests.detail.empty', 'No selections in this category')}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
{visibleItems.map((item) => (
<div key={item.photo.id} className="relative group">
<AuthenticatedImage
src={buildResourceUrl(item.photo.thumbnail_url)}
alt={item.photo.filename}
className="w-full aspect-square object-cover rounded"
/>
<div className="absolute top-1 right-1 flex gap-1">
{item.badges.map((b, i) => (
<span
key={i}
className="bg-black/60 text-white text-xs px-1.5 py-0.5 rounded"
>
{b === 'like' ? '♥' : b === 'favorite' ? '★' : b}
</span>
))}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity text-white text-xs p-2 rounded-b">
{item.photo.filename}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,344 @@
import React, { useState } from 'react';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Trash2, Eye, Download, UserPlus, Grid3x3, List } from 'lucide-react';
import { Card, Button, Loading } from '../common';
import { guestsService, AdminGuest } from '../../services/guests.service';
import { AdminGuestDetail } from './AdminGuestDetail';
import { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
import { GuestInviteDialog } from './GuestInviteDialog';
import { toast } from 'react-toastify';
interface AdminGuestsListProps {
eventId: number;
eventName?: string;
}
type View = 'list' | 'aggregate';
export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, eventName }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [view, setView] = useState<View>('list');
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
const [mergeMode, setMergeMode] = useState(false);
const [mergeSelection, setMergeSelection] = useState<number[]>([]);
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const { data, isLoading, refetch } = useQuery({
queryKey: ['admin-guests', eventId],
queryFn: () => guestsService.getEventGuests(eventId),
});
const deleteMutation = useMutation({
mutationFn: (guestId: number) => guestsService.deleteGuest(eventId, guestId),
onSuccess: () => {
toast.success(t('admin.guests.deletedToast', 'Guest removed'));
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
},
onError: () => toast.error(t('admin.guests.deletedError', 'Failed to remove guest')),
});
const mergeMutation = useMutation({
mutationFn: ({ keepId, mergeIds }: { keepId: number; mergeIds: number[] }) =>
guestsService.mergeGuests(eventId, keepId, mergeIds),
onSuccess: () => {
toast.success(t('admin.guests.mergedToast', 'Guests merged'));
setMergeMode(false);
setMergeSelection([]);
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
},
onError: () => toast.error(t('admin.guests.mergedError', 'Failed to merge guests')),
});
const handleDelete = (guest: AdminGuest) => {
if (window.confirm(t('admin.guests.forgetGuestConfirm', 'Remove this guest? Their picks will be anonymized but kept in aggregate totals.'))) {
deleteMutation.mutate(guest.id);
}
};
const handleExport = async (guest: AdminGuest, format: 'txt' | 'csv' | 'json') => {
try {
const blob = await guestsService.exportGuest(eventId, guest.id, format);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${guest.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.${format}`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch {
toast.error(t('admin.guests.exportError', 'Export failed'));
}
};
const handleExportAll = async (format: 'txt' | 'csv' | 'json') => {
try {
const blob = await guestsService.exportAllGuests(eventId, format);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `event-${eventId}-guests.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch {
toast.error(t('admin.guests.exportError', 'Export failed'));
}
};
const toggleMergeSelection = (id: number) => {
setMergeSelection((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
const performMerge = () => {
if (mergeSelection.length < 2) {
toast.warning(t('admin.guests.mergeSelectAtLeastTwo', 'Select at least 2 guests to merge'));
return;
}
const [keepId, ...mergeIds] = mergeSelection;
const keepName = data?.guests.find((g) => g.id === keepId)?.name;
const confirmMsg = t(
'admin.guests.mergeConfirm',
'Merge {{count}} guests into {{name}}? This cannot be undone.',
{ count: mergeSelection.length, name: keepName || '#' + keepId }
);
if (window.confirm(confirmMsg)) {
mergeMutation.mutate({ keepId, mergeIds });
}
};
if (isLoading) {
return <Loading size="lg" text={t('admin.guests.loading', 'Loading guests...')} />;
}
const guests = data?.guests || [];
if (view === 'aggregate') {
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setView('list')} leftIcon={<List className="w-4 h-4" />}>
{t('admin.guests.backToList', 'Back to list')}
</Button>
</div>
</div>
<GuestSelectionsAggregate eventId={eventId} />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('admin.guests.title', 'Guests')} ({guests.length})
</h3>
<div className="flex items-center gap-2">
{mergeMode ? (
<>
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{t('admin.guests.mergeSelected', '{{count}} selected', { count: mergeSelection.length })}
</span>
<Button variant="primary" size="sm" onClick={performMerge} disabled={mergeSelection.length < 2}>
{t('admin.guests.mergeNow', 'Merge selected')}
</Button>
<Button variant="ghost" size="sm" onClick={() => { setMergeMode(false); setMergeSelection([]); }}>
{t('common.cancel', 'Cancel')}
</Button>
</>
) : (
<>
<Button
variant="outline"
size="sm"
leftIcon={<UserPlus className="w-4 h-4" />}
onClick={() => setInviteDialogOpen(true)}
>
{t('admin.guests.createInvite', 'Create invite')}
</Button>
<Button
variant="outline"
size="sm"
leftIcon={<Grid3x3 className="w-4 h-4" />}
onClick={() => setView('aggregate')}
>
{t('admin.guests.aggregateView', 'By popularity')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setMergeMode(true)}
disabled={guests.length < 2}
>
{t('admin.guests.mergeMode', 'Merge')}
</Button>
<div className="relative group">
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />}>
{t('admin.guests.exportAll', 'Export all')}
</Button>
<div className="absolute right-0 top-full mt-1 hidden group-hover:block bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded shadow-lg z-10 min-w-[120px]">
{(['csv', 'txt', 'json'] as const).map((fmt) => (
<button
key={fmt}
onClick={() => handleExportAll(fmt)}
className="block w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
>
{fmt.toUpperCase()}
</button>
))}
</div>
</div>
</>
)}
</div>
</div>
{guests.length === 0 ? (
<Card>
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
{t('admin.guests.empty', 'No guests have registered yet.')}
</div>
</Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<tr>
{mergeMode && <th className="px-4 py-3 w-8" />}
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.name', 'Name')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.email', 'Email')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.likes', 'Likes')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.favorites', 'Favorites')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.comments', 'Comments')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.ratings', 'Ratings')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.lastSeen', 'Last seen')}
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-700">
{guests.map((guest) => (
<tr key={guest.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-800">
{mergeMode && (
<td className="px-4 py-3">
<input
type="checkbox"
checked={mergeSelection.includes(guest.id)}
onChange={() => toggleMergeSelection(guest.id)}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
</td>
)}
<td className="px-4 py-3 font-medium text-neutral-900 dark:text-neutral-100">
{guest.name}
{guest.email_verified_at && (
<span className="ml-2 text-xs text-green-600"></span>
)}
</td>
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
{guest.email || '—'}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.likes}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.favorites}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.comments}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.ratings}
</td>
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
{new Date(guest.last_seen_at).toLocaleDateString()}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => setSelectedGuest(guest)}
className="p-1 text-neutral-500 hover:text-primary-600"
title={t('admin.guests.view', 'View details')}
>
<Eye className="w-4 h-4" />
</button>
<div className="relative group">
<button
type="button"
className="p-1 text-neutral-500 hover:text-primary-600"
title={t('admin.guests.export', 'Export')}
>
<Download className="w-4 h-4" />
</button>
<div className="absolute right-0 top-full mt-1 hidden group-hover:block bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded shadow-lg z-10 min-w-[100px]">
{(['csv', 'txt', 'json'] as const).map((fmt) => (
<button
key={fmt}
onClick={() => handleExport(guest, fmt)}
className="block w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
>
{fmt.toUpperCase()}
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => handleDelete(guest)}
className="p-1 text-neutral-500 hover:text-red-600"
title={t('admin.guests.forgetGuest', 'Remove guest')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{selectedGuest && (
<AdminGuestDetail
eventId={eventId}
guest={selectedGuest}
onClose={() => setSelectedGuest(null)}
/>
)}
{inviteDialogOpen && (
<GuestInviteDialog
eventId={eventId}
eventName={eventName}
onClose={() => {
setInviteDialogOpen(false);
refetch();
}}
/>
)}
</div>
);
};
@@ -1,5 +1,5 @@
import React from 'react';
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye } from 'lucide-react';
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye, User, Users } from 'lucide-react';
import { Card } from '../common';
import { useTranslation } from 'react-i18next';
@@ -21,6 +21,7 @@ interface FeedbackSettings {
enable_rate_limiting: boolean;
rate_limit_window_minutes?: number;
rate_limit_max_requests?: number;
identity_mode?: 'simple' | 'guest';
}
export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
@@ -70,6 +71,74 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
{settings.feedback_enabled && (
<>
{/* Identity Mode */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('feedback.settings.identityMode', 'Identity Mode')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<label
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
(settings.identity_mode || 'simple') === 'simple'
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
<input
type="radio"
name="identity_mode"
value="simple"
checked={(settings.identity_mode || 'simple') === 'simple'}
onChange={() => onChange({ ...settings, identity_mode: 'simple' })}
className="mt-0.5 w-4 h-4 text-primary-600 focus:ring-primary-500"
/>
<User className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.identityModeSimple', 'Simple feedback')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t(
'feedback.settings.identityModeSimpleDesc',
'Anonymous, device-based. All visitors on the same device share state.'
)}
</div>
</div>
</label>
<label
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
settings.identity_mode === 'guest'
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
<input
type="radio"
name="identity_mode"
value="guest"
checked={settings.identity_mode === 'guest'}
onChange={() => onChange({ ...settings, identity_mode: 'guest' })}
className="mt-0.5 w-4 h-4 text-primary-600 focus:ring-primary-500"
/>
<Users className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.identityModeGuest', 'Per-guest selections')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t(
'feedback.settings.identityModeGuestDesc',
'Each visitor enters their name. Enables per-guest tracking and admin insights.'
)}
</div>
</div>
</label>
</div>
</div>
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
{/* Feedback Types */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
@@ -0,0 +1,194 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Copy, Check, Trash2 } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, Input, Loading } from '../common';
import { guestsService, GuestInvite } from '../../services/guests.service';
import { toast } from 'react-toastify';
interface GuestInviteDialogProps {
eventId: number;
eventName?: string;
onClose: () => void;
}
/**
* Admin dialog to create pre-minted invite tokens and list existing ones.
* Each invite generates a unique URL that the admin can send to a specific
* guest. Opening the URL auto-registers that guest (single use).
*/
export const GuestInviteDialog: React.FC<GuestInviteDialogProps> = ({ eventId, onClose }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [copiedId, setCopiedId] = useState<number | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['admin-guest-invites', eventId],
queryFn: () => guestsService.listInvites(eventId),
});
const createMutation = useMutation({
mutationFn: () => guestsService.createInvite(eventId, { name, email: email || undefined }),
onSuccess: () => {
setName('');
setEmail('');
toast.success(t('admin.guests.inviteCreated', 'Invite created'));
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] });
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
},
onError: () => toast.error(t('admin.guests.inviteCreateError', 'Failed to create invite')),
});
const revokeMutation = useMutation({
mutationFn: (inviteId: number) => guestsService.revokeInvite(eventId, inviteId),
onSuccess: () => {
toast.success(t('admin.guests.inviteRevoked', 'Invite revoked'));
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] });
},
onError: () => toast.error(t('admin.guests.inviteRevokeError', 'Failed to revoke invite')),
});
const copy = (invite: GuestInvite) => {
navigator.clipboard.writeText(invite.url).then(() => {
setCopiedId(invite.id);
setTimeout(() => setCopiedId(null), 1500);
});
};
const invites = data?.invites || [];
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-16">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('admin.guests.invitesTitle', 'Guest invites')}
</h2>
<button
type="button"
onClick={onClose}
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="overflow-y-auto p-4 space-y-4">
{/* Create form */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded">
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-3">
{t('admin.guests.createInvite', 'Create invite')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<Input
label={t('admin.guests.inviteName', 'Guest name')}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Alice"
required
/>
<Input
type="email"
label={t('admin.guests.inviteEmail', 'Email (optional)')}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="alice@example.com"
/>
</div>
<Button
variant="primary"
size="sm"
onClick={() => createMutation.mutate()}
disabled={!name.trim() || createMutation.isPending}
>
{createMutation.isPending
? t('common.submitting', 'Submitting...')
: t('admin.guests.generateInvite', 'Generate invite link')}
</Button>
</div>
{/* Existing invites */}
<div>
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-2">
{t('admin.guests.existingInvites', 'Existing invites')}
</h3>
{isLoading ? (
<Loading size="sm" />
) : invites.length === 0 ? (
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-4">
{t('admin.guests.noInvites', 'No invites yet')}
</div>
) : (
<div className="space-y-2">
{invites.map((invite) => (
<div
key={invite.id}
className="p-3 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-neutral-900 dark:text-neutral-100">
{invite.guest.name}
{invite.guest.email && (
<span className="text-neutral-500 dark:text-neutral-400 font-normal ml-2">
· {invite.guest.email}
</span>
)}
</div>
<div className="text-xs mt-1">
<span
className={`inline-block px-2 py-0.5 rounded-full font-medium ${
invite.status === 'redeemed'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: invite.status === 'revoked'
? 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
}`}
>
{t(`admin.guests.inviteStatus.${invite.status}`, invite.status)}
</span>
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 truncate mt-1 font-mono">
{invite.url}
</div>
</div>
<div className="flex gap-1">
{invite.status === 'pending' && (
<>
<button
type="button"
onClick={() => copy(invite)}
className="p-1.5 text-neutral-500 hover:text-primary-600"
title={t('admin.guests.copyLink', 'Copy link')}
>
{copiedId === invite.id ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
<button
type="button"
onClick={() => revokeMutation.mutate(invite.id)}
className="p-1.5 text-neutral-500 hover:text-red-600"
title={t('admin.guests.revokeInvite', 'Revoke')}
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,69 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Users } from 'lucide-react';
import { Card, Loading } from '../common';
import { guestsService } from '../../services/guests.service';
import { AuthenticatedImage } from '../common/AuthenticatedImage';
import { buildResourceUrl } from '../../utils/url';
interface GuestSelectionsAggregateProps {
eventId: number;
}
/**
* Shows photos sorted by the number of distinct guests who liked or
* favorited them. Photos with zero picks are filtered server-side.
*/
export const GuestSelectionsAggregate: React.FC<GuestSelectionsAggregateProps> = ({ eventId }) => {
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ['admin-guests-aggregate', eventId],
queryFn: () => guestsService.getAggregatePicks(eventId),
});
if (isLoading) {
return <Loading size="lg" text={t('admin.guests.loading', 'Loading...')} />;
}
const photos = data?.photos || [];
if (photos.length === 0) {
return (
<Card>
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
{t('admin.guests.aggregate.empty', 'No guest picks yet.')}
</div>
</Card>
);
}
return (
<div className="space-y-3">
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t(
'admin.guests.aggregate.description',
'Photos sorted by how many distinct guests liked or favorited them.'
)}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{photos.map((p) => (
<div key={p.id} className="relative group">
<AuthenticatedImage
src={buildResourceUrl(p.thumbnail_url)}
alt={p.filename}
className="w-full aspect-square object-cover rounded"
/>
<div className="absolute top-2 right-2 bg-primary-600 text-white text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1 shadow">
<Users className="w-3 h-3" />
{p.picker_count}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity text-white text-xs p-2 rounded-b">
{p.original_filename || p.filename}
</div>
</div>
))}
</div>
</div>
);
};
+4
View File
@@ -36,3 +36,7 @@ export { EventRenameDialog } from './EventRenameDialog';
export { PhotoFilterPanel } from './PhotoFilterPanel';
export { PhotoExportMenu } from './PhotoExportMenu';
export { CssTemplateEditor } from './CssTemplateEditor';
export { AdminGuestsList } from './AdminGuestsList';
export { AdminGuestDetail } from './AdminGuestDetail';
export { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
export { GuestInviteDialog } from './GuestInviteDialog';
@@ -7,6 +7,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon';
import { useTheme } from '../../contexts/ThemeContext';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
import { buildResourceUrl } from '../../utils/url';
import type { HeaderStyleType } from '../../types/theme.types';
@@ -59,6 +60,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { theme } = useTheme();
const guestIdentity = useGuestIdentityOptional();
// Determine header style - use prop first (from event data), then theme, then fall back to 'standard'
const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard';
@@ -589,20 +591,36 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</p>
)}
{/* Legal Links */}
<div className="mt-4 flex items-center justify-center gap-4">
<Link
to="/impressum"
<div className="mt-4 flex items-center justify-center gap-4 flex-wrap">
<Link
to="/impressum"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-muted-theme">|</span>
<Link
to="/datenschutz"
<Link
to="/datenschutz"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.datenschutz')}
</Link>
{guestIdentity?.identity && (
<>
<span className="text-xs text-muted-theme">|</span>
<button
type="button"
className="text-xs text-muted-theme hover:text-theme transition-colors"
onClick={async () => {
if (window.confirm(t('gallery.footer.forgetMeConfirm', 'Your name and selections will be removed from this gallery.'))) {
await guestIdentity.forget();
}
}}
>
{t('gallery.footer.forgetMe', 'Forget me ({{name}})', { name: guestIdentity.identity.name })}
</button>
</>
)}
</div>
</div>
</footer>
@@ -13,6 +13,9 @@ import { GalleryLayout } from './GalleryLayout';
import { GallerySidebar } from './GallerySidebar';
import { PhotoFilterBar } from './PhotoFilterBar';
import { UserPhotoUpload } from './UserPhotoUpload';
import { GuestNamePromptModal } from './GuestNamePromptModal';
import { GuestRecoveryModal } from './GuestRecoveryModal';
import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext';
import type { FilterType } from './GalleryFilter';
import { analyticsService } from '../../services/analytics.service';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
@@ -701,8 +704,14 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
);
}
const identityMode: 'simple' | 'guest' =
feedbackSettings?.identity_mode === 'guest' ? 'guest' : 'simple';
return (
<GuestIdentityProvider slug={slug} identityMode={identityMode}>
<>
<GuestNamePromptModal requireEmail={!!feedbackSettings?.require_name_email} />
<GuestRecoveryModal />
{/* Sidebar for non-grid layouts */}
{showSidebar ? (
<GallerySidebar
@@ -915,5 +924,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
)}
</GalleryLayout>
</>
</GuestIdentityProvider>
);
};
@@ -0,0 +1,156 @@
import React, { useState } from 'react';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../common';
import { useGuestIdentity } from '../../contexts/GuestIdentityContext';
interface GuestNamePromptModalProps {
requireEmail?: boolean;
allowCancel?: boolean;
onCancel?: () => void;
}
/**
* Session-wide prompt shown in guest identity mode when no identity exists
* yet. Triggered by `ensureIdentity()` on the first interactive feedback
* attempt, or manually via `openPrompt()`.
*
* Includes a link to the recovery flow for users who already registered on
* another device.
*/
export const GuestNamePromptModal: React.FC<GuestNamePromptModalProps> = ({
requireEmail = false,
allowCancel = true,
onCancel,
}) => {
const { t } = useTranslation();
const { promptOpen, closePrompt, register, openRecovery } = useGuestIdentity();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
if (!promptOpen) return null;
const handleClose = () => {
setName('');
setEmail('');
setErrors({});
setSubmitError(null);
closePrompt();
onCancel?.();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const newErrors: Record<string, string> = {};
if (!name.trim()) {
newErrors.name = t('gallery.guestPrompt.nameRequired', 'Name is required');
}
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = t('gallery.guestPrompt.invalidEmail', 'Invalid email address');
}
if (requireEmail && !email.trim()) {
newErrors.email = t('gallery.guestPrompt.emailRequired', 'Email is required');
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setSubmitting(true);
setSubmitError(null);
try {
await register(name.trim(), email.trim() || undefined);
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setSubmitError(error.response?.data?.error || t('gallery.guestPrompt.error', 'Registration failed'));
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={allowCancel ? handleClose : undefined} />
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
{allowCancel && (
<button
type="button"
onClick={handleClose}
className="absolute top-4 right-4 p-1 hover:bg-black/10 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-muted-theme" />
</button>
)}
<h2 className="text-lg font-semibold text-theme mb-2">
{t('gallery.guestPrompt.title', "Welcome — what's your name?")}
</h2>
<p className="text-sm text-muted-theme mb-4">
{t(
'gallery.guestPrompt.description',
'Your picks will be saved under this name so the photographer knows which photos you love.'
)}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label={t('gallery.guestPrompt.nameLabel', 'Your name')}
value={name}
onChange={(e) => setName(e.target.value)}
error={errors.name}
placeholder={t('gallery.guestPrompt.namePlaceholder', 'Enter your name')}
autoFocus
required
maxLength={100}
/>
<Input
type="email"
label={
requireEmail
? t('gallery.guestPrompt.emailLabelRequired', 'Email')
: t('gallery.guestPrompt.emailLabel', 'Email (optional)')
}
value={email}
onChange={(e) => setEmail(e.target.value)}
error={errors.email}
placeholder={t('gallery.guestPrompt.emailPlaceholder', 'you@example.com')}
maxLength={255}
/>
{submitError && (
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded px-3 py-2">
{submitError}
</div>
)}
<div className="flex gap-2 pt-2">
<Button type="submit" variant="primary" className="flex-1" disabled={submitting}>
{submitting
? t('common.submitting', 'Submitting...')
: t('gallery.guestPrompt.submit', 'Continue')}
</Button>
{allowCancel && (
<Button type="button" variant="ghost" onClick={handleClose} disabled={submitting}>
{t('common.cancel', 'Cancel')}
</Button>
)}
</div>
<button
type="button"
onClick={() => {
closePrompt();
openRecovery();
}}
className="text-sm text-primary-600 hover:underline w-full text-center pt-2"
>
{t('gallery.guestPrompt.alreadyHere', "I've been here before")}
</button>
</form>
</div>
</div>
);
};
@@ -0,0 +1,173 @@
import React, { useState } from 'react';
import { X, ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../common';
import { useGuestIdentity } from '../../contexts/GuestIdentityContext';
/**
* Email-based identity recovery flow (Phase 3.2).
*
* Two steps:
* 1) Enter email server sends a 6-digit code.
* 2) Enter code server returns a guest token, identity restored.
*
* Opens when the user clicks "I've been here before" in the name prompt.
*/
export const GuestRecoveryModal: React.FC = () => {
const { t } = useTranslation();
const { recoveryOpen, closeRecovery, recoverRequest, recoverVerify, openPrompt } =
useGuestIdentity();
const [step, setStep] = useState<'email' | 'code'>('email');
const [email, setEmail] = useState('');
const [code, setCode] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
if (!recoveryOpen) return null;
const reset = () => {
setStep('email');
setEmail('');
setCode('');
setSubmitting(false);
setError(null);
setInfo(null);
};
const handleClose = () => {
reset();
closeRecovery();
};
const backToPrompt = () => {
reset();
closeRecovery();
openPrompt();
};
const handleRequestCode = async (e: React.FormEvent) => {
e.preventDefault();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError(t('gallery.guestRecovery.invalidEmail', 'Enter a valid email address'));
return;
}
setSubmitting(true);
setError(null);
try {
await recoverRequest(email.trim().toLowerCase());
setInfo(t('gallery.guestRecovery.codeSent', 'Check your inbox for a verification code.'));
setStep('code');
} catch {
setError(t('gallery.guestRecovery.requestError', 'Could not send code. Try again.'));
} finally {
setSubmitting(false);
}
};
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault();
if (!/^\d{6}$/.test(code.trim())) {
setError(t('gallery.guestRecovery.invalidCode', 'Enter the 6-digit code'));
return;
}
setSubmitting(true);
setError(null);
try {
await recoverVerify(email.trim().toLowerCase(), code.trim());
// Success: context clears recoveryOpen on success, component will
// unmount naturally.
} catch {
setError(t('gallery.guestRecovery.verifyError', 'Invalid or expired code.'));
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={handleClose} />
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
<button
type="button"
onClick={handleClose}
className="absolute top-4 right-4 p-1 hover:bg-black/10 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-muted-theme" />
</button>
<button
type="button"
onClick={backToPrompt}
className="flex items-center gap-1 text-sm text-muted-theme hover:text-theme mb-3"
>
<ArrowLeft className="w-4 h-4" />
{t('gallery.guestRecovery.back', 'Back')}
</button>
<h2 className="text-lg font-semibold text-theme mb-2">
{t('gallery.guestRecovery.title', 'Recover your picks')}
</h2>
<p className="text-sm text-muted-theme mb-4">
{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.'
)}
</p>
{info && step === 'code' && (
<div className="text-sm text-green-700 bg-green-50 dark:bg-green-900/20 rounded px-3 py-2 mb-3">
{info}
</div>
)}
{error && (
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded px-3 py-2 mb-3">
{error}
</div>
)}
{step === 'email' ? (
<form onSubmit={handleRequestCode} className="space-y-4">
<Input
type="email"
label={t('gallery.guestRecovery.emailLabel', 'Email')}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus
required
/>
<Button type="submit" variant="primary" className="w-full" disabled={submitting}>
{submitting
? t('common.submitting', 'Submitting...')
: t('gallery.guestRecovery.sendCode', 'Send code')}
</Button>
</form>
) : (
<form onSubmit={handleVerify} className="space-y-4">
<Input
label={t('gallery.guestRecovery.codeLabel', 'Verification code')}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="123456"
maxLength={6}
autoFocus
required
/>
<Button type="submit" variant="primary" className="w-full" disabled={submitting}>
{submitting
? t('common.submitting', 'Submitting...')
: t('gallery.guestRecovery.verifyCode', 'Verify and continue')}
</Button>
</form>
)}
</div>
</div>
);
};
@@ -7,6 +7,7 @@ import { toast } from 'react-toastify';
import { format } from 'date-fns';
import { Button, Input } from '../common';
import type { PhotoFeedback } from '../../services/feedback.service';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoCommentsProps {
photoId: string;
@@ -29,6 +30,8 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const guestIdentity = useGuestIdentityOptional();
const isGuestMode = guestIdentity?.identityMode === 'guest';
const [showCommentForm, setShowCommentForm] = useState(false);
const [commentText, setCommentText] = useState('');
const [guestName, setGuestName] = useState('');
@@ -78,7 +81,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
}
});
const handleSubmitComment = (e: React.FormEvent) => {
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
@@ -87,7 +90,9 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
if (!commentText.trim()) {
newErrors.comment_text = t('feedback.commentRequired', 'Comment is required');
}
if (requireNameEmail) {
// In guest identity mode, name/email come from the guest token — don't
// ask for them here.
if (requireNameEmail && !isGuestMode) {
if (!guestName.trim()) {
newErrors.guest_name = t('feedback.nameRequired', 'Name is required');
}
@@ -101,6 +106,16 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
return;
}
if (isGuestMode && guestIdentity) {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
submitCommentMutation.mutate({ comment_text: commentText.trim() });
return;
}
submitCommentMutation.mutate({
comment_text: commentText.trim(),
guest_name: guestName.trim() || undefined,
@@ -140,7 +155,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
{/* Comment Form */}
{showCommentForm && (
<form onSubmit={handleSubmitComment} className="space-y-3 p-4 bg-surface rounded-lg border border-surface">
{requireNameEmail && (
{requireNameEmail && !isGuestMode && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input
placeholder={t('feedback.yourName', 'Your name')}
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoFavoritesProps {
photoId: string;
@@ -27,6 +28,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const guestIdentity = useGuestIdentityOptional();
const [isSubmitting, setIsSubmitting] = useState(false);
const [animating, setAnimating] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
@@ -68,9 +70,19 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
}
});
const handleFavoriteClick = () => {
const handleFavoriteClick = async () => {
if (!isEnabled || isSubmitting) return;
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
submitFavoriteMutation.mutate({});
return;
}
if (requireNameEmail && !savedIdentity) {
setShowIdentityModal(true);
} else {
+18 -2
View File
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoLikesProps {
photoId: string;
@@ -27,6 +28,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const guestIdentity = useGuestIdentityOptional();
const [isSubmitting, setIsSubmitting] = useState(false);
const [animating, setAnimating] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
@@ -68,9 +70,23 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
}
});
const handleLikeClick = () => {
const handleLikeClick = async () => {
if (!isEnabled || isSubmitting) return;
// Guest identity mode: ensure we have a per-person guest token. The
// server will read name/email from the token — body values are ignored.
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
// User cancelled the prompt — silently abort.
return;
}
submitLikeMutation.mutate({});
return;
}
// Simple mode (or no provider at all): legacy inline prompt flow.
if (requireNameEmail && !savedIdentity) {
setShowIdentityModal(true);
} else {
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoRatingProps {
photoId: string;
@@ -31,6 +32,7 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
const safeAverageRating = typeof averageRating === 'number' && !isNaN(averageRating) ? averageRating : 0;
const { t } = useTranslation();
const queryClient = useQueryClient();
const guestIdentity = useGuestIdentityOptional();
const [hoveredRating, setHoveredRating] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
@@ -72,18 +74,28 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
}
});
const handleRatingClick = (rating: number) => {
const handleRatingClick = async (rating: number) => {
if (!isEnabled || isSubmitting) return;
// If clicking the same rating, remove it
const newRating = rating === currentRating ? 0 : rating;
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
submitRatingMutation.mutate({ rating: newRating });
return;
}
if (requireNameEmail && !savedIdentity) {
setPendingRating(newRating);
setShowIdentityModal(true);
} else {
submitRatingMutation.mutate({
rating: newRating,
submitRatingMutation.mutate({
rating: newRating,
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email
});
@@ -5,6 +5,7 @@ import { AuthenticatedImage, Button } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
@@ -66,6 +67,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
@@ -150,6 +152,20 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
variant="ghost"
size="sm"
onClick={async () => {
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
setLikedIds(prev => new Set(prev).add(currentPhoto.id));
try {
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
feedback_type: 'like',
});
} catch (_) {}
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: currentPhoto.id });
setShowIdentityModal(true);
@@ -17,6 +17,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import { AuthenticatedImage } from '../../common';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import { FeedbackIdentityModal } from '../FeedbackIdentityModal';
import { galleryService } from '../../../services/gallery.service';
import { analyticsService } from '../../../services/analytics.service';
@@ -188,6 +189,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingLikePhotoId, setPendingLikePhotoId] = useState<number | null>(null);
@@ -237,6 +239,28 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
setLikedPhotoIds(prev => {
const next = new Set(prev);
next.add(photo.id);
return next;
});
try {
await feedbackService.submitFeedback(slug, String(photo.id), {
feedback_type: 'like',
});
onFeedbackChange?.();
} catch (err) {
console.warn('Like submit failed', err);
}
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingLikePhotoId(photo.id);
setShowIdentityModal(true);
@@ -260,7 +284,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
} catch (err) {
console.warn('Like submit failed', err);
}
}, [slug, savedIdentity, feedbackOptions, onFeedbackChange]);
}, [slug, savedIdentity, feedbackOptions, onFeedbackChange, guestIdentity]);
const handleIdentitySubmit = useCallback(async (name: string, email: string) => {
setSavedIdentity({ name, email });
@@ -6,6 +6,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
@@ -61,6 +62,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
onLikeSuccess
}) => {
const { t } = useTranslation();
const guestIdentity = useGuestIdentityOptional();
const [overlayVisible, setOverlayVisible] = React.useState(false);
const [isTouchDevice, setIsTouchDevice] = React.useState(false);
const overlayTimeoutRef = React.useRef<number | null>(null);
@@ -259,6 +261,25 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
className={`p-2 rounded-full transition-colors ${liked ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
hideOverlay();
return;
}
if (onLikeSuccess) onLikeSuccess();
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
} catch (err) {
console.warn('Like submit failed, keeping optimistic UI', err);
}
if (onFeedbackChange) onFeedbackChange();
hideOverlay();
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
onRequireIdentity('like', photo.id);
hideOverlay();
@@ -8,6 +8,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import { buildResourceUrl } from '../../../utils/url';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
@@ -81,6 +82,7 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
liked = false,
onLikeSuccess,
}) => {
const guestIdentity = useGuestIdentityOptional();
const [overlayVisible, setOverlayVisible] = useState(false);
const [isTouchDevice, setIsTouchDevice] = useState(false);
const overlayTimeoutRef = useRef<number | null>(null);
@@ -301,6 +303,25 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
}`}
onClick={async (e) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
hideOverlay();
return;
}
if (onLikeSuccess) onLikeSuccess();
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
} catch (err) {
console.warn('Like submit failed, keeping optimistic UI', err);
}
if (onFeedbackChange) onFeedbackChange();
hideOverlay();
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
onRequireIdentity('like', photo.id);
hideOverlay();
@@ -4,6 +4,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import {
calculateJustifiedLayout,
createJustifiedPhotos,
@@ -53,6 +54,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
// Calculate height based on actual photo aspect ratio
// This preserves the photo's natural proportions in the masonry layout
@@ -151,6 +153,17 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={async (e) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
@@ -4,6 +4,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
@@ -53,6 +54,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
const [likedLocal, setLikedLocal] = React.useState(false);
const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment);
@@ -108,6 +110,20 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
className={`p-2 rounded-full transition-colors ${likedLocal ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
setLikedLocal(true);
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
} catch (_) {}
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
@@ -7,6 +7,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
@@ -26,6 +27,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
const gallerySettings = theme.gallerySettings || {};
const grouping = gallerySettings.timelineGrouping || 'day';
const showDates = gallerySettings.timelineShowDates !== false;
@@ -147,6 +149,20 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
setLikedIds(prev => new Set(prev).add(photo.id));
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
} catch (_) {}
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
+20
View File
@@ -5,6 +5,7 @@ import {
inferGallerySlugFromLocation,
resolveSlugFromRequestUrl,
} from '../utils/galleryAuthStorage';
import { getGuestToken } from '../utils/guestIdentityStorage';
import { getApiBaseUrl } from '../utils/url';
// Maintenance mode callback
@@ -80,6 +81,25 @@ api.interceptors.request.use(
}
}
}
// Also inject guest token (x-guest-token) for per-person identity.
// Separate header so gallery auth and guest identity are independent.
const guestToken = getGuestToken(slug);
if (guestToken) {
if (!config.headers) {
config.headers = new AxiosHeaders();
}
if (config.headers instanceof AxiosHeaders) {
if (!config.headers.get('x-guest-token')) {
config.headers.set('x-guest-token', guestToken);
}
} else {
const headersRecord = config.headers as Record<string, string | undefined>;
if (!headersRecord['x-guest-token']) {
headersRecord['x-guest-token'] = guestToken;
}
}
}
}
}
}
@@ -0,0 +1,224 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { guestsService, GuestIdentity } from '../services/guests.service';
import {
clearGuestIdentity,
getGuestIdentity,
storeGuestIdentity,
} from '../utils/guestIdentityStorage';
type IdentityMode = 'simple' | 'guest';
interface GuestIdentityContextValue {
slug: string;
identity: GuestIdentity | null;
identityMode: IdentityMode;
isRequired: boolean; // true when mode='guest' AND no identity yet
promptOpen: boolean;
recoveryOpen: boolean;
openPrompt: () => void;
closePrompt: () => void;
openRecovery: () => void;
closeRecovery: () => void;
register: (name: string, email?: string) => Promise<GuestIdentity>;
recoverRequest: (email: string) => Promise<void>;
recoverVerify: (email: string, code: string) => Promise<GuestIdentity>;
forget: () => Promise<void>;
/**
* Used by feedback components. Returns the current identity, or opens the
* prompt and waits until the user registers (or cancels, in which case it
* throws a "user_cancelled" error).
*/
ensureIdentity: () => Promise<GuestIdentity>;
}
const GuestIdentityContext = createContext<GuestIdentityContextValue | null>(null);
interface GuestIdentityProviderProps {
slug: string;
identityMode: IdentityMode;
children: React.ReactNode;
}
export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
slug,
identityMode,
children,
}) => {
const [identity, setIdentity] = useState<GuestIdentity | null>(() => getGuestIdentity(slug));
const [promptOpen, setPromptOpen] = useState(false);
const [recoveryOpen, setRecoveryOpen] = useState(false);
// Pending promise resolvers for ensureIdentity() calls waiting on prompt.
const pendingResolvers = useRef<Array<(identity: GuestIdentity) => void>>([]);
const pendingRejecters = useRef<Array<(reason: Error) => void>>([]);
// Rehydrate identity when slug changes.
useEffect(() => {
setIdentity(getGuestIdentity(slug));
}, [slug]);
// When an invite token is present on the URL (?invite=xxx), redeem it once
// on mount. The server returns a guest token we can persist.
useEffect(() => {
if (identityMode !== 'guest' || identity) return;
const params = new URLSearchParams(window.location.search);
const inviteToken = params.get('invite');
if (!inviteToken) return;
(async () => {
try {
const response = await guestsService.redeemInvite(slug, inviteToken);
storeGuestIdentity(slug, response.guest, response.token);
setIdentity(response.guest);
// Strip invite param from URL to prevent re-redemption on reload.
params.delete('invite');
const newSearch = params.toString();
const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash;
window.history.replaceState({}, '', newUrl);
} catch (error) {
// Silently fail invalid invites; user will fall back to normal prompt.
// eslint-disable-next-line no-console
console.warn('Failed to redeem invite token', error);
}
})();
}, [slug, identityMode, identity]);
const openPrompt = useCallback(() => setPromptOpen(true), []);
const closePrompt = useCallback(() => {
setPromptOpen(false);
// Reject any pending ensureIdentity() promises.
pendingRejecters.current.forEach((r) => r(new Error('user_cancelled')));
pendingResolvers.current = [];
pendingRejecters.current = [];
}, []);
const openRecovery = useCallback(() => setRecoveryOpen(true), []);
const closeRecovery = useCallback(() => setRecoveryOpen(false), []);
const register = useCallback(
async (name: string, email?: string): Promise<GuestIdentity> => {
const response = await guestsService.registerGuest(slug, { name, email });
storeGuestIdentity(slug, response.guest, response.token);
setIdentity(response.guest);
setPromptOpen(false);
// Resolve pending ensureIdentity() promises.
pendingResolvers.current.forEach((r) => r(response.guest));
pendingResolvers.current = [];
pendingRejecters.current = [];
return response.guest;
},
[slug]
);
const recoverRequest = useCallback(
async (email: string): Promise<void> => {
await guestsService.requestRecoveryCode(slug, email);
},
[slug]
);
const recoverVerify = useCallback(
async (email: string, code: string): Promise<GuestIdentity> => {
const response = await guestsService.verifyRecoveryCode(slug, email, code);
storeGuestIdentity(slug, response.guest, response.token);
setIdentity(response.guest);
setPromptOpen(false);
setRecoveryOpen(false);
pendingResolvers.current.forEach((r) => r(response.guest));
pendingResolvers.current = [];
pendingRejecters.current = [];
return response.guest;
},
[slug]
);
const forget = useCallback(async (): Promise<void> => {
try {
if (identity) {
await guestsService.forgetMe(slug);
}
} catch {
// Best-effort. Clear local state regardless.
}
clearGuestIdentity(slug);
setIdentity(null);
}, [slug, identity]);
const ensureIdentity = useCallback((): Promise<GuestIdentity> => {
if (identityMode !== 'guest') {
// In simple mode, there is no per-person identity. Return a synthetic
// "null" identity that callers will ignore.
return Promise.resolve({
id: 0,
name: '',
email: null,
identifier: '',
} as GuestIdentity);
}
if (identity) return Promise.resolve(identity);
return new Promise((resolve, reject) => {
pendingResolvers.current.push(resolve);
pendingRejecters.current.push(reject);
setPromptOpen(true);
});
}, [identityMode, identity]);
const isRequired = identityMode === 'guest' && !identity;
const value = useMemo<GuestIdentityContextValue>(
() => ({
slug,
identity,
identityMode,
isRequired,
promptOpen,
recoveryOpen,
openPrompt,
closePrompt,
openRecovery,
closeRecovery,
register,
recoverRequest,
recoverVerify,
forget,
ensureIdentity,
}),
[
slug,
identity,
identityMode,
isRequired,
promptOpen,
recoveryOpen,
openPrompt,
closePrompt,
openRecovery,
closeRecovery,
register,
recoverRequest,
recoverVerify,
forget,
ensureIdentity,
]
);
return <GuestIdentityContext.Provider value={value}>{children}</GuestIdentityContext.Provider>;
};
export function useGuestIdentity(): GuestIdentityContextValue {
const ctx = useContext(GuestIdentityContext);
if (!ctx) {
throw new Error('useGuestIdentity must be used within a GuestIdentityProvider');
}
return ctx;
}
/**
* Safe hook that returns null if no provider is present. Useful when code
* needs to optionally tie into guest identity without crashing when used
* outside a gallery (e.g. in admin contexts).
*/
export function useGuestIdentityOptional(): GuestIdentityContextValue | null {
return useContext(GuestIdentityContext);
}
@@ -0,0 +1,69 @@
import { useCallback } from 'react';
import { feedbackService } from '../services/feedback.service';
import { useGuestIdentityOptional } from '../contexts/GuestIdentityContext';
/**
* Shared helper used by gallery layout "quick action" buttons (like, favorite,
* rating, etc.) to submit feedback with proper identity handling:
*
* - In guest identity mode: ensures the visitor has a guest token (prompts
* if needed), then submits. Server reads name/email from the token.
* - In simple mode with require_name_email: callers still need to show
* their own inline FeedbackIdentityModal (we return `needsSimpleIdentity`
* to signal this).
* - In simple mode without require_name_email: submits directly.
*/
export function useGalleryFeedbackAction() {
const guestIdentity = useGuestIdentityOptional();
/**
* Submit a feedback action. Returns:
* - { submitted: true } if the submission happened.
* - { cancelled: true } if the user cancelled the guest prompt.
* - { needsSimpleIdentity: true } if the caller must show its own legacy
* identity modal (simple mode with require_name_email).
*/
const submit = useCallback(
async (
slug: string,
photoId: number | string,
action: {
feedback_type: 'like' | 'favorite' | 'rating' | 'comment';
rating?: number;
comment_text?: string;
},
options?: {
requireNameEmail?: boolean;
savedIdentity?: { name: string; email: string } | null;
}
): Promise<{ submitted?: boolean; cancelled?: boolean; needsSimpleIdentity?: boolean }> => {
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return { cancelled: true };
}
await feedbackService.submitFeedback(slug, String(photoId), action);
return { submitted: true };
}
// Simple mode
if (options?.requireNameEmail && !options.savedIdentity) {
return { needsSimpleIdentity: true };
}
await feedbackService.submitFeedback(slug, String(photoId), {
...action,
guest_name: options?.savedIdentity?.name,
guest_email: options?.savedIdentity?.email,
});
return { submitted: true };
},
[guestIdentity]
);
return {
submit,
isGuestMode: guestIdentity?.identityMode === 'guest',
};
}
+6 -1
View File
@@ -2320,7 +2320,12 @@
"comments": "Comments",
"commentsDesc": "Text comments on photos",
"favorites": "Favorites",
"favoritesDesc": "Mark photos as favorites"
"favoritesDesc": "Mark photos as favorites",
"identityMode": "Identity Mode",
"identityModeSimple": "Simple feedback",
"identityModeSimpleDesc": "Anonymous, device-based. All visitors on the same device share state.",
"identityModeGuest": "Per-guest selections",
"identityModeGuestDesc": "Each visitor enters their name. Enables per-guest tracking and admin insights."
}
},
"filter": {
+19 -2
View File
@@ -53,7 +53,7 @@ import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, Loading } from '../../components/common';
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu } from '../../components/admin';
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { publicSettingsService } from '../../services/publicSettings.service';
@@ -232,7 +232,7 @@ export const EventDetailsPage: React.FC = () => {
const [clientPin, setClientPin] = useState('');
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
const [showExternalImport, setShowExternalImport] = useState(false);
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories' | 'guests'>('overview');
const [externalPath, setExternalPath] = useState<string>('');
const [importing, setImporting] = useState<boolean>(false);
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
@@ -912,6 +912,18 @@ export const EventDetailsPage: React.FC = () => {
>
{t('events.categories')}
</button>
{eventFeedbackSettings?.identity_mode === 'guest' && (
<button
onClick={() => setActiveTab('guests')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'guests'
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
{t('admin.events.tabs.guests', 'Guests')}
</button>
)}
</nav>
</div>
@@ -2104,6 +2116,11 @@ export const EventDetailsPage: React.FC = () => {
</div>
)}
{/* Guests Tab (only visible when identity_mode === 'guest') */}
{activeTab === 'guests' && eventFeedbackSettings?.identity_mode === 'guest' && (
<AdminGuestsList eventId={parseInt(id!)} eventName={event.event_name} />
)}
{/* Password Reset Modal */}
{showPasswordReset && (
<PasswordResetModal
@@ -1,5 +1,7 @@
import { api } from '../config/api';
export type IdentityMode = 'simple' | 'guest';
export interface FeedbackSettings {
feedback_enabled: boolean;
allow_ratings: boolean;
@@ -12,6 +14,7 @@ export interface FeedbackSettings {
enable_rate_limiting: boolean;
rate_limit_window_minutes?: number;
rate_limit_max_requests?: number;
identity_mode?: IdentityMode;
}
export interface PhotoFeedback {
+163
View File
@@ -0,0 +1,163 @@
import { api } from '../config/api';
export interface GuestIdentity {
id: number;
name: string;
email: string | null;
identifier: string;
}
export interface GuestRegisterResponse {
guest: GuestIdentity;
token: string;
}
export interface AdminGuestStats {
likes: number;
favorites: number;
comments: number;
ratings: number;
distinct_photos: number;
}
export interface AdminGuest {
id: number;
name: string;
email: string | null;
created_at: string;
last_seen_at: string;
email_verified_at: string | null;
is_deleted: boolean;
stats: AdminGuestStats;
}
export interface AdminGuestPhoto {
id: number;
filename: string;
original_filename: string | null;
type?: string;
url: string;
thumbnail_url: string;
}
export interface AdminGuestSelections {
liked: AdminGuestPhoto[];
favorited: AdminGuestPhoto[];
rated: Array<{ photo: AdminGuestPhoto; rating: number }>;
commented: Array<{ photo: AdminGuestPhoto; comment: string; created_at: string }>;
}
export interface AdminGuestDetail {
guest: AdminGuest;
selections: AdminGuestSelections;
}
export interface AggregatePhoto extends AdminGuestPhoto {
picker_count: number;
}
export interface GuestInvite {
id: number;
token: string;
url: string;
created_at: string;
redeemed_at: string | null;
revoked_at: string | null;
status: 'pending' | 'redeemed' | 'revoked';
guest: { id: number; name: string; email: string | null };
}
class GuestsService {
// ===================================================================
// Gallery-side (public) — guest identity
// ===================================================================
async registerGuest(slug: string, data: { name: string; email?: string }): Promise<GuestRegisterResponse> {
const response = await api.post(`/gallery/${slug}/guest`, data);
return response.data;
}
async getGuestMe(slug: string): Promise<{ guest: GuestIdentity }> {
const response = await api.get(`/gallery/${slug}/guest/me`);
return response.data;
}
async forgetMe(slug: string): Promise<{ success: boolean }> {
const response = await api.delete(`/gallery/${slug}/guest/me`);
return response.data;
}
async requestRecoveryCode(slug: string, email: string): Promise<{ success: boolean }> {
const response = await api.post(`/gallery/${slug}/guest/recover`, { email });
return response.data;
}
async verifyRecoveryCode(slug: string, email: string, code: string): Promise<GuestRegisterResponse> {
const response = await api.post(`/gallery/${slug}/guest/verify`, { email, code });
return response.data;
}
async redeemInvite(slug: string, inviteToken: string): Promise<GuestRegisterResponse> {
const response = await api.post(`/gallery/${slug}/guest/redeem`, { inviteToken });
return response.data;
}
// ===================================================================
// Admin-side
// ===================================================================
async getEventGuests(eventId: number): Promise<{ guests: AdminGuest[] }> {
const response = await api.get(`/admin/events/${eventId}/guests`);
return response.data;
}
async getGuestDetail(eventId: number, guestId: number): Promise<AdminGuestDetail> {
const response = await api.get(`/admin/events/${eventId}/guests/${guestId}`);
return response.data;
}
async getAggregatePicks(eventId: number): Promise<{ photos: AggregatePhoto[] }> {
const response = await api.get(`/admin/events/${eventId}/guests/aggregate`);
return response.data;
}
async deleteGuest(eventId: number, guestId: number): Promise<void> {
await api.delete(`/admin/events/${eventId}/guests/${guestId}`);
}
async mergeGuests(eventId: number, keepId: number, mergeIds: number[]): Promise<void> {
await api.post(`/admin/events/${eventId}/guests/${keepId}/merge`, { mergeIds });
}
async exportGuest(eventId: number, guestId: number, format: 'txt' | 'csv' | 'json'): Promise<Blob> {
const response = await api.get(`/admin/events/${eventId}/guests/${guestId}/export`, {
params: { format },
responseType: 'blob',
});
return response.data;
}
async exportAllGuests(eventId: number, format: 'txt' | 'csv' | 'json'): Promise<Blob> {
const response = await api.get(`/admin/events/${eventId}/guests/export-all`, {
params: { format },
responseType: 'blob',
});
return response.data;
}
async listInvites(eventId: number): Promise<{ invites: GuestInvite[] }> {
const response = await api.get(`/admin/events/${eventId}/guests/invites`);
return response.data;
}
async createInvite(eventId: number, data: { name: string; email?: string }): Promise<{ invite: GuestInvite }> {
const response = await api.post(`/admin/events/${eventId}/guests/invites`, data);
return response.data;
}
async revokeInvite(eventId: number, inviteId: number): Promise<void> {
await api.delete(`/admin/events/${eventId}/guests/invites/${inviteId}`);
}
}
export const guestsService = new GuestsService();
@@ -0,0 +1,85 @@
/**
* Per-gallery guest identity persistence.
*
* Stores the guest JWT and profile in sessionStorage, keyed by gallery slug,
* so multiple open tabs of the same gallery share identity but different
* browser contexts (and different galleries in the same context) stay
* independent.
*/
import type { GuestIdentity } from '../services/guests.service';
const TOKEN_KEY_PREFIX = 'guest_token_';
const IDENTITY_KEY_PREFIX = 'guest_identity_';
const isBrowser = typeof window !== 'undefined';
const getStorage = (): Storage | null => {
if (!isBrowser) return null;
try {
return window.sessionStorage;
} catch {
return null;
}
};
export function storeGuestIdentity(slug: string, identity: GuestIdentity, token: string): void {
const storage = getStorage();
if (!storage || !slug) return;
storage.setItem(`${TOKEN_KEY_PREFIX}${slug}`, token);
storage.setItem(`${IDENTITY_KEY_PREFIX}${slug}`, JSON.stringify(identity));
}
export function getGuestToken(slug?: string | null): string | null {
const storage = getStorage();
if (!storage) return null;
const resolvedSlug = slug || extractSlugFromLocation();
if (!resolvedSlug) return null;
return storage.getItem(`${TOKEN_KEY_PREFIX}${resolvedSlug}`);
}
export function getGuestIdentity(slug?: string | null): GuestIdentity | null {
const storage = getStorage();
if (!storage) return null;
const resolvedSlug = slug || extractSlugFromLocation();
if (!resolvedSlug) return null;
const raw = storage.getItem(`${IDENTITY_KEY_PREFIX}${resolvedSlug}`);
if (!raw) return null;
try {
return JSON.parse(raw) as GuestIdentity;
} catch {
return null;
}
}
export function clearGuestIdentity(slug: string): void {
const storage = getStorage();
if (!storage || !slug) return;
storage.removeItem(`${TOKEN_KEY_PREFIX}${slug}`);
storage.removeItem(`${IDENTITY_KEY_PREFIX}${slug}`);
}
/**
* Extract the gallery slug from a request URL path like "/gallery/:slug/...".
* Matches the axios interceptor logic in api.ts.
*/
export function extractGuestSlugFromUrl(url: string): string | null {
if (!url) return null;
const pathOnly = url.startsWith('http://') || url.startsWith('https://')
? (() => {
try {
return new URL(url).pathname;
} catch {
return url;
}
})()
: url;
const match = pathOnly.match(/\/gallery\/([^/?#]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
function extractSlugFromLocation(): string | null {
if (!isBrowser) return null;
const match = window.location.pathname.match(/\/gallery\/([^/?#]+)/);
return match ? decodeURIComponent(match[1]) : null;
}