Enable guest photo deletion and ownership flags
This commit is contained in:
@@ -2848,7 +2848,8 @@ class EventPublicController extends BaseController
|
|||||||
[$locale] = $this->resolveGuestLocale($request, $event);
|
[$locale] = $this->resolveGuestLocale($request, $event);
|
||||||
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
|
$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');
|
$filter = $request->query('filter');
|
||||||
|
|
||||||
$since = $request->query('since');
|
$since = $request->query('since');
|
||||||
@@ -2863,6 +2864,7 @@ class EventPublicController extends BaseController
|
|||||||
'photos.emotion_id',
|
'photos.emotion_id',
|
||||||
'photos.task_id',
|
'photos.task_id',
|
||||||
'photos.guest_name',
|
'photos.guest_name',
|
||||||
|
'photos.created_by_device_id',
|
||||||
'photos.created_at',
|
'photos.created_at',
|
||||||
'photos.ingest_source',
|
'photos.ingest_source',
|
||||||
'tasks.title as task_title',
|
'tasks.title as task_title',
|
||||||
@@ -2880,13 +2882,16 @@ class EventPublicController extends BaseController
|
|||||||
if ($filter === 'photobooth') {
|
if ($filter === 'photobooth') {
|
||||||
$query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]);
|
$query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]);
|
||||||
} elseif ($filter === 'myphotos' && $deviceId !== 'anon') {
|
} 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) {
|
if ($since) {
|
||||||
$query->where('photos.created_at', '>', $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')
|
$r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
|
||||||
?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? ''));
|
?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? ''));
|
||||||
$r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
|
$r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
|
||||||
@@ -2912,6 +2917,10 @@ class EventPublicController extends BaseController
|
|||||||
$r->emotion = $emotion;
|
$r->emotion = $emotion;
|
||||||
|
|
||||||
$r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN;
|
$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;
|
return $r;
|
||||||
});
|
});
|
||||||
@@ -3052,6 +3061,111 @@ class EventPublicController extends BaseController
|
|||||||
return response()->json(['liked' => false, 'likes_count' => $count]);
|
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)
|
public function upload(Request $request, string $token)
|
||||||
{
|
{
|
||||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ vi.mock('../services/photosApi', () => ({
|
|||||||
likePhoto: vi.fn(),
|
likePhoto: vi.fn(),
|
||||||
unlikePhoto: vi.fn(),
|
unlikePhoto: vi.fn(),
|
||||||
createPhotoShareLink: vi.fn(),
|
createPhotoShareLink: vi.fn(),
|
||||||
|
deletePhoto: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../components/AppShell', () => ({
|
vi.mock('../components/AppShell', () => ({
|
||||||
@@ -103,6 +104,7 @@ vi.mock('lucide-react', () => ({
|
|||||||
Loader2: () => <span>loader</span>,
|
Loader2: () => <span>loader</span>,
|
||||||
Download: () => <span>download</span>,
|
Download: () => <span>download</span>,
|
||||||
X: () => <span>x</span>,
|
X: () => <span>x</span>,
|
||||||
|
Trash2: () => <span>trash</span>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import GalleryScreen from '../screens/GalleryScreen';
|
import GalleryScreen from '../screens/GalleryScreen';
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import React from 'react';
|
|||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Button } from '@tamagui/button';
|
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 AppShell from '../components/AppShell';
|
||||||
import PhotoFrameTile from '../components/PhotoFrameTile';
|
import PhotoFrameTile from '../components/PhotoFrameTile';
|
||||||
import ShareSheet from '../components/ShareSheet';
|
import ShareSheet from '../components/ShareSheet';
|
||||||
import { useEventData } from '../context/EventDataContext';
|
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 { usePollGalleryDelta } from '../hooks/usePollGalleryDelta';
|
||||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
@@ -27,6 +27,7 @@ type GalleryTile = {
|
|||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
ingestSource?: string | null;
|
ingestSource?: string | null;
|
||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
|
isMine?: boolean;
|
||||||
taskId?: number | null;
|
taskId?: number | null;
|
||||||
taskLabel?: string | null;
|
taskLabel?: string | null;
|
||||||
emotion?: {
|
emotion?: {
|
||||||
@@ -40,6 +41,7 @@ type LightboxPhoto = {
|
|||||||
id: number;
|
id: number;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
likes: number;
|
likes: number;
|
||||||
|
isMine?: boolean;
|
||||||
taskId?: number | null;
|
taskId?: number | null;
|
||||||
taskLabel?: string | null;
|
taskLabel?: string | null;
|
||||||
emotion?: {
|
emotion?: {
|
||||||
@@ -66,6 +68,20 @@ function normalizeImageUrl(src?: string | null) {
|
|||||||
return `/${cleanPath}`.replace(/\/+/g, '/');
|
return `/${cleanPath}`.replace(/\/+/g, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readMyPhotoIds(): Set<number> {
|
||||||
|
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() {
|
export default function GalleryScreen() {
|
||||||
const { token, event } = useEventData();
|
const { token, event } = useEventData();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -95,6 +111,8 @@ export default function GalleryScreen() {
|
|||||||
url: null,
|
url: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
|
||||||
|
const [deleteBusy, setDeleteBusy] = React.useState(false);
|
||||||
const [likedIds, setLikedIds] = React.useState<Set<number>>(new Set());
|
const [likedIds, setLikedIds] = React.useState<Set<number>>(new Set());
|
||||||
const touchStartX = React.useRef<number | null>(null);
|
const touchStartX = React.useRef<number | null>(null);
|
||||||
const fallbackAttemptedRef = React.useRef(false);
|
const fallbackAttemptedRef = React.useRef(false);
|
||||||
@@ -153,6 +171,8 @@ export default function GalleryScreen() {
|
|||||||
? record.task_label
|
? record.task_label
|
||||||
: null;
|
: null;
|
||||||
const rawEmotion = (record.emotion as Record<string, unknown> | null) ?? null;
|
const rawEmotion = (record.emotion as Record<string, unknown> | null) ?? null;
|
||||||
|
const rawIsMine = record.is_mine ?? record.isMine;
|
||||||
|
const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1';
|
||||||
const emotionName =
|
const emotionName =
|
||||||
typeof rawEmotion?.name === 'string'
|
typeof rawEmotion?.name === 'string'
|
||||||
? rawEmotion.name
|
? rawEmotion.name
|
||||||
@@ -185,6 +205,7 @@ export default function GalleryScreen() {
|
|||||||
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
|
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
|
||||||
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
|
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
|
||||||
sessionId: typeof record.session_id === 'string' ? record.session_id : null,
|
sessionId: typeof record.session_id === 'string' ? record.session_id : null,
|
||||||
|
isMine,
|
||||||
taskId,
|
taskId,
|
||||||
taskLabel,
|
taskLabel,
|
||||||
emotion,
|
emotion,
|
||||||
@@ -215,13 +236,10 @@ export default function GalleryScreen() {
|
|||||||
photosRef.current = photos;
|
photosRef.current = photos;
|
||||||
}, [photos]);
|
}, [photos]);
|
||||||
|
|
||||||
const myPhotoIds = React.useMemo(() => {
|
const [myPhotoIds, setMyPhotoIds] = React.useState<Set<number>>(() => readMyPhotoIds());
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem('my-photo-ids');
|
React.useEffect(() => {
|
||||||
return new Set<number>(raw ? JSON.parse(raw) : []);
|
setMyPhotoIds(readMyPhotoIds());
|
||||||
} catch {
|
|
||||||
return new Set<number>();
|
|
||||||
}
|
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const filteredPhotos = React.useMemo(() => {
|
const filteredPhotos = React.useMemo(() => {
|
||||||
@@ -229,7 +247,7 @@ export default function GalleryScreen() {
|
|||||||
if (filter === 'popular') {
|
if (filter === 'popular') {
|
||||||
list.sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0));
|
list.sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0));
|
||||||
} else if (filter === 'mine') {
|
} 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') {
|
} else if (filter === 'photobooth') {
|
||||||
list = list.filter((photo) => photo.ingestSource === 'photobooth');
|
list = list.filter((photo) => photo.ingestSource === 'photobooth');
|
||||||
list.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime());
|
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]);
|
}, [displayPhotos, selectedPhotoId]);
|
||||||
const lightboxSelected = lightboxIndex >= 0 ? displayPhotos[lightboxIndex] : null;
|
const lightboxSelected = lightboxIndex >= 0 ? displayPhotos[lightboxIndex] : null;
|
||||||
const lightboxOpen = Boolean(selectedPhotoId);
|
const lightboxOpen = Boolean(selectedPhotoId);
|
||||||
|
const canDelete = Boolean(lightboxPhoto && (lightboxPhoto.isMine || myPhotoIds.has(lightboxPhoto.id)));
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) {
|
if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) {
|
||||||
@@ -304,6 +323,24 @@ export default function GalleryScreen() {
|
|||||||
setSearchParams(next, { replace: true });
|
setSearchParams(next, { replace: true });
|
||||||
}, [searchParams, setSearchParams]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (delta.photos.length === 0) {
|
if (delta.photos.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -326,6 +363,8 @@ export default function GalleryScreen() {
|
|||||||
if (!id || !imageUrl || existing.has(id)) {
|
if (!id || !imageUrl || existing.has(id)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const rawIsMine = record.is_mine ?? record.isMine;
|
||||||
|
const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1';
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
@@ -333,6 +372,7 @@ export default function GalleryScreen() {
|
|||||||
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
|
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
|
||||||
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
|
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
|
||||||
sessionId: typeof record.session_id === 'string' ? record.session_id : null,
|
sessionId: typeof record.session_id === 'string' ? record.session_id : null,
|
||||||
|
isMine,
|
||||||
} satisfies GalleryTile;
|
} satisfies GalleryTile;
|
||||||
})
|
})
|
||||||
.filter(Boolean) as GalleryTile[];
|
.filter(Boolean) as GalleryTile[];
|
||||||
@@ -371,6 +411,8 @@ export default function GalleryScreen() {
|
|||||||
setLightboxLoading(false);
|
setLightboxLoading(false);
|
||||||
setLightboxError(null);
|
setLightboxError(null);
|
||||||
pendingNotFoundRef.current = false;
|
pendingNotFoundRef.current = false;
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
setDeleteBusy(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +421,7 @@ export default function GalleryScreen() {
|
|||||||
id: lightboxSelected.id,
|
id: lightboxSelected.id,
|
||||||
imageUrl: lightboxSelected.imageUrl,
|
imageUrl: lightboxSelected.imageUrl,
|
||||||
likes: lightboxSelected.likes,
|
likes: lightboxSelected.likes,
|
||||||
|
isMine: lightboxSelected.isMine,
|
||||||
taskId: lightboxSelected.taskId ?? null,
|
taskId: lightboxSelected.taskId ?? null,
|
||||||
taskLabel: lightboxSelected.taskLabel ?? null,
|
taskLabel: lightboxSelected.taskLabel ?? null,
|
||||||
emotion: lightboxSelected.emotion ?? null,
|
emotion: lightboxSelected.emotion ?? null,
|
||||||
@@ -584,6 +627,40 @@ export default function GalleryScreen() {
|
|||||||
}
|
}
|
||||||
}, [lightboxPhoto, likedIds, likesById]);
|
}, [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 shareTitle = event?.name ?? t('share.title', 'Shared photo');
|
||||||
const shareText = t('share.shareText', 'Check out this moment on Fotospiel.');
|
const shareText = t('share.shareText', 'Check out this moment on Fotospiel.');
|
||||||
|
|
||||||
@@ -991,6 +1068,7 @@ export default function GalleryScreen() {
|
|||||||
borderRadius="$bentoLg"
|
borderRadius="$bentoLg"
|
||||||
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(255, 255, 255, 0.65)'}
|
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(255, 255, 255, 0.65)'}
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
|
position="relative"
|
||||||
style={{
|
style={{
|
||||||
height: 'min(76vh, 560px)',
|
height: 'min(76vh, 560px)',
|
||||||
boxShadow: hardShadow,
|
boxShadow: hardShadow,
|
||||||
@@ -1213,6 +1291,18 @@ export default function GalleryScreen() {
|
|||||||
<Download size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
<Download size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{lightboxPhoto && canDelete ? (
|
||||||
|
<Button
|
||||||
|
unstyled
|
||||||
|
paddingHorizontal="$2"
|
||||||
|
paddingVertical="$1.5"
|
||||||
|
onPress={() => setDeleteConfirmOpen(true)}
|
||||||
|
disabled={deleteBusy}
|
||||||
|
aria-label={t('galleryPage.lightbox.deleteAria', 'Delete photo')}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button unstyled paddingHorizontal="$2" paddingVertical="$1.5" onPress={openShareSheet}>
|
<Button unstyled paddingHorizontal="$2" paddingVertical="$1.5" onPress={openShareSheet}>
|
||||||
<Share2 size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
<Share2 size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1244,6 +1334,73 @@ export default function GalleryScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
</XStack>
|
</XStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
{deleteConfirmOpen ? (
|
||||||
|
<YStack
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
padding="$4"
|
||||||
|
backgroundColor={isDark ? 'rgba(2, 6, 23, 0.7)' : 'rgba(15, 23, 42, 0.4)'}
|
||||||
|
style={{ backdropFilter: 'blur(6px)', zIndex: 6 }}
|
||||||
|
>
|
||||||
|
<YStack
|
||||||
|
width="100%"
|
||||||
|
maxWidth={360}
|
||||||
|
padding="$4"
|
||||||
|
gap="$3"
|
||||||
|
borderRadius="$card"
|
||||||
|
backgroundColor="$surface"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={mutedButtonBorder}
|
||||||
|
style={{ boxShadow: cardShadow }}
|
||||||
|
>
|
||||||
|
<YStack gap="$1">
|
||||||
|
<Text fontSize="$4" fontWeight="$7">
|
||||||
|
{t('galleryPage.lightbox.deleteTitle', 'Foto löschen?')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||||
|
{t(
|
||||||
|
'galleryPage.lightbox.deleteDescription',
|
||||||
|
'Das Foto wird aus der Galerie entfernt und kann nicht wiederhergestellt werden.'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
<XStack gap="$2" justifyContent="flex-end">
|
||||||
|
<Button
|
||||||
|
backgroundColor={mutedButton}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={mutedButtonBorder}
|
||||||
|
onPress={() => setDeleteConfirmOpen(false)}
|
||||||
|
disabled={deleteBusy}
|
||||||
|
>
|
||||||
|
<Text fontSize="$2" fontWeight="$6">
|
||||||
|
{t('common.actions.cancel', 'Cancel')}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
backgroundColor="#EF4444"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="#EF4444"
|
||||||
|
onPress={handleDelete}
|
||||||
|
disabled={deleteBusy}
|
||||||
|
>
|
||||||
|
<XStack alignItems="center" gap="$1.5">
|
||||||
|
{deleteBusy ? <Loader2 size={14} className="animate-spin" color="#FFFFFF" /> : null}
|
||||||
|
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
|
||||||
|
{deleteBusy
|
||||||
|
? t('galleryPage.lightbox.deleting', 'Wird gelöscht…')
|
||||||
|
: t('galleryPage.lightbox.deleteConfirm', 'Foto löschen')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
</Button>
|
||||||
|
</XStack>
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
) : null}
|
||||||
<ShareSheet
|
<ShareSheet
|
||||||
open={shareSheet.loading || Boolean(shareSheet.url)}
|
open={shareSheet.loading || Boolean(shareSheet.url)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@@ -1319,10 +1476,13 @@ function mapFullPhoto(photo: Record<string, unknown>): LightboxPhoto | null {
|
|||||||
const emotion = emotionName || emotionIcon || emotionColor
|
const emotion = emotionName || emotionIcon || emotionColor
|
||||||
? { name: emotionName, icon: emotionIcon, color: emotionColor }
|
? { name: emotionName, icon: emotionIcon, color: emotionColor }
|
||||||
: null;
|
: null;
|
||||||
|
const rawIsMine = photo.is_mine ?? photo.isMine;
|
||||||
|
const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1';
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
|
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
|
||||||
|
isMine,
|
||||||
taskId,
|
taskId,
|
||||||
taskLabel,
|
taskLabel,
|
||||||
emotion,
|
emotion,
|
||||||
|
|||||||
@@ -223,6 +223,20 @@ export default function UploadScreen() {
|
|||||||
[optimizeMaxEdge, optimizeTargetBytes, t]
|
[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(
|
const uploadFiles = React.useCallback(
|
||||||
async (files: File[]) => {
|
async (files: File[]) => {
|
||||||
if (!token || files.length === 0) return;
|
if (!token || files.length === 0) return;
|
||||||
@@ -260,6 +274,7 @@ export default function UploadScreen() {
|
|||||||
}
|
}
|
||||||
pushGuestToast({ text: t('uploadV2.toast.uploaded', 'Upload complete.'), type: 'success' });
|
pushGuestToast({ text: t('uploadV2.toast.uploaded', 'Upload complete.'), type: 'success' });
|
||||||
void loadPending();
|
void loadPending();
|
||||||
|
persistMyPhotoId(photoId);
|
||||||
if (autoApprove && photoId) {
|
if (autoApprove && photoId) {
|
||||||
redirectPhotoId = photoId;
|
redirectPhotoId = photoId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fetchJson } from './apiClient';
|
import { fetchJson } from './apiClient';
|
||||||
import { getDeviceId } from '../lib/device';
|
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<string, unknown>;
|
export type GalleryPhoto = Record<string, unknown>;
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
close: 'Schließen',
|
close: 'Schließen',
|
||||||
back: 'Zurück',
|
back: 'Zurück',
|
||||||
loading: 'Lädt...',
|
loading: 'Lädt...',
|
||||||
|
cancel: 'Abbrechen',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
consent: {
|
consent: {
|
||||||
@@ -502,6 +503,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
prev: 'Zurück',
|
prev: 'Zurück',
|
||||||
next: 'Weiter',
|
next: 'Weiter',
|
||||||
likes: '{count} Likes',
|
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: {
|
share: {
|
||||||
@@ -945,6 +953,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
close: 'Close',
|
close: 'Close',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
|
cancel: 'Cancel',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
consent: {
|
consent: {
|
||||||
@@ -1420,6 +1429,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
prev: 'Prev',
|
prev: 'Prev',
|
||||||
next: 'Next',
|
next: 'Next',
|
||||||
likes: '{count} likes',
|
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: {
|
share: {
|
||||||
|
|||||||
@@ -96,6 +96,48 @@ export async function unlikePhoto(id: number): Promise<number> {
|
|||||||
return json.likes_count ?? json.data?.likes_count ?? 0;
|
return json.likes_count ?? json.data?.likes_count ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deletePhoto(eventToken: string, id: number): Promise<void> {
|
||||||
|
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<string, unknown> } } | null)?.error?.meta;
|
||||||
|
if (meta) {
|
||||||
|
error.meta = meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type UploadOptions = {
|
type UploadOptions = {
|
||||||
guestName?: string;
|
guestName?: string;
|
||||||
onProgress?: (percent: number) => void;
|
onProgress?: (percent: number) => void;
|
||||||
|
|||||||
@@ -197,6 +197,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
Route::post('/events/{token}/photos/{photo}/share', [EventPublicController::class, 'createShareLink'])
|
Route::post('/events/{token}/photos/{photo}/share', [EventPublicController::class, 'createShareLink'])
|
||||||
->whereNumber('photo')
|
->whereNumber('photo')
|
||||||
->name('photos.share');
|
->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}', [EventPublicController::class, 'shareLink'])->name('photo-shares.show');
|
||||||
Route::get('/photo-shares/{slug}/asset/{variant}', [EventPublicController::class, 'shareLinkAsset'])
|
Route::get('/photo-shares/{slug}/asset/{variant}', [EventPublicController::class, 'shareLinkAsset'])
|
||||||
->middleware('signed')
|
->middleware('signed')
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class EventPhotosLocaleTest extends TestCase
|
|||||||
'tenant_id' => $event->tenant_id,
|
'tenant_id' => $event->tenant_id,
|
||||||
'task_id' => $task->id,
|
'task_id' => $task->id,
|
||||||
'emotion_id' => $emotion->id,
|
'emotion_id' => $emotion->id,
|
||||||
|
'created_by_device_id' => 'device-123',
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'status' => 'approved',
|
'status' => 'approved',
|
||||||
]);
|
]);
|
||||||
@@ -57,6 +58,7 @@ class EventPhotosLocaleTest extends TestCase
|
|||||||
$responseEn->assertJsonPath('data.0.emotion.name', 'Joy');
|
$responseEn->assertJsonPath('data.0.emotion.name', 'Joy');
|
||||||
$responseEn->assertJsonPath('data.0.emotion.icon', '🙂');
|
$responseEn->assertJsonPath('data.0.emotion.icon', '🙂');
|
||||||
$responseEn->assertJsonPath('data.0.emotion.color', '#FF00AA');
|
$responseEn->assertJsonPath('data.0.emotion.color', '#FF00AA');
|
||||||
|
$responseEn->assertJsonPath('data.0.is_mine', true);
|
||||||
|
|
||||||
$etag = $responseEn->headers->get('ETag');
|
$etag = $responseEn->headers->get('ETag');
|
||||||
$this->assertNotEmpty($etag);
|
$this->assertNotEmpty($etag);
|
||||||
|
|||||||
87
tests/Feature/EventGuestPhotoDeleteTest.php
Normal file
87
tests/Feature/EventGuestPhotoDeleteTest.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Models\PhotoLike;
|
||||||
|
use App\Models\PhotoShareLink;
|
||||||
|
use App\Services\EventJoinTokenService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class EventGuestPhotoDeleteTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_guest_can_delete_own_photo(): void
|
||||||
|
{
|
||||||
|
$disk = config('filesystems.default', 'local');
|
||||||
|
Storage::fake($disk);
|
||||||
|
|
||||||
|
$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',
|
||||||
|
'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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user