diff --git a/app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php b/app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php index a3e9758..f2e526c 100644 --- a/app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php +++ b/app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php @@ -30,7 +30,6 @@ class LiveShowPhotoController extends Controller $query = Photo::query() ->where('event_id', $event->id) - ->where('status', 'approved') ->with('event') ->withCount('likes'); @@ -89,6 +88,58 @@ class LiveShowPhotoController extends Controller ]); } + public function approveAndLive(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 (in_array($photo->status, ['rejected', 'hidden'], true)) { + return ApiError::response( + 'photo_not_eligible', + 'Photo not eligible', + 'Rejected or hidden photos cannot be approved for Live Show.', + Response::HTTP_UNPROCESSABLE_ENTITY, + ['photo_id' => $photo->id] + ); + } + + if ($photo->status !== 'approved') { + $photo->forceFill([ + 'status' => 'approved', + 'moderated_at' => now(), + 'moderated_by' => $request->user()?->id, + 'moderation_notes' => null, + ])->save(); + } + + $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 and added to Live Show', + 'data' => new PhotoResource($photo), + ]); + } + public function reject(LiveShowRejectRequest $request, string $eventSlug, Photo $photo): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index d94ed0b..54d41de 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -1587,6 +1587,20 @@ export async function approveLiveShowPhoto( return normalizePhoto(data.data); } +export async function approveAndLiveShowPhoto( + slug: string, + id: number, + payload: { priority?: number } = {} +): Promise { + const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/photos/${id}/approve-and-live`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await jsonOrThrow(response, 'Failed to approve and add 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', diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index e7aa96a..8fa5aad 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -2158,16 +2158,25 @@ "none": "Nicht vorgemerkt" }, "galleryApproved": "Galerie freigegeben", - "galleryApprovedOnly": "Hier erscheinen nur bereits freigegebene Galerie-Fotos.", + "galleryApprovedOnly": "Galerie- und Live-Show-Freigaben sind getrennt. Ausstehende Fotos können hier freigegeben werden.", + "galleryStatus": { + "approved": "Galerie freigegeben", + "pending": "Galerie ausstehend", + "rejected": "Galerie abgelehnt", + "hidden": "Versteckt" + }, "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", + "approveAndLive": "Freigeben + Live", "reject": "Ablehnen", "clear": "Aus Live-Show entfernen", "approveSuccess": "Foto für Live-Show freigegeben", + "approveAndLiveSuccess": "Foto freigegeben und zur Live-Show hinzugefügt", "rejectSuccess": "Foto aus Live-Show entfernt", "clearSuccess": "Live-Show-Freigabe entfernt", + "notEligible": "Nicht zulässig", "actionFailed": "Live-Show-Aktion fehlgeschlagen." }, "mobileProfile": { diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 59f34c8..4347abe 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -2162,16 +2162,25 @@ "none": "Not queued" }, "galleryApproved": "Gallery approved", - "galleryApprovedOnly": "Only gallery-approved photos appear here.", + "galleryApprovedOnly": "Gallery and Live Show approvals are separate. Pending photos can be approved here.", + "galleryStatus": { + "approved": "Gallery approved", + "pending": "Gallery pending", + "rejected": "Gallery rejected", + "hidden": "Hidden" + }, "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", + "approveAndLive": "Approve + Live", "reject": "Reject", "clear": "Remove from Live Show", "approveSuccess": "Photo approved for Live Show", + "approveAndLiveSuccess": "Photo approved and added to Live Show", "rejectSuccess": "Photo removed from Live Show", "clearSuccess": "Live Show approval removed", + "notEligible": "Not eligible", "actionFailed": "Live Show update failed." }, "mobileProfile": { diff --git a/resources/js/admin/mobile/EventLiveShowQueuePage.tsx b/resources/js/admin/mobile/EventLiveShowQueuePage.tsx index 719aa0e..55a6a57 100644 --- a/resources/js/admin/mobile/EventLiveShowQueuePage.tsx +++ b/resources/js/admin/mobile/EventLiveShowQueuePage.tsx @@ -9,6 +9,7 @@ import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Pri import { MobileSelect } from './components/FormControls'; import { useEventContext } from '../context/EventContext'; import { + approveAndLiveShowPhoto, approveLiveShowPhoto, clearLiveShowPhoto, getEvents, @@ -123,6 +124,22 @@ export default function MobileEventLiveShowQueuePage() { } } + async function handleApproveAndLive(photo: TenantPhoto) { + if (!slug || busyId) return; + setBusyId(photo.id); + try { + const updated = await approveAndLiveShowPhoto(slug, photo.id); + setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); + toast.success(t('liveShowQueue.approveAndLiveSuccess', 'Photo approved and added to 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); @@ -161,6 +178,17 @@ export default function MobileEventLiveShowQueuePage() { return 'muted'; } + function resolveGalleryLabel(status?: string | null): string { + const fallbackMap: Record = { + approved: 'Gallery approved', + pending: 'Gallery pending', + rejected: 'Gallery rejected', + hidden: 'Hidden', + }; + const key = status ?? 'pending'; + return t(`liveShowQueue.galleryStatus.${key}`, fallbackMap[key] ?? key); + } + return ( - {t('liveShowQueue.galleryApprovedOnly', 'Only gallery-approved photos appear here.')} + {t( + 'liveShowQueue.galleryApprovedOnly', + 'Gallery and Live Show approvals are separate. Pending photos can be approved here.' + )} {!online ? ( @@ -223,6 +254,11 @@ export default function MobileEventLiveShowQueuePage() { {photos.map((photo) => { const isBusy = busyId === photo.id; const liveStatus = photo.live_status ?? 'pending'; + const galleryStatus = photo.status ?? 'pending'; + const canApproveGallery = galleryStatus === 'pending'; + const canApproveLiveOnly = galleryStatus === 'approved'; + const canApproveLive = canApproveGallery || canApproveLiveOnly; + const showApproveAction = liveStatus !== 'approved'; return ( @@ -241,8 +277,8 @@ export default function MobileEventLiveShowQueuePage() { ) : null} - - {t('liveShowQueue.galleryApproved', 'Gallery approved')} + + {resolveGalleryLabel(galleryStatus)} {t(`liveShowQueue.status.${liveStatus}`, liveStatus)} @@ -254,11 +290,25 @@ export default function MobileEventLiveShowQueuePage() { - {liveStatus !== 'approved' ? ( + {showApproveAction ? ( handleApprove(photo)} - disabled={!online} + label={ + canApproveGallery + ? t('liveShowQueue.approveAndLive', 'Approve + Live') + : canApproveLiveOnly + ? t('liveShowQueue.approve', 'Approve for Live Show') + : t('liveShowQueue.notEligible', 'Not eligible') + } + onPress={() => { + if (canApproveGallery) { + void handleApproveAndLive(photo); + return; + } + if (canApproveLiveOnly) { + void handleApprove(photo); + } + }} + disabled={!online || !canApproveLive} loading={isBusy} tone="primary" /> diff --git a/routes/api.php b/routes/api.php index a1bbe40..033b5b4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -205,6 +205,8 @@ Route::prefix('v1')->name('api.v1.')->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}/approve-and-live', [LiveShowPhotoController::class, 'approveAndLive']) + ->name('tenant.events.live-show.photos.approve-and-live'); Route::post('photos/{photo}/reject', [LiveShowPhotoController::class, 'reject']) ->name('tenant.events.live-show.photos.reject'); Route::post('photos/{photo}/clear', [LiveShowPhotoController::class, 'clear']) diff --git a/tests/Feature/LiveShowPhotoControllerTest.php b/tests/Feature/LiveShowPhotoControllerTest.php index 99f77dd..19cbbae 100644 --- a/tests/Feature/LiveShowPhotoControllerTest.php +++ b/tests/Feature/LiveShowPhotoControllerTest.php @@ -54,6 +54,33 @@ class LiveShowPhotoControllerTest extends TenantTestCase $response->assertJsonPath('error.code', 'photo_not_approved'); } + public function test_live_show_approve_and_live_updates_gallery_and_live_status(): void + { + $event = Event::factory()->for($this->tenant)->create([ + 'slug' => 'live-show-approve-live', + ]); + + $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-and-live", + ['priority' => 9] + ); + + $response->assertOk(); + $this->assertDatabaseHas('photos', [ + 'id' => $photo->id, + 'status' => 'approved', + 'moderated_by' => $this->tenantUser->id, + 'live_status' => PhotoLiveStatus::APPROVED->value, + 'live_priority' => 9, + ]); + } + public function test_live_show_approve_reject_and_clear_workflow(): void { $event = Event::factory()->for($this->tenant)->create([