From e3b7271f6967073848e8270d10875ce207c5183c Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 5 Jan 2026 14:04:05 +0100 Subject: [PATCH] Add live show moderation queue --- .beads/issues.jsonl | 2 +- .../Api/Tenant/LiveShowPhotoController.php | 144 ++++++++ .../Tenant/LiveShowApproveRequest.php | 28 ++ .../Requests/Tenant/LiveShowQueueRequest.php | 34 ++ .../Requests/Tenant/LiveShowRejectRequest.php | 28 ++ app/Http/Resources/Tenant/PhotoResource.php | 5 + app/Models/Photo.php | 11 + resources/js/admin/api.ts | 79 +++++ resources/js/admin/constants.ts | 1 + .../js/admin/i18n/locales/de/management.json | 28 ++ .../js/admin/i18n/locales/en/management.json | 28 ++ resources/js/admin/mobile/EventDetailPage.tsx | 22 +- .../admin/mobile/EventLiveShowQueuePage.tsx | 309 ++++++++++++++++++ resources/js/admin/router.tsx | 2 + routes/api.php | 11 + tests/Feature/LiveShowPhotoControllerTest.php | 105 ++++++ 16 files changed, 829 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php create mode 100644 app/Http/Requests/Tenant/LiveShowApproveRequest.php create mode 100644 app/Http/Requests/Tenant/LiveShowQueueRequest.php create mode 100644 app/Http/Requests/Tenant/LiveShowRejectRequest.php create mode 100644 resources/js/admin/mobile/EventLiveShowQueuePage.tsx create mode 100644 tests/Feature/LiveShowPhotoControllerTest.php diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f533924..d8db5a6 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -126,7 +126,7 @@ {"id":"fotospiel-app-wde","title":"Tenant lifecycle controls (status, limits, suspend/grace)","description":"Superadmin controls for tenant status, grace periods, and hard limits (uploads/storage/events). Includes UI, policy checks, and audit events.","notes":"Delivered dedicated tenant lifecycle view with limits + audit timeline, added grace_period_ends_at field and tenant_lifecycle_events logging, wired lifecycle actions (activate/suspend/deletion/anonymize) + management actions (limits, grace, subscription expiry), enforced tenant photo/storage limits in PackageLimitEvaluator, added lifecycle/limits tests, ran Pint + targeted tests.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:23.062036821+01:00","updated_at":"2026-01-02T17:33:35.031605632+01:00","closed_at":"2026-01-02T17:33:35.031605632+01:00","close_reason":"Closed"} {"id":"fotospiel-app-wkl","title":"Paddle catalog sync: paddle:sync-packages command (dry-run/pull)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:58.753792575+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:04.39629062+01:00","closed_at":"2026-01-01T16:01:04.39629062+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-wku","title":"Security review: run dynamic testing harness (identities, DAST, fuzz uploads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:37.008239379+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:37.008239379+01:00"} -{"id":"fotospiel-app-xg5","title":"Live Show: Admin app moderation queue UI","status":"open","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:15.006484132+01:00","created_by":"soeren","updated_at":"2026-01-05T11:11:15.006484132+01:00","dependencies":[{"issue_id":"fotospiel-app-xg5","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:38.94145573+01:00","created_by":"soeren"}]} +{"id":"fotospiel-app-xg5","title":"Live Show: Admin app moderation queue UI","acceptance_criteria":"- Dedicated Live Show moderation API endpoints exist for list + approve/reject/clear\\n- Admin mobile UI exposes Live Show queue with status filter and actions\\n- PhotoResource includes live_* fields for admin UI\\n- Feature tests cover list + approve/reject/clear workflows","notes":"Added dedicated Live Show moderation API (tenant admin): /events/{slug}/live-show/photos + approve/reject/clear actions. Added LiveShowPhotoController + FormRequests. PhotoResource now exposes live_* fields. Admin app: new Live Show queue page, route, and Event detail shortcut tile. Admin API updated with Live Show functions + types. Added translations (EN/DE) for Live Show queue UI. Tests: tests/Feature/LiveShowPhotoControllerTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:15.006484132+01:00","created_by":"soeren","updated_at":"2026-01-05T14:03:41.410176482+01:00","closed_at":"2026-01-05T14:03:41.410176482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-xg5","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:38.94145573+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-y1f","title":"Compliance tools: superadmin data export + retention override UI","description":"Add superadmin compliance tools for data exports and retention overrides.\nScope: list export requests, status, expiry, and allow manual retry/cancel; add per-tenant/event retention override UI with audit logging.\nEnsure access is restricted to superadmins and no PII is exposed beyond existing export metadata.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:29.825347299+01:00","created_by":"soeren","updated_at":"2026-01-02T22:49:53.586758621+01:00","closed_at":"2026-01-02T22:49:53.586758621+01:00","close_reason":"Closed"} {"id":"fotospiel-app-z2k","title":"Ops health widget visual polish","description":"Replace Tailwind utility styling in ops health widget with Filament components and icon-driven layout.","notes":"Updated queue health widget layout to use Filament cards, badges, empty states, and grid utilities; added status strip and alert rail.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-01T21:34:39.851728527+01:00","created_by":"soeren","updated_at":"2026-01-01T21:34:59.834597413+01:00","closed_at":"2026-01-01T21:34:59.834597413+01:00","close_reason":"completed"} diff --git a/app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php b/app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php new file mode 100644 index 0000000..a3e9758 --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php @@ -0,0 +1,144 @@ +attributes->get('tenant_id'); + $event = Event::where('slug', $eventSlug) + ->where('tenant_id', $tenantId) + ->firstOrFail(); + + $liveStatus = $request->string('live_status', 'pending')->toString(); + $perPage = (int) $request->input('per_page', 20); + $perPage = max(1, min($perPage, 50)); + + $query = Photo::query() + ->where('event_id', $event->id) + ->where('status', 'approved') + ->with('event') + ->withCount('likes'); + + if ($liveStatus !== '' && $liveStatus !== 'all') { + $query->where('live_status', $liveStatus); + } + + $photos = $query + ->orderByDesc('live_submitted_at') + ->orderByDesc('created_at') + ->paginate($perPage); + + return PhotoResource::collection($photos); + } + + public function approve(LiveShowApproveRequest $request, string $eventSlug, Photo $photo): JsonResponse + { + $tenantId = $request->attributes->get('tenant_id'); + $event = Event::where('slug', $eventSlug) + ->where('tenant_id', $tenantId) + ->firstOrFail(); + + if ($photo->event_id !== $event->id) { + return ApiError::response( + 'photo_not_found', + 'Photo not found', + 'The specified photo could not be located for this event.', + Response::HTTP_NOT_FOUND, + ['photo_id' => $photo->id] + ); + } + + if ($photo->status !== 'approved') { + return ApiError::response( + 'photo_not_approved', + 'Photo not approved', + 'Only approved photos can be added to the Live Show.', + Response::HTTP_UNPROCESSABLE_ENTITY, + ['photo_id' => $photo->id] + ); + } + + $photo->approveForLiveShow($request->user()); + + if ($request->filled('priority')) { + $photo->forceFill([ + 'live_priority' => $request->integer('priority'), + ])->save(); + } + + $photo->refresh()->load('event')->loadCount('likes'); + + return response()->json([ + 'message' => 'Photo approved for Live Show', + 'data' => new PhotoResource($photo), + ]); + } + + public function reject(LiveShowRejectRequest $request, string $eventSlug, Photo $photo): JsonResponse + { + $tenantId = $request->attributes->get('tenant_id'); + $event = Event::where('slug', $eventSlug) + ->where('tenant_id', $tenantId) + ->firstOrFail(); + + if ($photo->event_id !== $event->id) { + return ApiError::response( + 'photo_not_found', + 'Photo not found', + 'The specified photo could not be located for this event.', + Response::HTTP_NOT_FOUND, + ['photo_id' => $photo->id] + ); + } + + $reason = $request->string('reason')->toString(); + $photo->rejectForLiveShow($request->user(), $reason !== '' ? $reason : null); + $photo->refresh()->load('event')->loadCount('likes'); + + return response()->json([ + 'message' => 'Photo rejected for Live Show', + 'data' => new PhotoResource($photo), + ]); + } + + public function clear(Request $request, string $eventSlug, Photo $photo): JsonResponse + { + $tenantId = $request->attributes->get('tenant_id'); + $event = Event::where('slug', $eventSlug) + ->where('tenant_id', $tenantId) + ->firstOrFail(); + + if ($photo->event_id !== $event->id) { + return ApiError::response( + 'photo_not_found', + 'Photo not found', + 'The specified photo could not be located for this event.', + Response::HTTP_NOT_FOUND, + ['photo_id' => $photo->id] + ); + } + + $photo->clearFromLiveShow($request->user()); + $photo->refresh()->load('event')->loadCount('likes'); + + return response()->json([ + 'message' => 'Photo removed from Live Show', + 'data' => new PhotoResource($photo), + ]); + } +} diff --git a/app/Http/Requests/Tenant/LiveShowApproveRequest.php b/app/Http/Requests/Tenant/LiveShowApproveRequest.php new file mode 100644 index 0000000..36ae0f4 --- /dev/null +++ b/app/Http/Requests/Tenant/LiveShowApproveRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'priority' => ['nullable', 'integer', 'min:0', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/Tenant/LiveShowQueueRequest.php b/app/Http/Requests/Tenant/LiveShowQueueRequest.php new file mode 100644 index 0000000..21ff038 --- /dev/null +++ b/app/Http/Requests/Tenant/LiveShowQueueRequest.php @@ -0,0 +1,34 @@ +|string> + */ + public function rules(): array + { + return [ + 'live_status' => [ + 'nullable', + 'string', + Rule::in(['pending', 'approved', 'rejected', 'none', 'expired', 'all']), + ], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/Tenant/LiveShowRejectRequest.php b/app/Http/Requests/Tenant/LiveShowRejectRequest.php new file mode 100644 index 0000000..50f24b5 --- /dev/null +++ b/app/Http/Requests/Tenant/LiveShowRejectRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'reason' => ['nullable', 'string', 'max:64'], + ]; + } +} diff --git a/app/Http/Resources/Tenant/PhotoResource.php b/app/Http/Resources/Tenant/PhotoResource.php index 534e491..afeeb8f 100644 --- a/app/Http/Resources/Tenant/PhotoResource.php +++ b/app/Http/Resources/Tenant/PhotoResource.php @@ -32,6 +32,11 @@ class PhotoResource extends JsonResource 'is_featured' => (bool) ($this->is_featured ?? false), 'status' => $showSensitive ? $this->status : 'approved', 'moderation_notes' => $showSensitive ? $this->moderation_notes : null, + 'live_status' => $showSensitive ? $this->live_status?->value ?? $this->live_status : null, + 'live_approved_at' => $showSensitive ? $this->live_approved_at?->toISOString() : null, + 'live_reviewed_at' => $showSensitive ? $this->live_reviewed_at?->toISOString() : null, + 'live_rejection_reason' => $showSensitive ? $this->live_rejection_reason : null, + 'live_priority' => $showSensitive ? (int) ($this->live_priority ?? 0) : null, 'likes_count' => (int) ($this->likes_count ?? $this->likes()->count()), 'is_liked' => false, 'uploaded_at' => $this->created_at->toISOString(), diff --git a/app/Models/Photo.php b/app/Models/Photo.php index 75c22c0..0fa2845 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -128,6 +128,17 @@ class Photo extends Model ])->save(); } + public function clearFromLiveShow(?User $reviewer = null): void + { + $this->forceFill([ + 'live_status' => PhotoLiveStatus::NONE, + 'live_approved_at' => null, + 'live_reviewed_at' => now(), + 'live_reviewed_by' => $reviewer?->id, + 'live_rejection_reason' => null, + ])->save(); + } + public function likes(): HasMany { return $this->hasMany(PhotoLike::class); diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index c323be0..d94ed0b 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -132,6 +132,11 @@ export type TenantPhoto = { url: string | null; thumbnail_url: string | null; status: string; + live_status?: string | null; + live_approved_at?: string | null; + live_reviewed_at?: string | null; + live_rejection_reason?: string | null; + live_priority?: number | null; is_featured: boolean; likes_count: number; uploaded_at: string; @@ -911,6 +916,11 @@ function normalizePhoto(photo: TenantPhoto): TenantPhoto { url: photo.url, thumbnail_url: photo.thumbnail_url ?? photo.url, status: photo.status ?? 'approved', + live_status: photo.live_status ?? null, + live_approved_at: photo.live_approved_at ?? null, + live_reviewed_at: photo.live_reviewed_at ?? null, + live_rejection_reason: photo.live_rejection_reason ?? null, + live_priority: typeof photo.live_priority === 'number' ? photo.live_priority : null, is_featured: Boolean(photo.is_featured), likes_count: Number(photo.likes_count ?? 0), uploaded_at: photo.uploaded_at, @@ -1489,6 +1499,14 @@ export type GetEventPhotosOptions = { visibility?: 'visible' | 'hidden' | 'all'; }; +export type LiveShowQueueStatus = 'pending' | 'approved' | 'rejected' | 'none' | 'expired' | 'all'; + +export type GetLiveShowQueueOptions = { + page?: number; + perPage?: number; + liveStatus?: LiveShowQueueStatus; +}; + export async function getEventPhotos( slug: string, options: GetEventPhotosOptions = {} @@ -1526,6 +1544,67 @@ export async function getEventPhotos( }; } +export async function getLiveShowQueue( + slug: string, + options: GetLiveShowQueueOptions = {} +): Promise<{ photos: TenantPhoto[]; meta: PaginationMeta }> { + const params = new URLSearchParams(); + if (options.page) params.set('page', String(options.page)); + if (options.perPage) params.set('per_page', String(options.perPage)); + if (options.liveStatus) params.set('live_status', options.liveStatus); + + const response = await authorizedFetch( + `${eventEndpoint(slug)}/live-show/photos${params.toString() ? `?${params.toString()}` : ''}` + ); + const data = await jsonOrThrow<{ + data?: TenantPhoto[]; + meta?: Partial; + current_page?: number; + last_page?: number; + per_page?: number; + total?: number; + }>(response, 'Failed to load live show queue'); + + const meta = buildPagination(data as unknown as JsonValue, options.perPage ?? 20); + + return { + photos: (data.data ?? []).map(normalizePhoto), + meta, + }; +} + +export async function approveLiveShowPhoto( + slug: string, + id: number, + payload: { priority?: number } = {} +): Promise { + const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/photos/${id}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await jsonOrThrow(response, 'Failed to approve live show photo'); + return normalizePhoto(data.data); +} + +export async function rejectLiveShowPhoto(slug: string, id: number, reason?: string): Promise { + const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/photos/${id}/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason }), + }); + const data = await jsonOrThrow(response, 'Failed to reject live show photo'); + return normalizePhoto(data.data); +} + +export async function clearLiveShowPhoto(slug: string, id: number): Promise { + const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/photos/${id}/clear`, { + method: 'POST', + }); + const data = await jsonOrThrow(response, 'Failed to clear live show photo'); + return normalizePhoto(data.data); +} + export async function getEventPhoto(slug: string, id: number): Promise { const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`); const data = await jsonOrThrow(response, 'Failed to load photo'); diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index 086d8e2..15d2017 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -33,3 +33,4 @@ export const ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/guest-notifications`); export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}`); export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/branding`); +export const ADMIN_EVENT_LIVE_SHOW_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show`); diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 347934d..e7aa96a 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -310,6 +310,7 @@ "tasks": "Aufgaben & Checklisten", "qr": "QR-Code-Layouts", "images": "Bildverwaltung", + "liveShow": "Live-Show-Warteschlange", "guests": "Gästeverwaltung", "guestMessages": "Gästebenachrichtigungen", "branding": "Branding & Design", @@ -2142,6 +2143,33 @@ "queueWaiting": "Offline", "syncFailed": "Synchronisierung fehlgeschlagen. Bitte später erneut versuchen." }, + "liveShowQueue": { + "title": "Live-Show-Warteschlange", + "subtitle": "Fotos für die Live-Slideshow freigeben", + "filterLabel": "Live-Status", + "statusPending": "Ausstehend", + "statusApproved": "Freigegeben", + "statusRejected": "Abgelehnt", + "statusNone": "Nicht vorgemerkt", + "status": { + "pending": "Ausstehend", + "approved": "Freigegeben", + "rejected": "Abgelehnt", + "none": "Nicht vorgemerkt" + }, + "galleryApproved": "Galerie freigegeben", + "galleryApprovedOnly": "Hier erscheinen nur bereits freigegebene Galerie-Fotos.", + "offlineNotice": "Du bist offline. Live-Show-Aktionen sind deaktiviert.", + "empty": "Keine Fotos für die Live-Show in der Warteschlange.", + "loadFailed": "Live-Show-Warteschlange konnte nicht geladen werden.", + "approve": "Für Live-Show freigeben", + "reject": "Ablehnen", + "clear": "Aus Live-Show entfernen", + "approveSuccess": "Foto für Live-Show freigegeben", + "rejectSuccess": "Foto aus Live-Show entfernt", + "clearSuccess": "Live-Show-Freigabe entfernt", + "actionFailed": "Live-Show-Aktion fehlgeschlagen." + }, "mobileProfile": { "title": "Profil", "settings": "Einstellungen", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index d13d4f9..59f34c8 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -305,6 +305,7 @@ "tasks": "Tasks & checklists", "qr": "QR code layouts", "images": "Image management", + "liveShow": "Live Show queue", "guests": "Guest management", "guestMessages": "Guest messages", "branding": "Branding & theme", @@ -2146,6 +2147,33 @@ "queueWaiting": "Offline", "syncFailed": "Sync failed. Please try again later." }, + "liveShowQueue": { + "title": "Live Show queue", + "subtitle": "Approve photos for the live slideshow", + "filterLabel": "Live status", + "statusPending": "Pending", + "statusApproved": "Approved", + "statusRejected": "Rejected", + "statusNone": "Not queued", + "status": { + "pending": "Pending", + "approved": "Approved", + "rejected": "Rejected", + "none": "Not queued" + }, + "galleryApproved": "Gallery approved", + "galleryApprovedOnly": "Only gallery-approved photos appear here.", + "offlineNotice": "You are offline. Live Show actions are disabled.", + "empty": "No photos waiting for Live Show.", + "loadFailed": "Live Show queue could not be loaded.", + "approve": "Approve for Live Show", + "reject": "Reject", + "clear": "Remove from Live Show", + "approveSuccess": "Photo approved for Live Show", + "rejectSuccess": "Photo removed from Live Show", + "clearSuccess": "Live Show approval removed", + "actionFailed": "Live Show update failed." + }, "mobileProfile": { "title": "Profile", "settings": "Settings", diff --git a/resources/js/admin/mobile/EventDetailPage.tsx b/resources/js/admin/mobile/EventDetailPage.tsx index e47528a..b5a9c78 100644 --- a/resources/js/admin/mobile/EventDetailPage.tsx +++ b/resources/js/admin/mobile/EventDetailPage.tsx @@ -1,14 +1,14 @@ import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, Pencil, Megaphone } from 'lucide-react'; +import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, Pencil, Megaphone, Tv } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, PillBadge, KpiTile, ActionTile } from './components/Primitives'; import { TenantEvent, EventStats, EventToolkit, getEvent, getEventStats, getEventToolkit, getEvents } from '../api'; -import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants'; +import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_LIVE_SHOW_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import { MobileSheet } from './components/Sheet'; @@ -250,12 +250,20 @@ export default function MobileEventDetailPage() { onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))} delayMs={ADMIN_MOTION.tileStaggerMs * 2} /> + slug && navigate(ADMIN_EVENT_LIVE_SHOW_PATH(slug))} + disabled={!slug} + delayMs={ADMIN_MOTION.tileStaggerMs * 3} + /> navigate(adminPath(`/mobile/events/${slug ?? ''}/members`))} - delayMs={ADMIN_MOTION.tileStaggerMs * 3} + delayMs={ADMIN_MOTION.tileStaggerMs * 4} /> slug && navigate(ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH(slug))} disabled={!slug} - delayMs={ADMIN_MOTION.tileStaggerMs * 4} + delayMs={ADMIN_MOTION.tileStaggerMs * 5} /> navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`)) : undefined } disabled={!brandingAllowed} - delayMs={ADMIN_MOTION.tileStaggerMs * 5} + delayMs={ADMIN_MOTION.tileStaggerMs * 6} /> navigate(adminPath(`/mobile/events/${slug ?? ''}/photobooth`))} - delayMs={ADMIN_MOTION.tileStaggerMs * 6} + delayMs={ADMIN_MOTION.tileStaggerMs * 7} /> {isPastEvent(event?.event_date) ? ( navigate(adminPath(`/mobile/events/${slug ?? ''}/recap`))} - delayMs={ADMIN_MOTION.tileStaggerMs * 7} + delayMs={ADMIN_MOTION.tileStaggerMs * 8} /> ) : null} diff --git a/resources/js/admin/mobile/EventLiveShowQueuePage.tsx b/resources/js/admin/mobile/EventLiveShowQueuePage.tsx new file mode 100644 index 0000000..719aa0e --- /dev/null +++ b/resources/js/admin/mobile/EventLiveShowQueuePage.tsx @@ -0,0 +1,309 @@ +import React from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { RefreshCcw } from 'lucide-react'; +import { YStack, XStack } from '@tamagui/stacks'; +import { SizableText as Text } from '@tamagui/text'; +import { MobileShell, HeaderActionButton } from './components/MobileShell'; +import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; +import { MobileSelect } from './components/FormControls'; +import { useEventContext } from '../context/EventContext'; +import { + approveLiveShowPhoto, + clearLiveShowPhoto, + getEvents, + getLiveShowQueue, + LiveShowQueueStatus, + rejectLiveShowPhoto, + TenantEvent, + TenantPhoto, +} from '../api'; +import { isAuthError } from '../auth/tokens'; +import { getApiErrorMessage } from '../lib/apiError'; +import { adminPath } from '../constants'; +import toast from 'react-hot-toast'; +import { useBackNavigation } from './hooks/useBackNavigation'; +import { useAdminTheme } from './theme'; +import { useOnlineStatus } from './hooks/useOnlineStatus'; + +const STATUS_OPTIONS: Array<{ value: LiveShowQueueStatus; labelKey: string; fallback: string }> = [ + { value: 'pending', labelKey: 'liveShowQueue.statusPending', fallback: 'Pending' }, + { value: 'approved', labelKey: 'liveShowQueue.statusApproved', fallback: 'Approved' }, + { value: 'rejected', labelKey: 'liveShowQueue.statusRejected', fallback: 'Rejected' }, + { value: 'none', labelKey: 'liveShowQueue.statusNone', fallback: 'Not queued' }, +]; + +export default function MobileEventLiveShowQueuePage() { + const { slug: slugParam } = useParams<{ slug?: string }>(); + const navigate = useNavigate(); + const { t } = useTranslation('management'); + const { activeEvent, selectEvent } = useEventContext(); + const slug = slugParam ?? activeEvent?.slug ?? null; + const online = useOnlineStatus(); + const { textStrong, text, muted, border, danger } = useAdminTheme(); + const [photos, setPhotos] = React.useState([]); + const [statusFilter, setStatusFilter] = React.useState('pending'); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(false); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [busyId, setBusyId] = React.useState(null); + const [fallbackAttempted, setFallbackAttempted] = React.useState(false); + const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); + + React.useEffect(() => { + if (slugParam && activeEvent?.slug !== slugParam) { + selectEvent(slugParam); + } + }, [slugParam, activeEvent?.slug, selectEvent]); + + const loadQueue = React.useCallback(async () => { + if (!slug) { + if (!fallbackAttempted) { + setFallbackAttempted(true); + try { + const events = await getEvents({ force: true }); + const first = events[0] as TenantEvent | undefined; + if (first?.slug) { + selectEvent(first.slug); + navigate(adminPath(`/mobile/events/${first.slug}/live-show`), { replace: true }); + } + } catch { + // ignore + } + } + setLoading(false); + setError(t('events.errors.missingSlug', 'No event selected.')); + return; + } + + setLoading(true); + setError(null); + try { + const result = await getLiveShowQueue(slug, { + page, + perPage: 20, + liveStatus: statusFilter, + }); + setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos])); + const lastPage = result.meta?.last_page ?? 1; + setHasMore(page < lastPage); + } catch (err) { + if (!isAuthError(err)) { + const message = getApiErrorMessage(err, t('liveShowQueue.loadFailed', 'Live Show queue could not be loaded.')); + setError(message); + toast.error(message); + } + } finally { + setLoading(false); + } + }, [slug, page, statusFilter, fallbackAttempted, navigate, selectEvent, t]); + + React.useEffect(() => { + setPage(1); + }, [statusFilter]); + + React.useEffect(() => { + void loadQueue(); + }, [loadQueue]); + + async function handleApprove(photo: TenantPhoto) { + if (!slug || busyId) return; + setBusyId(photo.id); + try { + const updated = await approveLiveShowPhoto(slug, photo.id); + setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); + toast.success(t('liveShowQueue.approveSuccess', 'Photo approved for Live Show')); + } catch (err) { + if (!isAuthError(err)) { + toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.')); + } + } finally { + setBusyId(null); + } + } + + async function handleReject(photo: TenantPhoto) { + if (!slug || busyId) return; + setBusyId(photo.id); + try { + const updated = await rejectLiveShowPhoto(slug, photo.id); + setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); + toast.success(t('liveShowQueue.rejectSuccess', 'Photo removed from Live Show')); + } catch (err) { + if (!isAuthError(err)) { + toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.')); + } + } finally { + setBusyId(null); + } + } + + async function handleClear(photo: TenantPhoto) { + if (!slug || busyId) return; + setBusyId(photo.id); + try { + const updated = await clearLiveShowPhoto(slug, photo.id); + setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); + toast.success(t('liveShowQueue.clearSuccess', 'Live Show approval removed')); + } catch (err) { + if (!isAuthError(err)) { + toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.')); + } + } finally { + setBusyId(null); + } + } + + function resolveStatusTone(status?: string | null): 'success' | 'warning' | 'muted' { + if (status === 'approved') return 'success'; + if (status === 'pending') return 'warning'; + return 'muted'; + } + + return ( + loadQueue()} ariaLabel={t('common.refresh', 'Refresh')}> + + + } + > + + + {t('liveShowQueue.galleryApprovedOnly', 'Only gallery-approved photos appear here.')} + + {!online ? ( + + {t('liveShowQueue.offlineNotice', 'You are offline. Live Show actions are disabled.')} + + ) : null} + + + + setStatusFilter(event.target.value as LiveShowQueueStatus)} + > + {STATUS_OPTIONS.map((option) => ( + + ))} + + + + {error ? ( + + + {error} + + + ) : null} + + {loading && page === 1 ? ( + + {Array.from({ length: 4 }).map((_, idx) => ( + + ))} + + ) : photos.length === 0 ? ( + + + {t('liveShowQueue.empty', 'No photos waiting for Live Show.')} + + + ) : ( + + {photos.map((photo) => { + const isBusy = busyId === photo.id; + const liveStatus = photo.live_status ?? 'pending'; + return ( + + + {photo.thumbnail_url ? ( + {photo.original_name + ) : null} + + + + {t('liveShowQueue.galleryApproved', 'Gallery approved')} + + + {t(`liveShowQueue.status.${liveStatus}`, liveStatus)} + + + + {photo.uploaded_at} + + + + + {liveStatus !== 'approved' ? ( + handleApprove(photo)} + disabled={!online} + loading={isBusy} + tone="primary" + /> + ) : ( + handleClear(photo)} + disabled={!online} + loading={isBusy} + tone="ghost" + /> + )} + {liveStatus !== 'rejected' ? ( + handleReject(photo)} + disabled={!online} + loading={isBusy} + tone="danger" + /> + ) : ( + handleClear(photo)} + disabled={!online} + loading={isBusy} + tone="ghost" + /> + )} + + + ); + })} + + )} + + {hasMore ? ( + + setPage((prev) => prev + 1)} + disabled={loading} + /> + + ) : null} + + ); +} diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index 7a66287..28e4293 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -29,6 +29,7 @@ const MobileQrPrintPage = React.lazy(() => import('./mobile/QrPrintPage')); const MobileQrLayoutCustomizePage = React.lazy(() => import('./mobile/QrLayoutCustomizePage')); const MobileEventGuestNotificationsPage = React.lazy(() => import('./mobile/EventGuestNotificationsPage')); const MobileEventPhotosPage = React.lazy(() => import('./mobile/EventPhotosPage')); +const MobileEventLiveShowQueuePage = React.lazy(() => import('./mobile/EventLiveShowQueuePage')); const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage')); const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage')); const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage')); @@ -194,6 +195,7 @@ export const router = createBrowserRouter([ { path: 'mobile/events/:slug/qr', element: }, { path: 'mobile/events/:slug/qr/customize/:tokenId?', element: }, { path: 'mobile/events/:slug/photos/:photoId?', element: }, + { path: 'mobile/events/:slug/live-show', element: }, { path: 'mobile/events/:slug/recap', element: }, { path: 'mobile/events/:slug/members', element: }, { path: 'mobile/events/:slug/tasks', element: }, diff --git a/routes/api.php b/routes/api.php index 240274b..a1bbe40 100644 --- a/routes/api.php +++ b/routes/api.php @@ -20,6 +20,7 @@ use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController; use App\Http\Controllers\Api\Tenant\EventMemberController; use App\Http\Controllers\Api\Tenant\EventTypeController; use App\Http\Controllers\Api\Tenant\FontController; +use App\Http\Controllers\Api\Tenant\LiveShowPhotoController; use App\Http\Controllers\Api\Tenant\NotificationLogController; use App\Http\Controllers\Api\Tenant\OnboardingController; use App\Http\Controllers\Api\Tenant\PhotoboothController; @@ -199,6 +200,16 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::post('guest-notifications', [EventGuestNotificationController::class, 'store'])->name('tenant.events.guest-notifications.store'); Route::post('addons/apply', [EventAddonController::class, 'apply'])->name('tenant.events.addons.apply'); Route::post('addons/checkout', [EventAddonController::class, 'checkout'])->name('tenant.events.addons.checkout'); + + Route::prefix('live-show')->group(function () { + Route::get('photos', [LiveShowPhotoController::class, 'index'])->name('tenant.events.live-show.photos.index'); + Route::post('photos/{photo}/approve', [LiveShowPhotoController::class, 'approve']) + ->name('tenant.events.live-show.photos.approve'); + Route::post('photos/{photo}/reject', [LiveShowPhotoController::class, 'reject']) + ->name('tenant.events.live-show.photos.reject'); + Route::post('photos/{photo}/clear', [LiveShowPhotoController::class, 'clear']) + ->name('tenant.events.live-show.photos.clear'); + }); }); Route::prefix('join-tokens')->group(function () { diff --git a/tests/Feature/LiveShowPhotoControllerTest.php b/tests/Feature/LiveShowPhotoControllerTest.php new file mode 100644 index 0000000..99f77dd --- /dev/null +++ b/tests/Feature/LiveShowPhotoControllerTest.php @@ -0,0 +1,105 @@ +for($this->tenant)->create([ + 'slug' => 'live-show-queue', + ]); + + $pending = Photo::factory()->for($event)->create([ + 'status' => 'approved', + 'live_status' => PhotoLiveStatus::PENDING, + ]); + + Photo::factory()->for($event)->create([ + 'status' => 'approved', + 'live_status' => PhotoLiveStatus::APPROVED, + 'live_approved_at' => now(), + ]); + + $response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/live-show/photos"); + + $response->assertOk(); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.id', $pending->id); + $response->assertJsonPath('data.0.live_status', PhotoLiveStatus::PENDING->value); + } + + public function test_live_show_approve_requires_gallery_approval(): void + { + $event = Event::factory()->for($this->tenant)->create([ + 'slug' => 'live-show-approve-guard', + ]); + + $photo = Photo::factory()->for($event)->create([ + 'status' => 'pending', + 'live_status' => PhotoLiveStatus::PENDING, + ]); + + $response = $this->authenticatedRequest( + 'POST', + "/api/v1/tenant/events/{$event->slug}/live-show/photos/{$photo->id}/approve" + ); + + $response->assertStatus(422); + $response->assertJsonPath('error.code', 'photo_not_approved'); + } + + public function test_live_show_approve_reject_and_clear_workflow(): void + { + $event = Event::factory()->for($this->tenant)->create([ + 'slug' => 'live-show-workflow', + ]); + + $photo = Photo::factory()->for($event)->create([ + 'status' => 'approved', + 'live_status' => PhotoLiveStatus::PENDING, + ]); + + $approve = $this->authenticatedRequest( + 'POST', + "/api/v1/tenant/events/{$event->slug}/live-show/photos/{$photo->id}/approve", + ['priority' => 12] + ); + + $approve->assertOk(); + $this->assertDatabaseHas('photos', [ + 'id' => $photo->id, + 'live_status' => PhotoLiveStatus::APPROVED->value, + 'live_priority' => 12, + ]); + + $reject = $this->authenticatedRequest( + 'POST', + "/api/v1/tenant/events/{$event->slug}/live-show/photos/{$photo->id}/reject", + ['reason' => 'policy'] + ); + + $reject->assertOk(); + $this->assertDatabaseHas('photos', [ + 'id' => $photo->id, + 'live_status' => PhotoLiveStatus::REJECTED->value, + 'live_rejection_reason' => 'policy', + ]); + + $clear = $this->authenticatedRequest( + 'POST', + "/api/v1/tenant/events/{$event->slug}/live-show/photos/{$photo->id}/clear" + ); + + $clear->assertOk(); + $this->assertDatabaseHas('photos', [ + 'id' => $photo->id, + 'live_status' => PhotoLiveStatus::NONE->value, + ]); + } +}