Files
picpeak/frontend/src/components/admin/GuestInviteDialog.tsx
T
Paul Nothaft ad4e5a7506 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.
2026-04-11 07:48:23 +02:00

195 lines
8.4 KiB
TypeScript

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>
);
};