feat: guest selections with per-person identity (#292)
Introduces a new "Per-guest selections" identity mode for event feedback, letting each visitor register under their own name so their likes/favorites/comments/ratings are tracked independently. Includes admin insights (list, per-guest detail, aggregate view, export) and advanced identity features (forget-me, email recovery, invite tokens, merge). New event-level setting - event_feedback_settings.identity_mode = 'simple' | 'guest' (default 'simple' → zero behavior change for existing events). - Admin UI radio under Feedback Settings to toggle per event. Root cause of the previous "all guests share state" bug - generateGuestIdentifier() was sha256(ip + userAgent), so every visitor on the same WiFi + similar device collided into one identity. - Now: when a verified guest JWT is present (x-guest-token header), req.guest.identifier takes precedence — per-person rate limits and per-person deduplication. Phase 1 — identity layer - Migration 078: new gallery_guests, guest_invites, guest_verification_ codes tables; identity_mode column + check constraint; nullable guest_id FK on photo_feedback. - New guest JWT type scoped to (eventId, guestId). - New middleware guestAuth.resolveGuest (non-blocking) + requireGuest. - POST /gallery/:slug/guest, GET /guest/me, DELETE /guest/me. - Gallery feedback route enforces guest identity in guest mode and reads name/email from the verified token (never from the body). - Frontend GuestIdentityContext + GuestNamePromptModal; axios interceptor injects x-guest-token on gallery API calls. - Feedback-only blocking: gallery opens freely, prompt only on first interactive feedback action. - Admin "Guests" tab (conditional on identity_mode='guest') with the AdminGuestsList component. Phase 2 — admin insights - GET /admin/events/:eventId/guests list + aggregated counts. - GET /admin/events/:eventId/guests/:guestId detail with per-type groupings; AdminGuestDetail modal with thumbnail grid + tabs. - GET /admin/events/:eventId/guests/aggregate sorted by distinct guest pick count; GuestSelectionsAggregate component. - Per-guest export (txt/csv/json) and bulk export-all ZIP. Phase 3 — polish - 3.1 Self-service forget-me link in gallery footer. - 3.2 Email-based identity recovery: POST /guest/recover sends a 6-digit code via the existing emailProcessor, POST /guest/verify exchanges it for a token (rate-limited, enumeration-safe). - 3.3 Admin invite tokens: pre-mint identities, share URLs with ?invite=, single-use redemption stripping the param from history. - 3.4 Admin merge endpoint reassigns feedback + soft-deletes sources. Shared helper - useGalleryFeedbackAction hook wraps the identity-check logic for inline like buttons across Masonry/Grid/Justified/Mosaic/Carousel/ Timeline/Premium layouts. Backwards compatibility - Existing events default to 'simple' after migration; behavior unchanged. - Legacy photo_feedback rows keep guest_id NULL; admin shows them in the generic feedback moderation view as before. - feedback_count denormalized stat now uses COALESCE(guest_id, guest_identifier) so per-guest counts are accurate without touching legacy rows. Verified end-to-end against local Docker - Migration clean on existing data. - Simple mode unchanged (no prompt, legacy flow). - Guest mode: Alice registers on click, tokens persist in sessionStorage, feedback rows carry guest_id. - Carol via invite link auto-redeems, sees Alice's "1 likes" badge. - Admin Guests tab shows both with correct counts; detail modal displays thumbnail grid with badges; aggregate view sorts by picker count (photo 227 = 2, others = 1); CSV/JSON export matches DB. - Merge Carol into Alice: feedback reassigned, Carol soft-deleted, Alice count = 4.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user