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:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user