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,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',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user