Add live show moderation queue
This commit is contained in:
@@ -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"}
|
||||
|
||||
144
app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php
Normal file
144
app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\LiveShowApproveRequest;
|
||||
use App\Http\Requests\Tenant\LiveShowQueueRequest;
|
||||
use App\Http\Requests\Tenant\LiveShowRejectRequest;
|
||||
use App\Http\Resources\Tenant\PhotoResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LiveShowPhotoController extends Controller
|
||||
{
|
||||
public function index(LiveShowQueueRequest $request, string $eventSlug): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/Tenant/LiveShowApproveRequest.php
Normal file
28
app/Http/Requests/Tenant/LiveShowApproveRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class LiveShowApproveRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'priority' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
34
app/Http/Requests/Tenant/LiveShowQueueRequest.php
Normal file
34
app/Http/Requests/Tenant/LiveShowQueueRequest.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class LiveShowQueueRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/Tenant/LiveShowRejectRequest.php
Normal file
28
app/Http/Requests/Tenant/LiveShowRejectRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class LiveShowRejectRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'reason' => ['nullable', 'string', 'max:64'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<PaginationMeta>;
|
||||
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<TenantPhoto> {
|
||||
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<PhotoResponse>(response, 'Failed to approve live show photo');
|
||||
return normalizePhoto(data.data);
|
||||
}
|
||||
|
||||
export async function rejectLiveShowPhoto(slug: string, id: number, reason?: string): Promise<TenantPhoto> {
|
||||
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<PhotoResponse>(response, 'Failed to reject live show photo');
|
||||
return normalizePhoto(data.data);
|
||||
}
|
||||
|
||||
export async function clearLiveShowPhoto(slug: string, id: number): Promise<TenantPhoto> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/photos/${id}/clear`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to clear live show photo');
|
||||
return normalizePhoto(data.data);
|
||||
}
|
||||
|
||||
export async function getEventPhoto(slug: string, id: number): Promise<TenantPhoto> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`);
|
||||
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to load photo');
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Tv}
|
||||
label={t('events.quick.liveShow', 'Live Show queue')}
|
||||
color={ADMIN_ACTION_COLORS.images}
|
||||
onPress={() => slug && navigate(ADMIN_EVENT_LIVE_SHOW_PATH(slug))}
|
||||
disabled={!slug}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 3}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Users}
|
||||
label={t('events.quick.guests', 'Guest Management')}
|
||||
color={ADMIN_ACTION_COLORS.guests}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/members`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 3}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 4}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Megaphone}
|
||||
@@ -263,7 +271,7 @@ export default function MobileEventDetailPage() {
|
||||
color={ADMIN_ACTION_COLORS.guestMessages}
|
||||
onPress={() => slug && navigate(ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH(slug))}
|
||||
disabled={!slug}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 4}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 5}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Layout}
|
||||
@@ -273,14 +281,14 @@ export default function MobileEventDetailPage() {
|
||||
brandingAllowed ? () => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`)) : undefined
|
||||
}
|
||||
disabled={!brandingAllowed}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 5}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 6}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Camera}
|
||||
label={t('events.quick.photobooth', 'Photobooth')}
|
||||
color={ADMIN_ACTION_COLORS.photobooth}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photobooth`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 6}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 7}
|
||||
/>
|
||||
{isPastEvent(event?.event_date) ? (
|
||||
<ActionTile
|
||||
@@ -288,7 +296,7 @@ export default function MobileEventDetailPage() {
|
||||
label={t('events.quick.recap', 'Recap & Archive')}
|
||||
color={ADMIN_ACTION_COLORS.recap}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/recap`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 7}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 8}
|
||||
/>
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
309
resources/js/admin/mobile/EventLiveShowQueuePage.tsx
Normal file
309
resources/js/admin/mobile/EventLiveShowQueuePage.tsx
Normal file
@@ -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<TenantPhoto[]>([]);
|
||||
const [statusFilter, setStatusFilter] = React.useState<LiveShowQueueStatus>('pending');
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busyId, setBusyId] = React.useState<number | null>(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 (
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={t('liveShowQueue.title', 'Live Show queue')}
|
||||
subtitle={t('liveShowQueue.subtitle', 'Approve photos for the live slideshow')}
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => loadQueue()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
}
|
||||
>
|
||||
<MobileCard borderColor={border} backgroundColor="transparent">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('liveShowQueue.galleryApprovedOnly', 'Only gallery-approved photos appear here.')}
|
||||
</Text>
|
||||
{!online ? (
|
||||
<Text fontSize="$sm" color={danger}>
|
||||
{t('liveShowQueue.offlineNotice', 'You are offline. Live Show actions are disabled.')}
|
||||
</Text>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard>
|
||||
<MobileSelect
|
||||
label={t('liveShowQueue.filterLabel', 'Live status')}
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as LiveShowQueueStatus)}
|
||||
>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</option>
|
||||
))}
|
||||
</MobileSelect>
|
||||
</MobileCard>
|
||||
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
{loading && page === 1 ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<SkeletonCard key={`skeleton-${idx}`} height={120} />
|
||||
))}
|
||||
</YStack>
|
||||
) : photos.length === 0 ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color={text}>
|
||||
{t('liveShowQueue.empty', 'No photos waiting for Live Show.')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
{photos.map((photo) => {
|
||||
const isBusy = busyId === photo.id;
|
||||
const liveStatus = photo.live_status ?? 'pending';
|
||||
return (
|
||||
<MobileCard key={photo.id}>
|
||||
<XStack space="$3" alignItems="center">
|
||||
{photo.thumbnail_url ? (
|
||||
<img
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.original_name ?? 'Photo'}
|
||||
style={{
|
||||
width: 86,
|
||||
height: 86,
|
||||
borderRadius: 14,
|
||||
objectFit: 'cover',
|
||||
border: `1px solid ${border}`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<YStack flex={1} space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<PillBadge tone="success">
|
||||
{t('liveShowQueue.galleryApproved', 'Gallery approved')}
|
||||
</PillBadge>
|
||||
<PillBadge tone={resolveStatusTone(liveStatus)}>
|
||||
{t(`liveShowQueue.status.${liveStatus}`, liveStatus)}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{photo.uploaded_at}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2">
|
||||
{liveStatus !== 'approved' ? (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.approve', 'Approve for Live Show')}
|
||||
onPress={() => handleApprove(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="primary"
|
||||
/>
|
||||
) : (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.clear', 'Remove from Live Show')}
|
||||
onPress={() => handleClear(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="ghost"
|
||||
/>
|
||||
)}
|
||||
{liveStatus !== 'rejected' ? (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.reject', 'Reject')}
|
||||
onPress={() => handleReject(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="danger"
|
||||
/>
|
||||
) : (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.clear', 'Remove from Live Show')}
|
||||
onPress={() => handleClear(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="ghost"
|
||||
/>
|
||||
)}
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
{hasMore ? (
|
||||
<MobileCard>
|
||||
<CTAButton
|
||||
label={t('common.loadMore', 'Load more')}
|
||||
onPress={() => setPage((prev) => prev + 1)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -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: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/qr/customize/:tokenId?', element: <RequireAdminAccess><MobileQrLayoutCustomizePage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/photos/:photoId?', element: <MobileEventPhotosPage /> },
|
||||
{ path: 'mobile/events/:slug/live-show', element: <RequireAdminAccess><MobileEventLiveShowQueuePage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
105
tests/Feature/LiveShowPhotoControllerTest.php
Normal file
105
tests/Feature/LiveShowPhotoControllerTest.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\PhotoLiveStatus;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use Tests\Feature\Tenant\TenantTestCase;
|
||||
|
||||
class LiveShowPhotoControllerTest extends TenantTestCase
|
||||
{
|
||||
public function test_live_show_queue_defaults_to_pending(): void
|
||||
{
|
||||
$event = Event::factory()->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user