diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index d857f05..8e74508 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -1233,6 +1233,24 @@ class EventPublicController extends BaseController return $this->makeSignedGalleryAssetUrlForId($token, (int) $photo->id, $variant); } + private function makeSignedPendingAssetUrl(string $token, int $photoId, string $variant, string $deviceId): ?string + { + if (! in_array($variant, ['thumbnail', 'full'], true)) { + return null; + } + + return URL::temporarySignedRoute( + 'api.v1.events.pending-photos.asset', + now()->addSeconds(self::SIGNED_URL_TTL_SECONDS), + [ + 'token' => $token, + 'photo' => $photoId, + 'variant' => $variant, + 'device_id' => $deviceId, + ] + ); + } + private function makeSignedBrandingUrl(?string $path): ?string { if (! $path) { @@ -1488,6 +1506,53 @@ class EventPublicController extends BaseController ]); } + public function pendingUploads(Request $request, string $token) + { + $result = $this->resolvePublishedEvent($request, $token, ['id']); + + if ($result instanceof JsonResponse) { + return $result; + } + + [$event] = $result; + $deviceId = $this->resolveDeviceIdentifier($request); + + if ($deviceId === 'anonymous') { + return response()->json([ + 'data' => [], + 'meta' => ['total_count' => 0], + ])->header('Cache-Control', 'no-store'); + } + + $limit = (int) $request->query('limit', 12); + $limit = max(1, min($limit, 30)); + + $baseQuery = Photo::query() + ->where('event_id', $event->id) + ->where('status', 'pending') + ->where('created_by_device_id', $deviceId); + + $totalCount = (clone $baseQuery)->count(); + + $photos = $baseQuery + ->orderByDesc('created_at') + ->limit($limit) + ->get(['id', 'created_at', 'status']); + + $data = $photos->map(fn (Photo $photo) => [ + 'id' => $photo->id, + 'status' => $photo->status, + 'created_at' => $photo->created_at?->toIso8601String(), + 'thumbnail_url' => $this->makeSignedPendingAssetUrl($token, (int) $photo->id, 'thumbnail', $deviceId), + 'full_url' => $this->makeSignedPendingAssetUrl($token, (int) $photo->id, 'full', $deviceId), + ])->all(); + + return response()->json([ + 'data' => $data, + 'meta' => ['total_count' => $totalCount], + ])->header('Cache-Control', 'no-store'); + } + public function createShareLink(Request $request, string $token, Photo $photo) { $resolved = $this->resolvePublishedEvent($request, $token, ['id']); @@ -1699,6 +1764,53 @@ class EventPublicController extends BaseController return $this->streamGalleryPhoto($event, $record, $variantPreference, 'inline'); } + public function pendingPhotoAsset(Request $request, string $token, int $photo, string $variant) + { + $resolved = $this->resolveGalleryEvent($request, $token); + + if ($resolved instanceof JsonResponse) { + return $resolved; + } + + [$event] = $resolved; + $deviceId = $this->normalizeGuestIdentifier((string) $request->query('device_id', '')); + + if ($deviceId === '') { + return ApiError::response( + 'pending_photo_forbidden', + 'Pending Photo Access Denied', + 'The pending photo cannot be accessed.', + Response::HTTP_FORBIDDEN + ); + } + + $record = Photo::with('mediaAsset') + ->where('id', $photo) + ->where('event_id', $event->id) + ->where('status', 'pending') + ->where('created_by_device_id', $deviceId) + ->first(); + + if (! $record) { + return ApiError::response( + 'photo_not_found', + 'Photo Not Found', + 'The requested photo is no longer available.', + Response::HTTP_NOT_FOUND, + [ + 'photo_id' => $photo, + 'event_id' => $event->id, + ] + ); + } + + $variantPreference = $variant === 'thumbnail' + ? ['thumbnail', 'original'] + : ['original']; + + return $this->streamGalleryPhoto($event, $record, $variantPreference, 'inline'); + } + public function galleryPhotoDownload(Request $request, string $token, int $photo) { $resolved = $this->resolveGalleryEvent($request, $token); @@ -2897,6 +3009,7 @@ class EventPublicController extends BaseController 'tenant_id' => $tenantModel->id, 'task_id' => $validated['task_id'] ?? null, 'guest_name' => $validated['guest_name'] ?? $deviceId, + 'created_by_device_id' => $deviceId !== 'anonymous' ? $deviceId : null, 'file_path' => $url, 'thumbnail_path' => $thumbUrl, 'likes_count' => 0, diff --git a/database/migrations/2025_12_20_203307_add_created_by_device_id_to_photos_table.php b/database/migrations/2025_12_20_203307_add_created_by_device_id_to_photos_table.php new file mode 100644 index 0000000..b870d53 --- /dev/null +++ b/database/migrations/2025_12_20_203307_add_created_by_device_id_to_photos_table.php @@ -0,0 +1,30 @@ +string('created_by_device_id', 120)->nullable()->after('guest_name'); + $table->index(['event_id', 'created_by_device_id', 'status'], 'photos_event_device_status_index'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('photos', function (Blueprint $table) { + $table->dropIndex('photos_event_device_status_index'); + $table->dropColumn('created_by_device_id'); + }); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index 9b6e16d..3b60cb5 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -544,8 +544,14 @@ h4, .guest-immersive .guest-bottom-nav { display: flex !important; + opacity: 0 !important; + transform: translateY(12px) !important; + pointer-events: none !important; +} + +.guest-immersive.guest-nav-visible .guest-bottom-nav { opacity: 1 !important; - transform: none !important; + transform: translateY(0) !important; pointer-events: auto !important; } diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index 130249d..2dbd8a0 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -10,6 +10,7 @@ import { Camera, Bell, ArrowUpRight, + Clock, MessageSquare, Sparkles, LifeBuoy, @@ -275,7 +276,7 @@ type NotificationButtonProps = { type PushState = ReturnType; function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, taskProgress, t }: NotificationButtonProps) { - const badgeCount = center.unreadCount; + const badgeCount = center.unreadCount + center.pendingCount + center.queueCount; const progressRatio = taskProgress ? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET) : 0; @@ -428,24 +429,37 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt )} {activeTab === 'status' && ( -
-
- - {t('header.notifications.queueLabel', 'Uploads in Warteschlange')} - {center.queueCount} -
- { - if (center.unreadCount > 0) { - void center.refresh(); - } - }} - > - {t('header.notifications.queueCta', 'Verlauf')} - - +
+ {center.pendingCount > 0 && ( +
+
+ + {t('header.notifications.pendingLabel', 'Uploads in Prüfung')} + {center.pendingCount} +
+ { + if (center.unreadCount > 0) { + void center.refresh(); + } + }} + > + {t('header.notifications.pendingCta', 'Details')} + + +
+ )} + {center.queueCount > 0 && ( +
+
+ + {t('header.notifications.queueLabel', 'Upload-Warteschlange (offline)')} + {center.queueCount} +
+
+ )}
)} {taskProgress && ( diff --git a/resources/js/guest/components/__tests__/HeaderNotificationToggle.test.tsx b/resources/js/guest/components/__tests__/HeaderNotificationToggle.test.tsx index 1f265a4..ee53e39 100644 --- a/resources/js/guest/components/__tests__/HeaderNotificationToggle.test.tsx +++ b/resources/js/guest/components/__tests__/HeaderNotificationToggle.test.tsx @@ -36,8 +36,10 @@ vi.mock('../../context/NotificationCenterContext', () => ({ unreadCount: 0, queueItems: [], queueCount: 0, + pendingCount: 0, totalCount: 0, loading: false, + pendingLoading: false, refresh: vi.fn(), setFilters: vi.fn(), markAsRead: vi.fn(), diff --git a/resources/js/guest/context/NotificationCenterContext.tsx b/resources/js/guest/context/NotificationCenterContext.tsx index 4a6cb80..74d6c38 100644 --- a/resources/js/guest/context/NotificationCenterContext.tsx +++ b/resources/js/guest/context/NotificationCenterContext.tsx @@ -7,14 +7,17 @@ import { markGuestNotificationRead, type GuestNotificationItem, } from '../services/notificationApi'; +import { fetchPendingUploadsSummary } from '../services/pendingUploadsApi'; export type NotificationCenterValue = { notifications: GuestNotificationItem[]; unreadCount: number; queueItems: QueueItem[]; queueCount: number; + pendingCount: number; totalCount: number; loading: boolean; + pendingLoading: boolean; refresh: () => Promise; setFilters: (filters: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => void; markAsRead: (id: number) => Promise; @@ -31,6 +34,8 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke const [notifications, setNotifications] = React.useState([]); const [unreadCount, setUnreadCount] = React.useState(0); const [loadingNotifications, setLoadingNotifications] = React.useState(true); + const [pendingCount, setPendingCount] = React.useState(0); + const [pendingLoading, setPendingLoading] = React.useState(true); const [filters, setFiltersState] = React.useState<{ status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }>({ status: 'new', scope: 'all', @@ -95,19 +100,40 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke [eventToken] ); + const loadPendingUploads = React.useCallback(async () => { + if (!eventToken) { + setPendingLoading(false); + return; + } + + try { + setPendingLoading(true); + const result = await fetchPendingUploadsSummary(eventToken, 1); + setPendingCount(result.totalCount); + } catch (error) { + console.error('Failed to load pending uploads', error); + setPendingCount(0); + } finally { + setPendingLoading(false); + } + }, [eventToken]); + React.useEffect(() => { setNotifications([]); setUnreadCount(0); etagRef.current = null; + setPendingCount(0); if (!eventToken) { setLoadingNotifications(false); + setPendingLoading(false); return; } setLoadingNotifications(true); void loadNotifications(); - }, [eventToken, loadNotifications]); + void loadPendingUploads(); + }, [eventToken, loadNotifications, loadPendingUploads]); React.useEffect(() => { if (!eventToken) { @@ -116,10 +142,11 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke const interval = window.setInterval(() => { void loadNotifications({ silent: true }); + void loadPendingUploads(); }, 90000); return () => window.clearInterval(interval); - }, [eventToken, loadNotifications]); + }, [eventToken, loadNotifications, loadPendingUploads]); React.useEffect(() => { const handleOnline = () => setIsOffline(false); @@ -232,19 +259,21 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke }, [loadNotifications]); const refresh = React.useCallback(async () => { - await Promise.all([loadNotifications(), refreshQueue()]); - }, [loadNotifications, refreshQueue]); + await Promise.all([loadNotifications(), refreshQueue(), loadPendingUploads()]); + }, [loadNotifications, refreshQueue, loadPendingUploads]); - const loading = loadingNotifications || queueLoading; - const totalCount = unreadCount + queueCount; + const loading = loadingNotifications || queueLoading || pendingLoading; + const totalCount = unreadCount + queueCount + pendingCount; const value: NotificationCenterValue = { notifications, unreadCount, queueItems: items, queueCount, + pendingCount, totalCount, loading, + pendingLoading, refresh, setFilters, markAsRead, diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index 4c67403..b48854f 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -370,6 +370,23 @@ export const messages: Record = { title: 'Uploads', description: 'Warteschlange mit Fortschritt und erneuten Versuchen; Hintergrund-Sync umschalten.', }, + pendingUploads: { + title: 'Uploads in Prüfung', + subtitle: 'Deine Fotos warten noch auf die Freigabe.', + successTitle: 'Upload gespeichert', + successBody: 'Dein Foto ist hochgeladen und wartet auf die Freigabe.', + emptyTitle: 'Keine wartenden Uploads', + emptyBody: 'Wenn du ein Foto hochlädst, erscheint es hier bis zur Freigabe.', + cta: 'Weiteres Foto aufnehmen', + refresh: 'Aktualisieren', + loading: 'Lade Uploads...', + error: 'Laden fehlgeschlagen. Bitte versuche es erneut.', + card: { + pending: 'Wartet auf Freigabe', + uploadedAt: 'Hochgeladen {time}', + justNow: 'Gerade eben', + }, + }, lightbox: { taskLabel: 'Aufgabe', loadingTask: 'Lade Aufgabe...', @@ -1033,6 +1050,23 @@ export const messages: Record = { title: 'Uploads', description: 'Queue with progress/retry and background sync toggle.', }, + pendingUploads: { + title: 'Pending uploads', + subtitle: 'Your photos are waiting for approval.', + successTitle: 'Upload saved', + successBody: 'Your photo is uploaded and waiting for approval.', + emptyTitle: 'No pending uploads', + emptyBody: 'Once you upload a photo, it will appear here until it is approved.', + cta: 'Take another photo', + refresh: 'Refresh', + loading: 'Loading uploads...', + error: 'Failed to load uploads. Please try again.', + card: { + pending: 'Waiting for approval', + uploadedAt: 'Uploaded {time}', + justNow: 'Just now', + }, + }, lightbox: { taskLabel: 'Task', loadingTask: 'Loading task...', diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index bd95f37..47ba4cd 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -192,7 +192,6 @@ const [canUpload, setCanUpload] = useState(true); if (typeof document === 'undefined') return undefined; const className = 'guest-immersive'; document.body.classList.add(className); - document.body.classList.add('guest-nav-visible'); // show nav by default on upload page return () => { document.body.classList.remove(className); @@ -227,8 +226,8 @@ const [canUpload, setCanUpload] = useState(true); if (typeof document === 'undefined') { return; } - // nav is always visible on upload page unless user explicitly toggles immersive off via button - document.body.classList.add('guest-nav-visible'); + const shouldShow = typeof window !== 'undefined' && window.scrollY > 24; + document.body.classList.toggle('guest-nav-visible', shouldShow); }, []); useEffect(() => { @@ -236,11 +235,12 @@ const [canUpload, setCanUpload] = useState(true); return; } - // ensure nav remains visible; hide only when immersive toggled off via the menu button updateNavVisibility(); + window.addEventListener('scroll', updateNavVisibility, { passive: true }); return () => { document.body.classList.remove('guest-nav-visible'); + window.removeEventListener('scroll', updateNavVisibility); }; }, [updateNavVisibility]); @@ -721,9 +721,10 @@ const [canUpload, setCanUpload] = useState(true); if (task?.id) params.set('task', String(task.id)); if (photoId) params.set('photo', String(photoId)); if (emotionSlug) params.set('emotion', emotionSlug); - navigate(`/e/${encodeURIComponent(eventKey)}/gallery?${params.toString()}`); + const target = uploadsRequireApproval ? 'queue' : 'gallery'; + navigate(`/e/${encodeURIComponent(eventKey)}/${target}?${params.toString()}`); }, - [emotionSlug, navigate, eventKey, task?.id] + [emotionSlug, navigate, eventKey, task?.id, uploadsRequireApproval] ); const handleUsePhoto = useCallback(async () => { @@ -1009,39 +1010,11 @@ const [canUpload, setCanUpload] = useState(true);

Bereit für dein Foto?

Teile den Moment mit allen Gästen.

-

Zieh eine Mission oder starte direkt.

Live
-
- - - - - Mini-Mission: Fang ein Lachen ein - -
) : null; @@ -1228,12 +1201,6 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[ > {taskFloatingCard} {heroOverlay} - {uploadsRequireApproval ? ( -
-

{t('upload.review.noticeTitle', 'Uploads werden geprüft')}

-

{t('upload.review.noticeBody', 'Dein Foto erscheint, sobald es freigegeben wurde.')}

-
- ) : null}
- - +
+ {uploadsRequireApproval ? ( +
+

{t('upload.review.noticeTitle', 'Uploads werden geprüft')}

+

{t('upload.review.noticeBody', 'Dein Foto erscheint, sobald es freigegeben wurde.')}

+
+ ) : null} +
+ + +
) : (
diff --git a/resources/js/guest/pages/UploadQueuePage.tsx b/resources/js/guest/pages/UploadQueuePage.tsx index f2dec3d..4898496 100644 --- a/resources/js/guest/pages/UploadQueuePage.tsx +++ b/resources/js/guest/pages/UploadQueuePage.tsx @@ -1,12 +1,153 @@ -import React from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Page } from './_util'; import { useTranslation } from '../i18n/useTranslation'; +import { fetchPendingUploadsSummary, type PendingUpload } from '../services/pendingUploadsApi'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Image as ImageIcon, Loader2, RefreshCcw } from 'lucide-react'; +import { useEventBranding } from '../context/EventBrandingContext'; export default function UploadQueuePage() { - const { t } = useTranslation(); + const { t, locale } = useTranslation(); + const { token } = useParams<{ token?: string }>(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { branding } = useEventBranding(); + const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; + const [pending, setPending] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const showSuccess = searchParams.get('uploaded') === 'true'; + const buttonStyle = branding.buttons?.style ?? 'filled'; + const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor; + const radius = branding.buttons?.radius ?? 12; + + const formatter = useMemo( + () => new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }), + [locale], + ); + + const formatTimestamp = useCallback((value?: string | null) => { + if (!value) { + return t('pendingUploads.card.justNow'); + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return t('pendingUploads.card.justNow'); + } + return formatter.format(date); + }, [formatter, t]); + + const loadPendingUploads = useCallback(async () => { + if (!token) return; + try { + setLoading(true); + setError(null); + const result = await fetchPendingUploadsSummary(token, 12); + setPending(result.items); + } catch (err) { + console.error('Pending uploads load failed', err); + setError(t('pendingUploads.error')); + } finally { + setLoading(false); + } + }, [t, token]); + + useEffect(() => { + if (!token) return; + loadPendingUploads(); + }, [loadPendingUploads, token]); + + const emptyState = !loading && pending.length === 0; + return ( - -

{t('uploadQueue.description')}

+ +
+

{t('pendingUploads.subtitle')}

+ + {showSuccess && ( + + +

{t('pendingUploads.successTitle')}

+

{t('pendingUploads.successBody')}

+
+
+ )} + + {error && ( + + {error} + + )} + +
+ + +
+ + {loading ? ( +
+ + {t('pendingUploads.loading', 'Lade Uploads...')} +
+ ) : ( +
+ {pending.map((photo) => ( +
+
+ {photo.thumbnail_url ? ( + + ) : ( +
+ +
+ )} +
+
+

{t('pendingUploads.card.pending')}

+

+ {t('pendingUploads.card.uploadedAt').replace('{time}', formatTimestamp(photo.created_at))} +

+
+
+ ))} + + {emptyState && ( +
+

{t('pendingUploads.emptyTitle')}

+

{t('pendingUploads.emptyBody')}

+
+ )} +
+ )} +
); } diff --git a/resources/js/guest/pages/__tests__/UploadPageNavVisibility.test.tsx b/resources/js/guest/pages/__tests__/UploadPageNavVisibility.test.tsx index 37f9f5c..a1aeb12 100644 --- a/resources/js/guest/pages/__tests__/UploadPageNavVisibility.test.tsx +++ b/resources/js/guest/pages/__tests__/UploadPageNavVisibility.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import UploadPage from '../UploadPage'; vi.mock('react-router-dom', () => ({ @@ -69,6 +69,7 @@ describe('UploadPage bottom nav visibility', () => { beforeEach(() => { document.body.classList.remove('guest-nav-visible'); document.body.classList.remove('guest-immersive'); + Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true }); vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0; @@ -76,46 +77,21 @@ describe('UploadPage bottom nav visibility', () => { vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); }); - it('shows the nav after the KPI chips are scrolled past', async () => { + it('toggles the nav visibility based on scroll position', async () => { render(); - const chips = screen.getByTestId('upload-kpi-chips'); - const sentinel = screen.getByTestId('nav-visibility-sentinel'); - let bottom = 20; - let top = 20; - vi.spyOn(chips, 'getBoundingClientRect').mockImplementation(() => ({ - bottom, - top: bottom - 40, - left: 0, - right: 0, - width: 0, - height: 0, - x: 0, - y: 0, - toJSON: () => ({}), - }) as DOMRect); - vi.spyOn(sentinel, 'getBoundingClientRect').mockImplementation(() => ({ - bottom: top, - top, - left: 0, - right: 0, - width: 0, - height: 0, - x: 0, - y: 0, - toJSON: () => ({}), - }) as DOMRect); + expect(document.body.classList.contains('guest-nav-visible')).toBe(false); - // nav is on by default now - expect(document.body.classList.contains('guest-nav-visible')).toBe(true); - - bottom = -1; - top = -10; + window.scrollY = 120; window.dispatchEvent(new Event('scroll')); await waitFor(() => { expect(document.body.classList.contains('guest-nav-visible')).toBe(true); }); - // Nav stays visible by design now + window.scrollY = 0; + window.dispatchEvent(new Event('scroll')); + await waitFor(() => { + expect(document.body.classList.contains('guest-nav-visible')).toBe(false); + }); }); }); diff --git a/resources/js/guest/services/pendingUploadsApi.ts b/resources/js/guest/services/pendingUploadsApi.ts new file mode 100644 index 0000000..6330f6f --- /dev/null +++ b/resources/js/guest/services/pendingUploadsApi.ts @@ -0,0 +1,52 @@ +import { getDeviceId } from '../lib/device'; + +export type PendingUpload = { + id: number; + status: 'pending' | 'approved' | 'rejected'; + created_at?: string | null; + thumbnail_url?: string | null; + full_url?: string | null; +}; + +type PendingUploadsResponse = { + data: PendingUpload[]; + meta?: { + total_count?: number; + }; +}; + +async function handleResponse(response: Response): Promise { + const data = await response.json().catch(() => null); + + if (!response.ok) { + const errorPayload = data as { error?: { message?: string; code?: unknown } } | null; + const error = new Error(errorPayload?.error?.message ?? 'Request failed') as Error & { code?: unknown }; + error.code = errorPayload?.error?.code ?? response.status; + throw error; + } + + return data as T; +} + +export async function fetchPendingUploadsSummary( + token: string, + limit = 12 +): Promise<{ items: PendingUpload[]; totalCount: number }> { + const params = new URLSearchParams(); + params.set('limit', String(limit)); + + const response = await fetch(`/api/v1/events/${encodeURIComponent(token)}/pending-photos?${params.toString()}`, { + headers: { + 'Accept': 'application/json', + 'X-Device-Id': getDeviceId(), + }, + credentials: 'omit', + }); + + const payload = await handleResponse(response); + + return { + items: payload.data ?? [], + totalCount: payload.meta?.total_count ?? (payload.data?.length ?? 0), + }; +} diff --git a/routes/api.php b/routes/api.php index c3965d9..aab4215 100644 --- a/routes/api.php +++ b/routes/api.php @@ -108,6 +108,13 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->middleware('signed') ->name('photo-shares.asset'); Route::post('/events/{token}/upload', [EventPublicController::class, 'upload'])->name('events.upload'); + Route::get('/events/{token}/pending-photos', [EventPublicController::class, 'pendingUploads']) + ->name('events.pending-photos'); + Route::get('/events/{token}/pending-photos/{photo}/{variant}', [EventPublicController::class, 'pendingPhotoAsset']) + ->whereNumber('photo') + ->where('variant', 'thumbnail|full') + ->middleware('signed') + ->name('events.pending-photos.asset'); Route::get('/branding/asset/{path}', [EventPublicController::class, 'brandingAsset']) ->where('path', '.*') ->middleware('signed') diff --git a/tests/Feature/Api/Event/PendingUploadsTest.php b/tests/Feature/Api/Event/PendingUploadsTest.php new file mode 100644 index 0000000..15a69bb --- /dev/null +++ b/tests/Feature/Api/Event/PendingUploadsTest.php @@ -0,0 +1,73 @@ +create([ + 'status' => 'published', + ]); + + $token = app(EventJoinTokenService::class) + ->createToken($event, ['label' => 'guest']) + ->plain_token; + + $pendingMine = Photo::factory()->for($event)->create([ + 'status' => 'pending', + 'created_by_device_id' => 'device-123', + ]); + + Photo::factory()->for($event)->create([ + 'status' => 'pending', + 'created_by_device_id' => 'device-999', + ]); + + Photo::factory()->for($event)->create([ + 'status' => 'approved', + 'created_by_device_id' => 'device-123', + ]); + + $response = $this->withHeaders(['X-Device-Id' => 'device-123']) + ->getJson("/api/v1/events/{$token}/pending-photos"); + + $response->assertOk(); + $response->assertJsonCount(1, 'data'); + $response->assertJsonFragment([ + 'id' => $pendingMine->id, + 'status' => 'pending', + ]); + $response->assertJson(['meta' => ['total_count' => 1]]); + $this->assertNotEmpty($response->json('data.0.thumbnail_url')); + } + + public function test_pending_uploads_returns_empty_without_device_id(): void + { + $event = Event::factory()->create([ + 'status' => 'published', + ]); + + $token = app(EventJoinTokenService::class) + ->createToken($event, ['label' => 'guest']) + ->plain_token; + + Photo::factory()->for($event)->create([ + 'status' => 'pending', + 'created_by_device_id' => 'device-123', + ]); + + $response = $this->getJson("/api/v1/events/{$token}/pending-photos"); + + $response->assertOk(); + $response->assertJson(['data' => [], 'meta' => ['total_count' => 0]]); + } +}