Add approve-and-live action for Live Show
This commit is contained in:
@@ -30,7 +30,6 @@ class LiveShowPhotoController extends Controller
|
|||||||
|
|
||||||
$query = Photo::query()
|
$query = Photo::query()
|
||||||
->where('event_id', $event->id)
|
->where('event_id', $event->id)
|
||||||
->where('status', 'approved')
|
|
||||||
->with('event')
|
->with('event')
|
||||||
->withCount('likes');
|
->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
|
public function reject(LiveShowRejectRequest $request, string $eventSlug, Photo $photo): JsonResponse
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|||||||
@@ -1587,6 +1587,20 @@ export async function approveLiveShowPhoto(
|
|||||||
return normalizePhoto(data.data);
|
return normalizePhoto(data.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function approveAndLiveShowPhoto(
|
||||||
|
slug: string,
|
||||||
|
id: number,
|
||||||
|
payload: { priority?: number } = {}
|
||||||
|
): Promise<TenantPhoto> {
|
||||||
|
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<PhotoResponse>(response, 'Failed to approve and add live show photo');
|
||||||
|
return normalizePhoto(data.data);
|
||||||
|
}
|
||||||
|
|
||||||
export async function rejectLiveShowPhoto(slug: string, id: number, reason?: string): Promise<TenantPhoto> {
|
export async function rejectLiveShowPhoto(slug: string, id: number, reason?: string): Promise<TenantPhoto> {
|
||||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/photos/${id}/reject`, {
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/photos/${id}/reject`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -2158,16 +2158,25 @@
|
|||||||
"none": "Nicht vorgemerkt"
|
"none": "Nicht vorgemerkt"
|
||||||
},
|
},
|
||||||
"galleryApproved": "Galerie freigegeben",
|
"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.",
|
"offlineNotice": "Du bist offline. Live-Show-Aktionen sind deaktiviert.",
|
||||||
"empty": "Keine Fotos für die Live-Show in der Warteschlange.",
|
"empty": "Keine Fotos für die Live-Show in der Warteschlange.",
|
||||||
"loadFailed": "Live-Show-Warteschlange konnte nicht geladen werden.",
|
"loadFailed": "Live-Show-Warteschlange konnte nicht geladen werden.",
|
||||||
"approve": "Für Live-Show freigeben",
|
"approve": "Für Live-Show freigeben",
|
||||||
|
"approveAndLive": "Freigeben + Live",
|
||||||
"reject": "Ablehnen",
|
"reject": "Ablehnen",
|
||||||
"clear": "Aus Live-Show entfernen",
|
"clear": "Aus Live-Show entfernen",
|
||||||
"approveSuccess": "Foto für Live-Show freigegeben",
|
"approveSuccess": "Foto für Live-Show freigegeben",
|
||||||
|
"approveAndLiveSuccess": "Foto freigegeben und zur Live-Show hinzugefügt",
|
||||||
"rejectSuccess": "Foto aus Live-Show entfernt",
|
"rejectSuccess": "Foto aus Live-Show entfernt",
|
||||||
"clearSuccess": "Live-Show-Freigabe entfernt",
|
"clearSuccess": "Live-Show-Freigabe entfernt",
|
||||||
|
"notEligible": "Nicht zulässig",
|
||||||
"actionFailed": "Live-Show-Aktion fehlgeschlagen."
|
"actionFailed": "Live-Show-Aktion fehlgeschlagen."
|
||||||
},
|
},
|
||||||
"mobileProfile": {
|
"mobileProfile": {
|
||||||
|
|||||||
@@ -2162,16 +2162,25 @@
|
|||||||
"none": "Not queued"
|
"none": "Not queued"
|
||||||
},
|
},
|
||||||
"galleryApproved": "Gallery approved",
|
"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.",
|
"offlineNotice": "You are offline. Live Show actions are disabled.",
|
||||||
"empty": "No photos waiting for Live Show.",
|
"empty": "No photos waiting for Live Show.",
|
||||||
"loadFailed": "Live Show queue could not be loaded.",
|
"loadFailed": "Live Show queue could not be loaded.",
|
||||||
"approve": "Approve for Live Show",
|
"approve": "Approve for Live Show",
|
||||||
|
"approveAndLive": "Approve + Live",
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
"clear": "Remove from Live Show",
|
"clear": "Remove from Live Show",
|
||||||
"approveSuccess": "Photo approved for Live Show",
|
"approveSuccess": "Photo approved for Live Show",
|
||||||
|
"approveAndLiveSuccess": "Photo approved and added to Live Show",
|
||||||
"rejectSuccess": "Photo removed from Live Show",
|
"rejectSuccess": "Photo removed from Live Show",
|
||||||
"clearSuccess": "Live Show approval removed",
|
"clearSuccess": "Live Show approval removed",
|
||||||
|
"notEligible": "Not eligible",
|
||||||
"actionFailed": "Live Show update failed."
|
"actionFailed": "Live Show update failed."
|
||||||
},
|
},
|
||||||
"mobileProfile": {
|
"mobileProfile": {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Pri
|
|||||||
import { MobileSelect } from './components/FormControls';
|
import { MobileSelect } from './components/FormControls';
|
||||||
import { useEventContext } from '../context/EventContext';
|
import { useEventContext } from '../context/EventContext';
|
||||||
import {
|
import {
|
||||||
|
approveAndLiveShowPhoto,
|
||||||
approveLiveShowPhoto,
|
approveLiveShowPhoto,
|
||||||
clearLiveShowPhoto,
|
clearLiveShowPhoto,
|
||||||
getEvents,
|
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) {
|
async function handleReject(photo: TenantPhoto) {
|
||||||
if (!slug || busyId) return;
|
if (!slug || busyId) return;
|
||||||
setBusyId(photo.id);
|
setBusyId(photo.id);
|
||||||
@@ -161,6 +178,17 @@ export default function MobileEventLiveShowQueuePage() {
|
|||||||
return 'muted';
|
return 'muted';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveGalleryLabel(status?: string | null): string {
|
||||||
|
const fallbackMap: Record<string, string> = {
|
||||||
|
approved: 'Gallery approved',
|
||||||
|
pending: 'Gallery pending',
|
||||||
|
rejected: 'Gallery rejected',
|
||||||
|
hidden: 'Hidden',
|
||||||
|
};
|
||||||
|
const key = status ?? 'pending';
|
||||||
|
return t(`liveShowQueue.galleryStatus.${key}`, fallbackMap[key] ?? key);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
activeTab="home"
|
activeTab="home"
|
||||||
@@ -175,7 +203,10 @@ export default function MobileEventLiveShowQueuePage() {
|
|||||||
>
|
>
|
||||||
<MobileCard borderColor={border} backgroundColor="transparent">
|
<MobileCard borderColor={border} backgroundColor="transparent">
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{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.'
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
{!online ? (
|
{!online ? (
|
||||||
<Text fontSize="$sm" color={danger}>
|
<Text fontSize="$sm" color={danger}>
|
||||||
@@ -223,6 +254,11 @@ export default function MobileEventLiveShowQueuePage() {
|
|||||||
{photos.map((photo) => {
|
{photos.map((photo) => {
|
||||||
const isBusy = busyId === photo.id;
|
const isBusy = busyId === photo.id;
|
||||||
const liveStatus = photo.live_status ?? 'pending';
|
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 (
|
return (
|
||||||
<MobileCard key={photo.id}>
|
<MobileCard key={photo.id}>
|
||||||
<XStack space="$3" alignItems="center">
|
<XStack space="$3" alignItems="center">
|
||||||
@@ -241,8 +277,8 @@ export default function MobileEventLiveShowQueuePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
<YStack flex={1} space="$2">
|
<YStack flex={1} space="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<PillBadge tone="success">
|
<PillBadge tone={resolveStatusTone(galleryStatus)}>
|
||||||
{t('liveShowQueue.galleryApproved', 'Gallery approved')}
|
{resolveGalleryLabel(galleryStatus)}
|
||||||
</PillBadge>
|
</PillBadge>
|
||||||
<PillBadge tone={resolveStatusTone(liveStatus)}>
|
<PillBadge tone={resolveStatusTone(liveStatus)}>
|
||||||
{t(`liveShowQueue.status.${liveStatus}`, liveStatus)}
|
{t(`liveShowQueue.status.${liveStatus}`, liveStatus)}
|
||||||
@@ -254,11 +290,25 @@ export default function MobileEventLiveShowQueuePage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack space="$2" marginTop="$2">
|
<XStack space="$2" marginTop="$2">
|
||||||
{liveStatus !== 'approved' ? (
|
{showApproveAction ? (
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('liveShowQueue.approve', 'Approve for Live Show')}
|
label={
|
||||||
onPress={() => handleApprove(photo)}
|
canApproveGallery
|
||||||
disabled={!online}
|
? 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}
|
loading={isBusy}
|
||||||
tone="primary"
|
tone="primary"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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::get('photos', [LiveShowPhotoController::class, 'index'])->name('tenant.events.live-show.photos.index');
|
||||||
Route::post('photos/{photo}/approve', [LiveShowPhotoController::class, 'approve'])
|
Route::post('photos/{photo}/approve', [LiveShowPhotoController::class, 'approve'])
|
||||||
->name('tenant.events.live-show.photos.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'])
|
Route::post('photos/{photo}/reject', [LiveShowPhotoController::class, 'reject'])
|
||||||
->name('tenant.events.live-show.photos.reject');
|
->name('tenant.events.live-show.photos.reject');
|
||||||
Route::post('photos/{photo}/clear', [LiveShowPhotoController::class, 'clear'])
|
Route::post('photos/{photo}/clear', [LiveShowPhotoController::class, 'clear'])
|
||||||
|
|||||||
@@ -54,6 +54,33 @@ class LiveShowPhotoControllerTest extends TenantTestCase
|
|||||||
$response->assertJsonPath('error.code', 'photo_not_approved');
|
$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
|
public function test_live_show_approve_reject_and_clear_workflow(): void
|
||||||
{
|
{
|
||||||
$event = Event::factory()->for($this->tenant)->create([
|
$event = Event::factory()->for($this->tenant)->create([
|
||||||
|
|||||||
Reference in New Issue
Block a user