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]);
+ }
+}