From 18b4f36fcfbb76a67e55a5c8b945fa72b7e5a341 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 5 Feb 2026 22:05:10 +0100 Subject: [PATCH] Enable guest photo deletion and ownership flags --- .../Controllers/Api/EventPublicController.php | 120 +++++++++++- .../guest-v2/__tests__/GalleryScreen.test.tsx | 2 + .../js/guest-v2/screens/GalleryScreen.tsx | 180 +++++++++++++++++- .../js/guest-v2/screens/UploadScreen.tsx | 15 ++ resources/js/guest-v2/services/photosApi.ts | 2 +- resources/js/guest/i18n/messages.ts | 16 ++ resources/js/guest/services/photosApi.ts | 42 ++++ routes/api.php | 3 + .../Api/Event/EventPhotosLocaleTest.php | 2 + tests/Feature/EventGuestPhotoDeleteTest.php | 87 +++++++++ 10 files changed, 455 insertions(+), 14 deletions(-) create mode 100644 tests/Feature/EventGuestPhotoDeleteTest.php diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index e3108189..45994132 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -2848,7 +2848,8 @@ class EventPublicController extends BaseController [$locale] = $this->resolveGuestLocale($request, $event); $fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null); - $deviceId = (string) $request->header('X-Device-Id', 'anon'); + $deviceId = $this->normalizeGuestIdentifier((string) $request->header('X-Device-Id', '')); + $deviceId = $deviceId !== '' ? $deviceId : 'anon'; $filter = $request->query('filter'); $since = $request->query('since'); @@ -2863,6 +2864,7 @@ class EventPublicController extends BaseController 'photos.emotion_id', 'photos.task_id', 'photos.guest_name', + 'photos.created_by_device_id', 'photos.created_at', 'photos.ingest_source', 'tasks.title as task_title', @@ -2880,13 +2882,16 @@ class EventPublicController extends BaseController if ($filter === 'photobooth') { $query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]); } elseif ($filter === 'myphotos' && $deviceId !== 'anon') { - $query->where('guest_name', $deviceId); + $query->where(function ($inner) use ($deviceId) { + $inner->where('created_by_device_id', $deviceId) + ->orWhere('guest_name', $deviceId); + }); } if ($since) { $query->where('photos.created_at', '>', $since); } - $rows = $query->get()->map(function ($r) use ($fallbacks, $token) { + $rows = $query->get()->map(function ($r) use ($fallbacks, $token, $deviceId) { $r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full') ?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? '')); $r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail') @@ -2912,6 +2917,10 @@ class EventPublicController extends BaseController $r->emotion = $emotion; $r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN; + $createdBy = $r->created_by_device_id ? $this->normalizeGuestIdentifier((string) $r->created_by_device_id) : ''; + $r->is_mine = $deviceId !== 'anon' + && $deviceId !== '' + && (($createdBy !== '' && $createdBy === $deviceId) || ($createdBy === '' && (string) $r->guest_name === $deviceId)); return $r; }); @@ -3052,6 +3061,111 @@ class EventPublicController extends BaseController return response()->json(['liked' => false, 'likes_count' => $count]); } + public function destroyPhoto(Request $request, string $token, Photo $photo): JsonResponse + { + $result = $this->resolvePublishedEvent($request, $token, ['id']); + + if ($result instanceof JsonResponse) { + return $result; + } + + [$event] = $result; + $deviceId = $this->resolveDeviceIdentifier($request); + + if ($deviceId === 'anonymous') { + return ApiError::response( + 'photo_delete_forbidden', + 'Delete Not Allowed', + 'This photo cannot be deleted from this device.', + Response::HTTP_FORBIDDEN, + ['photo_id' => $photo->id] + ); + } + + if ($photo->event_id !== (int) $event->id) { + return ApiError::response( + 'photo_not_found', + 'Photo Not Found', + 'Photo not found or event not public.', + Response::HTTP_NOT_FOUND, + ['photo_id' => $photo->id] + ); + } + + $ownerId = $photo->created_by_device_id + ? $this->normalizeGuestIdentifier((string) $photo->created_by_device_id) + : ''; + $guestName = is_string($photo->guest_name) ? $photo->guest_name : ''; + $isOwner = $ownerId !== '' + ? $ownerId === $deviceId + : ($guestName !== '' && $guestName === $deviceId); + + if (! $isOwner) { + return ApiError::response( + 'photo_delete_forbidden', + 'Delete Not Allowed', + 'This photo cannot be deleted from this device.', + Response::HTTP_FORBIDDEN, + ['photo_id' => $photo->id] + ); + } + + $eventModel = Event::with(['eventPackage.package'])->find((int) $event->id); + $assets = EventMediaAsset::where('photo_id', $photo->id)->get(); + + foreach ($assets as $asset) { + if (! is_string($asset->path) || $asset->path === '') { + continue; + } + try { + Storage::disk($asset->disk)->delete($asset->path); + } catch (\Throwable $e) { + Log::warning('Failed to delete guest photo asset from storage', [ + 'asset_id' => $asset->id, + 'disk' => $asset->disk, + 'path' => $asset->path, + 'error' => $e->getMessage(), + ]); + } + } + + if ($assets->isEmpty() && $eventModel) { + $fallbackDisk = $this->eventStorageManager->getHotDiskForEvent($eventModel); + $paths = array_values(array_filter([ + is_string($photo->path ?? null) ? $photo->path : null, + is_string($photo->thumbnail_path ?? null) ? $photo->thumbnail_path : null, + is_string($photo->file_path ?? null) ? $photo->file_path : null, + ])); + if (! empty($paths)) { + Storage::disk($fallbackDisk)->delete($paths); + } + } + + DB::transaction(function () use ($photo, $assets) { + $photo->likes()->delete(); + PhotoShareLink::where('photo_id', $photo->id)->delete(); + if ($assets->isNotEmpty()) { + EventMediaAsset::whereIn('id', $assets->pluck('id'))->delete(); + } + $photo->delete(); + }); + + $eventPackage = $eventModel?->eventPackage; + if ($eventPackage && $eventPackage->package) { + $previousUsed = (int) $eventPackage->used_photos; + if ($previousUsed > 0) { + $eventPackage->decrement('used_photos'); + $eventPackage->refresh(); + $this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, -1); + } + } + + return response()->json([ + 'message' => 'Photo deleted successfully', + 'photo_id' => $photo->id, + ]); + } + public function upload(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id']); diff --git a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx index 6f6dd897..cfab3b69 100644 --- a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx @@ -58,6 +58,7 @@ vi.mock('../services/photosApi', () => ({ likePhoto: vi.fn(), unlikePhoto: vi.fn(), createPhotoShareLink: vi.fn(), + deletePhoto: vi.fn(), })); vi.mock('../components/AppShell', () => ({ @@ -103,6 +104,7 @@ vi.mock('lucide-react', () => ({ Loader2: () => loader, Download: () => download, X: () => x, + Trash2: () => trash, })); import GalleryScreen from '../screens/GalleryScreen'; diff --git a/resources/js/guest-v2/screens/GalleryScreen.tsx b/resources/js/guest-v2/screens/GalleryScreen.tsx index 86e5a014..4f2e2e6e 100644 --- a/resources/js/guest-v2/screens/GalleryScreen.tsx +++ b/resources/js/guest-v2/screens/GalleryScreen.tsx @@ -2,12 +2,12 @@ import React from 'react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; -import { Camera, ChevronLeft, ChevronRight, Download, Heart, Loader2, Share2, Sparkles, X } from 'lucide-react'; +import { Camera, ChevronLeft, ChevronRight, Download, Heart, Loader2, Share2, Sparkles, Trash2, X } from 'lucide-react'; import AppShell from '../components/AppShell'; import PhotoFrameTile from '../components/PhotoFrameTile'; import ShareSheet from '../components/ShareSheet'; import { useEventData } from '../context/EventDataContext'; -import { createPhotoShareLink, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } from '../services/photosApi'; +import { createPhotoShareLink, deletePhoto, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } from '../services/photosApi'; import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta'; import { useGuestThemeVariant } from '../lib/guestTheme'; import { useTranslation } from '@/guest/i18n/useTranslation'; @@ -27,6 +27,7 @@ type GalleryTile = { createdAt?: string | null; ingestSource?: string | null; sessionId?: string | null; + isMine?: boolean; taskId?: number | null; taskLabel?: string | null; emotion?: { @@ -40,6 +41,7 @@ type LightboxPhoto = { id: number; imageUrl: string; likes: number; + isMine?: boolean; taskId?: number | null; taskLabel?: string | null; emotion?: { @@ -66,6 +68,20 @@ function normalizeImageUrl(src?: string | null) { return `/${cleanPath}`.replace(/\/+/g, '/'); } +function readMyPhotoIds(): Set { + try { + const raw = localStorage.getItem('my-photo-ids'); + const parsed = raw ? JSON.parse(raw) : []; + if (!Array.isArray(parsed)) { + return new Set(); + } + const ids = parsed.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0); + return new Set(ids); + } catch { + return new Set(); + } +} + export default function GalleryScreen() { const { token, event } = useEventData(); const { t } = useTranslation(); @@ -95,6 +111,8 @@ export default function GalleryScreen() { url: null, loading: false, }); + const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false); + const [deleteBusy, setDeleteBusy] = React.useState(false); const [likedIds, setLikedIds] = React.useState>(new Set()); const touchStartX = React.useRef(null); const fallbackAttemptedRef = React.useRef(false); @@ -153,6 +171,8 @@ export default function GalleryScreen() { ? record.task_label : null; const rawEmotion = (record.emotion as Record | null) ?? null; + const rawIsMine = record.is_mine ?? record.isMine; + const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1'; const emotionName = typeof rawEmotion?.name === 'string' ? rawEmotion.name @@ -185,6 +205,7 @@ export default function GalleryScreen() { createdAt: typeof record.created_at === 'string' ? record.created_at : null, ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null, sessionId: typeof record.session_id === 'string' ? record.session_id : null, + isMine, taskId, taskLabel, emotion, @@ -215,13 +236,10 @@ export default function GalleryScreen() { photosRef.current = photos; }, [photos]); - const myPhotoIds = React.useMemo(() => { - try { - const raw = localStorage.getItem('my-photo-ids'); - return new Set(raw ? JSON.parse(raw) : []); - } catch { - return new Set(); - } + const [myPhotoIds, setMyPhotoIds] = React.useState>(() => readMyPhotoIds()); + + React.useEffect(() => { + setMyPhotoIds(readMyPhotoIds()); }, [token]); const filteredPhotos = React.useMemo(() => { @@ -229,7 +247,7 @@ export default function GalleryScreen() { if (filter === 'popular') { list.sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0)); } else if (filter === 'mine') { - list = list.filter((photo) => myPhotoIds.has(photo.id)); + list = list.filter((photo) => Boolean(photo.isMine) || myPhotoIds.has(photo.id)); } else if (filter === 'photobooth') { list = list.filter((photo) => photo.ingestSource === 'photobooth'); list.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime()); @@ -253,6 +271,7 @@ export default function GalleryScreen() { }, [displayPhotos, selectedPhotoId]); const lightboxSelected = lightboxIndex >= 0 ? displayPhotos[lightboxIndex] : null; const lightboxOpen = Boolean(selectedPhotoId); + const canDelete = Boolean(lightboxPhoto && (lightboxPhoto.isMine || myPhotoIds.has(lightboxPhoto.id))); React.useEffect(() => { if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) { @@ -304,6 +323,24 @@ export default function GalleryScreen() { setSearchParams(next, { replace: true }); }, [searchParams, setSearchParams]); + const removeMyPhotoId = React.useCallback((photoId: number) => { + setMyPhotoIds((prev) => { + const next = new Set(prev); + next.delete(photoId); + return next; + }); + try { + const raw = localStorage.getItem('my-photo-ids'); + const parsed = raw ? JSON.parse(raw) : []; + if (Array.isArray(parsed)) { + const next = parsed.filter((value) => Number(value) !== photoId); + localStorage.setItem('my-photo-ids', JSON.stringify(next)); + } + } catch (error) { + console.warn('Failed to update my-photo-ids', error); + } + }, []); + React.useEffect(() => { if (delta.photos.length === 0) { return; @@ -326,6 +363,8 @@ export default function GalleryScreen() { if (!id || !imageUrl || existing.has(id)) { return null; } + const rawIsMine = record.is_mine ?? record.isMine; + const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1'; return { id, imageUrl, @@ -333,6 +372,7 @@ export default function GalleryScreen() { createdAt: typeof record.created_at === 'string' ? record.created_at : null, ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null, sessionId: typeof record.session_id === 'string' ? record.session_id : null, + isMine, } satisfies GalleryTile; }) .filter(Boolean) as GalleryTile[]; @@ -371,6 +411,8 @@ export default function GalleryScreen() { setLightboxLoading(false); setLightboxError(null); pendingNotFoundRef.current = false; + setDeleteConfirmOpen(false); + setDeleteBusy(false); return; } @@ -379,6 +421,7 @@ export default function GalleryScreen() { id: lightboxSelected.id, imageUrl: lightboxSelected.imageUrl, likes: lightboxSelected.likes, + isMine: lightboxSelected.isMine, taskId: lightboxSelected.taskId ?? null, taskLabel: lightboxSelected.taskLabel ?? null, emotion: lightboxSelected.emotion ?? null, @@ -584,6 +627,40 @@ export default function GalleryScreen() { } }, [lightboxPhoto, likedIds, likesById]); + const handleDelete = React.useCallback(async () => { + if (!lightboxPhoto || !token || deleteBusy) return; + setDeleteBusy(true); + try { + await deletePhoto(token, lightboxPhoto.id); + setPhotos((prev) => prev.filter((photo) => photo.id !== lightboxPhoto.id)); + setLikesById((prev) => { + const next = { ...prev }; + delete next[lightboxPhoto.id]; + return next; + }); + setLikedIds((prev) => { + const next = new Set(prev); + next.delete(lightboxPhoto.id); + return next; + }); + removeMyPhotoId(lightboxPhoto.id); + setDeleteConfirmOpen(false); + setDeleteBusy(false); + closeLightbox(); + pushGuestToast({ + text: t('galleryPage.lightbox.deletedToast', 'Photo deleted.'), + type: 'success', + }); + } catch (error) { + console.error('Failed to delete photo', error); + setDeleteBusy(false); + pushGuestToast({ + text: t('galleryPage.lightbox.deleteFailed', 'Photo could not be deleted.'), + type: 'error', + }); + } + }, [closeLightbox, deleteBusy, lightboxPhoto, removeMyPhotoId, t, token]); + const shareTitle = event?.name ?? t('share.title', 'Shared photo'); const shareText = t('share.shareText', 'Check out this moment on Fotospiel.'); @@ -991,6 +1068,7 @@ export default function GalleryScreen() { borderRadius="$bentoLg" backgroundColor={isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(255, 255, 255, 0.65)'} overflow="hidden" + position="relative" style={{ height: 'min(76vh, 560px)', boxShadow: hardShadow, @@ -1213,6 +1291,18 @@ export default function GalleryScreen() { ) : null} + {lightboxPhoto && canDelete ? ( + + ) : null} @@ -1244,6 +1334,73 @@ export default function GalleryScreen() { + {deleteConfirmOpen ? ( + + + + + {t('galleryPage.lightbox.deleteTitle', 'Foto löschen?')} + + + {t( + 'galleryPage.lightbox.deleteDescription', + 'Das Foto wird aus der Galerie entfernt und kann nicht wiederhergestellt werden.' + )} + + + + + + + + + ) : null} { @@ -1319,10 +1476,13 @@ function mapFullPhoto(photo: Record): LightboxPhoto | null { const emotion = emotionName || emotionIcon || emotionColor ? { name: emotionName, icon: emotionIcon, color: emotionColor } : null; + const rawIsMine = photo.is_mine ?? photo.isMine; + const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1'; return { id, imageUrl, likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0, + isMine, taskId, taskLabel, emotion, diff --git a/resources/js/guest-v2/screens/UploadScreen.tsx b/resources/js/guest-v2/screens/UploadScreen.tsx index a3016ee2..e666c42e 100644 --- a/resources/js/guest-v2/screens/UploadScreen.tsx +++ b/resources/js/guest-v2/screens/UploadScreen.tsx @@ -223,6 +223,20 @@ export default function UploadScreen() { [optimizeMaxEdge, optimizeTargetBytes, t] ); + const persistMyPhotoId = React.useCallback((photoId: number) => { + if (!photoId) return; + try { + const raw = localStorage.getItem('my-photo-ids'); + const parsed = raw ? JSON.parse(raw) : []; + const list = Array.isArray(parsed) ? parsed.filter((value) => Number.isFinite(Number(value))) : []; + if (!list.includes(photoId)) { + localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...list])); + } + } catch (error) { + console.warn('Failed to persist my-photo-ids', error); + } + }, []); + const uploadFiles = React.useCallback( async (files: File[]) => { if (!token || files.length === 0) return; @@ -260,6 +274,7 @@ export default function UploadScreen() { } pushGuestToast({ text: t('uploadV2.toast.uploaded', 'Upload complete.'), type: 'success' }); void loadPending(); + persistMyPhotoId(photoId); if (autoApprove && photoId) { redirectPhotoId = photoId; } diff --git a/resources/js/guest-v2/services/photosApi.ts b/resources/js/guest-v2/services/photosApi.ts index c2ca3fbb..9f5e24d4 100644 --- a/resources/js/guest-v2/services/photosApi.ts +++ b/resources/js/guest-v2/services/photosApi.ts @@ -1,6 +1,6 @@ import { fetchJson } from './apiClient'; import { getDeviceId } from '../lib/device'; -export { likePhoto, unlikePhoto, createPhotoShareLink, uploadPhoto } from '@/guest/services/photosApi'; +export { likePhoto, unlikePhoto, createPhotoShareLink, uploadPhoto, deletePhoto } from '@/guest/services/photosApi'; export type GalleryPhoto = Record; diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index 9e3e00ac..22204266 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -27,6 +27,7 @@ export const messages: Record = { close: 'Schließen', back: 'Zurück', loading: 'Lädt...', + cancel: 'Abbrechen', }, }, consent: { @@ -502,6 +503,13 @@ export const messages: Record = { prev: 'Zurück', next: 'Weiter', likes: '{count} Likes', + deleteTitle: 'Foto löschen?', + deleteDescription: 'Das Foto wird aus der Galerie entfernt und kann nicht wiederhergestellt werden.', + deleteConfirm: 'Foto löschen', + deleteAria: 'Foto löschen', + deleting: 'Wird gelöscht…', + deletedToast: 'Foto gelöscht.', + deleteFailed: 'Foto konnte nicht gelöscht werden.', }, }, share: { @@ -945,6 +953,7 @@ export const messages: Record = { close: 'Close', back: 'Back', loading: 'Loading...', + cancel: 'Cancel', }, }, consent: { @@ -1420,6 +1429,13 @@ export const messages: Record = { prev: 'Prev', next: 'Next', likes: '{count} likes', + deleteTitle: 'Delete this photo?', + deleteDescription: 'This photo will be removed from the gallery and cannot be restored.', + deleteConfirm: 'Delete photo', + deleteAria: 'Delete photo', + deleting: 'Deleting…', + deletedToast: 'Photo deleted.', + deleteFailed: 'Photo could not be deleted.', }, }, share: { diff --git a/resources/js/guest/services/photosApi.ts b/resources/js/guest/services/photosApi.ts index c3b0904f..b588529d 100644 --- a/resources/js/guest/services/photosApi.ts +++ b/resources/js/guest/services/photosApi.ts @@ -96,6 +96,48 @@ export async function unlikePhoto(id: number): Promise { return json.likes_count ?? json.data?.likes_count ?? 0; } +export async function deletePhoto(eventToken: string, id: number): Promise { + const headers = buildCsrfHeaders(); + const url = `/api/v1/events/${encodeURIComponent(eventToken)}/photos/${id}`; + + const res = await fetch(url, { + method: 'DELETE', + credentials: 'include', + headers: { + ...headers, + 'Content-Type': 'application/json', + }, + }); + + if (!res.ok) { + let payload: unknown = null; + try { + payload = await res.clone().json(); + } catch (error) { + console.warn('Delete photo: failed to parse error payload', error); + } + + if (res.status === 419) { + const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.'); + error.code = 'csrf_mismatch'; + error.status = res.status; + throw error; + } + + const error: UploadError = new Error( + (payload as { error?: { message?: string } } | null)?.error?.message ?? `Delete failed: ${res.status}` + ); + error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'delete_failed'; + error.status = res.status; + const meta = (payload as { error?: { meta?: Record } } | null)?.error?.meta; + if (meta) { + error.meta = meta; + } + + throw error; + } +} + type UploadOptions = { guestName?: string; onProgress?: (percent: number) => void; diff --git a/routes/api.php b/routes/api.php index 701d19b8..7d4018a4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -197,6 +197,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::post('/events/{token}/photos/{photo}/share', [EventPublicController::class, 'createShareLink']) ->whereNumber('photo') ->name('photos.share'); + Route::delete('/events/{token}/photos/{photo}', [EventPublicController::class, 'destroyPhoto']) + ->whereNumber('photo') + ->name('events.photos.destroy'); Route::get('/photo-shares/{slug}', [EventPublicController::class, 'shareLink'])->name('photo-shares.show'); Route::get('/photo-shares/{slug}/asset/{variant}', [EventPublicController::class, 'shareLinkAsset']) ->middleware('signed') diff --git a/tests/Feature/Api/Event/EventPhotosLocaleTest.php b/tests/Feature/Api/Event/EventPhotosLocaleTest.php index a8f0ae83..5f4504a5 100644 --- a/tests/Feature/Api/Event/EventPhotosLocaleTest.php +++ b/tests/Feature/Api/Event/EventPhotosLocaleTest.php @@ -44,6 +44,7 @@ class EventPhotosLocaleTest extends TestCase 'tenant_id' => $event->tenant_id, 'task_id' => $task->id, 'emotion_id' => $emotion->id, + 'created_by_device_id' => 'device-123', 'created_at' => now(), 'status' => 'approved', ]); @@ -57,6 +58,7 @@ class EventPhotosLocaleTest extends TestCase $responseEn->assertJsonPath('data.0.emotion.name', 'Joy'); $responseEn->assertJsonPath('data.0.emotion.icon', '🙂'); $responseEn->assertJsonPath('data.0.emotion.color', '#FF00AA'); + $responseEn->assertJsonPath('data.0.is_mine', true); $etag = $responseEn->headers->get('ETag'); $this->assertNotEmpty($etag); diff --git a/tests/Feature/EventGuestPhotoDeleteTest.php b/tests/Feature/EventGuestPhotoDeleteTest.php new file mode 100644 index 00000000..c932e69f --- /dev/null +++ b/tests/Feature/EventGuestPhotoDeleteTest.php @@ -0,0 +1,87 @@ +create([ + 'status' => 'published', + ]); + + $token = app(EventJoinTokenService::class) + ->createToken($event, ['label' => 'guest']) + ->plain_token; + + $photo = Photo::factory()->for($event)->create([ + 'status' => 'approved', + 'guest_name' => 'device-123', + 'created_by_device_id' => 'device-123', + 'file_path' => "events/{$event->id}/photos/test.jpg", + 'thumbnail_path' => "events/{$event->id}/photos/thumbs/test_thumb.jpg", + ]); + + Storage::disk($disk)->put($photo->file_path, 'file'); + Storage::disk($disk)->put($photo->thumbnail_path, 'thumb'); + + PhotoShareLink::factory()->create([ + 'photo_id' => $photo->id, + ]); + + PhotoLike::create([ + 'photo_id' => $photo->id, + 'guest_name' => 'device-123', + 'ip_address' => 'device', + ]); + + $response = $this->withHeaders(['X-Device-Id' => 'device-123']) + ->deleteJson("/api/v1/events/{$token}/photos/{$photo->id}"); + + $response->assertOk(); + $response->assertJsonFragment(['photo_id' => $photo->id]); + + $this->assertDatabaseMissing('photos', ['id' => $photo->id]); + $this->assertDatabaseMissing('photo_share_links', ['photo_id' => $photo->id]); + $this->assertDatabaseMissing('photo_likes', ['photo_id' => $photo->id]); + Storage::disk($disk)->assertMissing($photo->file_path); + Storage::disk($disk)->assertMissing($photo->thumbnail_path); + } + + public function test_guest_cannot_delete_someone_elses_photo(): void + { + $event = Event::factory()->create([ + 'status' => 'published', + ]); + + $token = app(EventJoinTokenService::class) + ->createToken($event, ['label' => 'guest']) + ->plain_token; + + $photo = Photo::factory()->for($event)->create([ + 'status' => 'approved', + 'guest_name' => 'device-123', + 'created_by_device_id' => 'device-123', + ]); + + $this->withHeaders(['X-Device-Id' => 'device-999']) + ->deleteJson("/api/v1/events/{$token}/photos/{$photo->id}") + ->assertForbidden(); + + $this->assertDatabaseHas('photos', ['id' => $photo->id]); + } +}