Fix tenant photo moderation and guest updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-12 14:35:57 +01:00
parent cceed361b7
commit 2287e7f32c
10 changed files with 408 additions and 107 deletions

View File

@@ -525,13 +525,13 @@ class PhotoController extends Controller
]); ]);
// Only tenant admins can moderate // Only tenant admins can moderate
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) { if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
return ApiError::response( return ApiError::response(
'insufficient_scope', 'insufficient_scope',
'Insufficient Scopes', 'Insufficient Scopes',
'You are not allowed to moderate photos for this event.', 'You are not allowed to moderate photos for this event.',
Response::HTTP_FORBIDDEN, Response::HTTP_FORBIDDEN,
['required_scope' => 'tenant:write'] ['required_scope' => 'tenant-admin']
); );
} }
@@ -823,6 +823,11 @@ class PhotoController extends Controller
private function tokenHasScope(Request $request, string $scope): bool private function tokenHasScope(Request $request, string $scope): bool
{ {
$accessToken = $request->user()?->currentAccessToken();
if ($accessToken && $accessToken->can($scope)) {
return true;
}
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []); $scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
if (! is_array($scopes)) { if (! is_array($scopes)) {

View File

@@ -5,11 +5,19 @@ namespace App\Listeners\GuestNotifications;
use App\Enums\GuestNotificationAudience; use App\Enums\GuestNotificationAudience;
use App\Enums\GuestNotificationType; use App\Enums\GuestNotificationType;
use App\Events\GuestPhotoUploaded; use App\Events\GuestPhotoUploaded;
use App\Models\GuestNotification;
use App\Models\Photo; use App\Models\Photo;
use App\Services\GuestNotificationService; use App\Services\GuestNotificationService;
use Illuminate\Support\Carbon;
class SendPhotoUploadedNotification class SendPhotoUploadedNotification
{ {
private const DEDUPE_WINDOW_SECONDS = 30;
private const GROUP_WINDOW_MINUTES = 10;
private const MAX_GROUP_PHOTOS = 6;
/** /**
* @param int[] $milestones * @param int[] $milestones
*/ */
@@ -25,7 +33,20 @@ class SendPhotoUploadedNotification
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel) ? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
: 'Es gibt neue Fotos!'; : 'Es gibt neue Fotos!';
$this->notifications->createNotification( $recent = $this->findRecentPhotoNotification($event->event->id);
if ($recent) {
if ($this->shouldSkipDuplicate($recent, $event->photoId, $title)) {
return;
}
$notification = $this->updateGroupedNotification($recent, $event->photoId);
$this->markUploaderRead($notification, $event->guestIdentifier);
$this->maybeCreateMilestoneNotification($event, $guestLabel);
return;
}
$notification = $this->notifications->createNotification(
$event->event, $event->event,
GuestNotificationType::PHOTO_ACTIVITY, GuestNotificationType::PHOTO_ACTIVITY,
$title, $title,
@@ -34,11 +55,15 @@ class SendPhotoUploadedNotification
'audience_scope' => GuestNotificationAudience::ALL, 'audience_scope' => GuestNotificationAudience::ALL,
'payload' => [ 'payload' => [
'photo_id' => $event->photoId, 'photo_id' => $event->photoId,
'photo_ids' => [$event->photoId],
'count' => 1,
], ],
'expires_at' => now()->addHours(3), 'expires_at' => now()->addHours(3),
] ]
); );
$this->markUploaderRead($notification, $event->guestIdentifier);
$this->maybeCreateMilestoneNotification($event, $guestLabel); $this->maybeCreateMilestoneNotification($event, $guestLabel);
} }
@@ -87,4 +112,94 @@ class SendPhotoUploadedNotification
return $guestIdentifier; return $guestIdentifier;
} }
private function findRecentPhotoNotification(int $eventId): ?GuestNotification
{
$cutoff = Carbon::now()->subMinutes(self::GROUP_WINDOW_MINUTES);
return GuestNotification::query()
->where('event_id', $eventId)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->active()
->notExpired()
->where('created_at', '>=', $cutoff)
->orderByDesc('id')
->first();
}
private function shouldSkipDuplicate(GuestNotification $notification, int $photoId, string $title): bool
{
$payload = $notification->payload;
if (is_array($payload)) {
$payloadIds = array_filter(
array_map(
fn ($value) => is_numeric($value) ? (int) $value : null,
(array) ($payload['photo_ids'] ?? [])
),
fn ($value) => $value !== null && $value > 0
);
if (in_array($photoId, $payloadIds, true)) {
return true;
}
if (is_numeric($payload['photo_id'] ?? null) && (int) $payload['photo_id'] === $photoId) {
return true;
}
}
$cutoff = Carbon::now()->subSeconds(self::DEDUPE_WINDOW_SECONDS);
if ($notification->created_at instanceof Carbon && $notification->created_at->greaterThanOrEqualTo($cutoff)) {
return $notification->title === $title;
}
return false;
}
private function updateGroupedNotification(GuestNotification $notification, int $photoId): GuestNotification
{
$payload = is_array($notification->payload) ? $notification->payload : [];
$photoIds = array_filter(
array_map(
fn ($value) => is_numeric($value) ? (int) $value : null,
(array) ($payload['photo_ids'] ?? [])
),
fn ($value) => $value !== null && $value > 0
);
$photoIds[] = $photoId;
$photoIds = array_values(array_unique($photoIds));
$photoIds = array_slice($photoIds, 0, self::MAX_GROUP_PHOTOS);
$existingCount = is_numeric($payload['count'] ?? null)
? max(1, (int) $payload['count'])
: max(1, count($photoIds) - 1);
$newCount = $existingCount + 1;
$notification->forceFill([
'title' => $this->buildGroupedTitle($newCount),
'payload' => [
'count' => $newCount,
'photo_ids' => $photoIds,
],
])->save();
return $notification;
}
private function buildGroupedTitle(int $count): string
{
if ($count <= 1) {
return 'Es gibt neue Fotos!';
}
return sprintf('Es gibt %d neue Fotos!', $count);
}
private function markUploaderRead(GuestNotification $notification, string $guestIdentifier): void
{
$guestIdentifier = trim($guestIdentifier);
if ($guestIdentifier === '' || $guestIdentifier === 'anonymous') {
return;
}
$this->notifications->markAsRead($notification, $guestIdentifier);
}
} }

View File

@@ -126,6 +126,36 @@ class GuestNotificationService
return null; return null;
} }
$photoId = Arr::get($payload, 'photo_id');
if (is_numeric($photoId)) {
$photoId = max(1, (int) $photoId);
} else {
$photoId = null;
}
$photoIds = Arr::get($payload, 'photo_ids');
if (is_array($photoIds)) {
$photoIds = array_values(array_unique(array_filter(array_map(function ($value) {
if (! is_numeric($value)) {
return null;
}
$int = (int) $value;
return $int > 0 ? $int : null;
}, $photoIds))));
$photoIds = array_slice($photoIds, 0, 10);
} else {
$photoIds = [];
}
$count = Arr::get($payload, 'count');
if (is_numeric($count)) {
$count = max(1, min(9999, (int) $count));
} else {
$count = null;
}
$cta = Arr::get($payload, 'cta'); $cta = Arr::get($payload, 'cta');
if (is_array($cta)) { if (is_array($cta)) {
$cta = [ $cta = [
@@ -142,6 +172,9 @@ class GuestNotificationService
$clean = array_filter([ $clean = array_filter([
'cta' => $cta, 'cta' => $cta,
'photo_id' => $photoId,
'photo_ids' => $photoIds,
'count' => $count,
]); ]);
return $clean === [] ? null : $clean; return $clean === [] ? null : $clean;

BIN
firefox_i0ktsA4zsn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@@ -27,7 +27,6 @@ import { SettingsSheet } from './settings-sheet';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext'; import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext'; import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
import { usePushSubscription } from '../hooks/usePushSubscription'; import { usePushSubscription } from '../hooks/usePushSubscription';
import { getContrastingTextColor, relativeLuminance } from '../lib/color'; import { getContrastingTextColor, relativeLuminance } from '../lib/color';
import { isTaskModeEnabled } from '../lib/engagement'; import { isTaskModeEnabled } from '../lib/engagement';
@@ -151,7 +150,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
const { event, status } = useEventData(); const { event, status } = useEventData();
const notificationCenter = useOptionalNotificationCenter(); const notificationCenter = useOptionalNotificationCenter();
const [notificationsOpen, setNotificationsOpen] = React.useState(false); const [notificationsOpen, setNotificationsOpen] = React.useState(false);
const taskProgress = useGuestTaskProgress(eventToken);
const tasksEnabled = isTaskModeEnabled(event); const tasksEnabled = isTaskModeEnabled(event);
const panelRef = React.useRef<HTMLDivElement | null>(null); const panelRef = React.useRef<HTMLDivElement | null>(null);
const notificationButtonRef = React.useRef<HTMLButtonElement | null>(null); const notificationButtonRef = React.useRef<HTMLButtonElement | null>(null);
@@ -258,7 +256,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
onToggle={() => setNotificationsOpen((prev) => !prev)} onToggle={() => setNotificationsOpen((prev) => !prev)}
panelRef={panelRef} panelRef={panelRef}
buttonRef={notificationButtonRef} buttonRef={notificationButtonRef}
taskProgress={tasksEnabled && taskProgress?.hydrated ? taskProgress : undefined}
t={t} t={t}
/> />
)} )}
@@ -285,18 +282,14 @@ type NotificationButtonProps = {
onToggle: () => void; onToggle: () => void;
panelRef: React.RefObject<HTMLDivElement | null>; panelRef: React.RefObject<HTMLDivElement | null>;
buttonRef: React.RefObject<HTMLButtonElement | null>; buttonRef: React.RefObject<HTMLButtonElement | null>;
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
t: TranslateFn; t: TranslateFn;
}; };
type PushState = ReturnType<typeof usePushSubscription>; type PushState = ReturnType<typeof usePushSubscription>;
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, taskProgress, t }: NotificationButtonProps) { function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) {
const badgeCount = center.unreadCount + center.pendingCount + center.queueCount; const badgeCount = center.unreadCount;
const progressRatio = taskProgress const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all');
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
: 0;
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all');
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all'); const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all');
const pushState = usePushSubscription(eventToken); const pushState = usePushSubscription(eventToken);
@@ -321,7 +314,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
case 'unread': case 'unread':
base = unreadNotifications; base = unreadNotifications;
break; break;
case 'status': case 'uploads':
base = uploadNotifications; base = uploadNotifications;
break; break;
default: default:
@@ -331,7 +324,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]); }, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
const scopedNotifications = React.useMemo(() => { const scopedNotifications = React.useMemo(() => {
if (scopeFilter === 'all') { if (activeTab === 'uploads' || scopeFilter === 'all') {
return filteredNotifications; return filteredNotifications;
} }
return filteredNotifications.filter((item) => { return filteredNotifications.filter((item) => {
@@ -365,10 +358,10 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Benachrichtigungen')}</p> <p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Updates')}</p>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
{center.unreadCount > 0 {center.unreadCount > 0
? t('header.notifications.unread', { defaultValue: '{{count}} neu', count: center.unreadCount }) ? t('header.notifications.unread', { defaultValue: '{count} neu', count: center.unreadCount })
: t('header.notifications.allRead', 'Alles gelesen')} : t('header.notifications.allRead', 'Alles gelesen')}
</p> </p>
</div> </div>
@@ -384,67 +377,43 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
</div> </div>
<NotificationTabs <NotificationTabs
tabs={[ tabs={[
{ key: 'unread', label: t('header.notifications.tabUnread', 'Neu'), badge: unreadNotifications.length }, { key: 'unread', label: t('header.notifications.tabUnread', 'Nachrichten'), badge: unreadNotifications.length },
{ key: 'status', label: t('header.notifications.tabStatus', 'Uploads/Status'), badge: uploadNotifications.length }, { key: 'uploads', label: t('header.notifications.tabUploads', 'Uploads'), badge: uploadNotifications.length },
{ key: 'all', label: t('header.notifications.tabAll', 'Alle'), badge: center.notifications.length }, { key: 'all', label: t('header.notifications.tabAll', 'Alle Updates'), badge: center.notifications.length },
]} ]}
activeTab={activeTab} activeTab={activeTab}
onTabChange={(next) => setActiveTab(next as typeof activeTab)} onTabChange={(next) => setActiveTab(next as typeof activeTab)}
/> />
<div className="mt-3"> {activeTab !== 'uploads' && (
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1"> <div className="mt-3">
{( <div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
[ {(
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') }, [
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') }, { key: 'all', label: t('header.notifications.scope.all', 'Alle') },
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') }, { key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
] as const { key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
).map((option) => ( ] as const
<button ).map((option) => (
key={option.key} <button
type="button" key={option.key}
onClick={() => { type="button"
setScopeFilter(option.key); onClick={() => {
center.setFilters({ scope: option.key }); setScopeFilter(option.key);
}} center.setFilters({ scope: option.key });
className={`rounded-full border px-3 py-1 font-semibold transition ${ }}
scopeFilter === option.key className={`rounded-full border px-3 py-1 font-semibold transition ${
? 'border-pink-200 bg-pink-50 text-pink-700' scopeFilter === option.key
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700' ? 'border-pink-200 bg-pink-50 text-pink-700'
}`} : 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
> }`}
{option.label} >
</button> {option.label}
))} </button>
))}
</div>
</div> </div>
</div> )}
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1"> {activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
{center.loading ? (
<NotificationSkeleton />
) : scopedNotifications.length === 0 ? (
<NotificationEmptyState
t={t}
message={
activeTab === 'unread'
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
: activeTab === 'status'
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
: undefined
}
/>
) : (
scopedNotifications.map((item) => (
<NotificationListItem
key={item.id}
item={item}
onMarkRead={() => center.markAsRead(item.id)}
onDismiss={() => center.dismiss(item.id)}
t={t}
/>
))
)}
</div>
{activeTab === 'status' && (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{center.pendingCount > 0 && ( {center.pendingCount > 0 && (
<div className="flex items-center justify-between rounded-xl bg-amber-50/90 px-3 py-2 text-xs text-amber-900"> <div className="flex items-center justify-between rounded-xl bg-amber-50/90 px-3 py-2 text-xs text-amber-900">
@@ -478,30 +447,32 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
)} )}
</div> </div>
)} )}
{taskProgress && ( <div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3"> {center.loading ? (
<div className="flex items-center justify-between"> <NotificationSkeleton />
<div> ) : scopedNotifications.length === 0 ? (
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}</p> <NotificationEmptyState
<p className="text-lg font-semibold text-slate-900"> t={t}
{taskProgress.completedCount}/{TASK_BADGE_TARGET} message={
</p> activeTab === 'unread'
</div> ? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
<Link : activeTab === 'uploads'
to={`/e/${encodeURIComponent(eventToken)}/tasks`} ? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-pink-600 transition hover:border-pink-300" : undefined
> }
{t('header.notifications.tasksCta', 'Weiter')} />
</Link> ) : (
</div> scopedNotifications.map((item) => (
<div className="mt-3 h-1.5 w-full rounded-full bg-slate-100"> <NotificationListItem
<div key={item.id}
className="h-full rounded-full bg-pink-500" item={item}
style={{ width: `${progressRatio * 100}%` }} onMarkRead={() => center.markAsRead(item.id)}
onDismiss={() => center.dismiss(item.id)}
t={t}
/> />
</div> ))
</div> )}
)} </div>
<NotificationStatusBar <NotificationStatusBar
lastFetchedAt={center.lastFetchedAt} lastFetchedAt={center.lastFetchedAt}
isOffline={center.isOffline} isOffline={center.isOffline}

View File

@@ -38,7 +38,6 @@ vi.mock('../../context/NotificationCenterContext', () => ({
queueItems: [], queueItems: [],
queueCount: 0, queueCount: 0,
pendingCount: 0, pendingCount: 0,
totalCount: 0,
loading: false, loading: false,
pendingLoading: false, pendingLoading: false,
refresh: vi.fn(), refresh: vi.fn(),
@@ -97,10 +96,10 @@ describe('Header notifications toggle', () => {
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen'); const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
fireEvent.click(bellButton); fireEvent.click(bellButton);
expect(screen.getByText('Benachrichtigungen')).toBeInTheDocument(); expect(screen.getByText('Updates')).toBeInTheDocument();
fireEvent.click(bellButton); fireEvent.click(bellButton);
expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument(); expect(screen.queryByText('Updates')).not.toBeInTheDocument();
}); });
}); });

View File

@@ -16,7 +16,6 @@ export type NotificationCenterValue = {
queueItems: QueueItem[]; queueItems: QueueItem[];
queueCount: number; queueCount: number;
pendingCount: number; pendingCount: number;
totalCount: number;
loading: boolean; loading: boolean;
pendingLoading: boolean; pendingLoading: boolean;
refresh: () => Promise<void>; refresh: () => Promise<void>;
@@ -264,11 +263,9 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
}, [loadNotifications, refreshQueue, loadPendingUploads]); }, [loadNotifications, refreshQueue, loadPendingUploads]);
const loading = loadingNotifications || queueLoading || pendingLoading; const loading = loadingNotifications || queueLoading || pendingLoading;
const totalCount = unreadCount + queueCount + pendingCount;
React.useEffect(() => { React.useEffect(() => {
void updateAppBadge(totalCount); void updateAppBadge(unreadCount);
}, [totalCount]); }, [unreadCount]);
const value: NotificationCenterValue = { const value: NotificationCenterValue = {
notifications, notifications,
@@ -276,7 +273,6 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
queueItems: items, queueItems: items,
queueCount, queueCount,
pendingCount, pendingCount,
totalCount,
loading, loading,
pendingLoading, pendingLoading,
refresh, refresh,

View File

@@ -42,7 +42,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
}, },
helpGallery: 'Hilfe zu Galerie & Teilen', helpGallery: 'Hilfe zu Galerie & Teilen',
notifications: { notifications: {
tabStatus: 'Upload-Status', title: 'Updates',
unread: '{count} neu',
allRead: 'Alles gelesen',
tabUnread: 'Nachrichten',
tabUploads: 'Uploads',
tabAll: 'Alle Updates',
emptyStatus: 'Keine Upload-Hinweise oder Wartungen aktiv.',
}, },
}, },
liveShowPlayer: { liveShowPlayer: {
@@ -774,7 +780,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
}, },
helpGallery: 'Help: Gallery & sharing', helpGallery: 'Help: Gallery & sharing',
notifications: { notifications: {
tabStatus: 'Upload status', title: 'Updates',
unread: '{count} new',
allRead: 'All read',
tabUnread: 'Messages',
tabUploads: 'Uploads',
tabAll: 'All updates',
emptyStatus: 'No upload status or maintenance active.',
}, },
}, },
liveShowPlayer: { liveShowPlayer: {

View File

@@ -0,0 +1,26 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Event;
use App\Models\Photo;
class PhotoModerationControllerTest extends TenantTestCase
{
public function test_tenant_admin_can_approve_photo(): void
{
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'moderation-event',
]);
$photo = Photo::factory()->for($event)->create([
'status' => 'pending',
]);
$response = $this->authenticatedRequest('PATCH', "/api/v1/tenant/events/{$event->slug}/photos/{$photo->id}", [
'status' => 'approved',
]);
$response->assertOk();
$this->assertSame('approved', $photo->refresh()->status);
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace Tests\Unit;
use App\Enums\GuestNotificationType;
use App\Events\GuestPhotoUploaded;
use App\Listeners\GuestNotifications\SendPhotoUploadedNotification;
use App\Models\Event;
use App\Models\GuestNotification;
use App\Models\GuestNotificationReceipt;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class SendPhotoUploadedNotificationTest extends TestCase
{
use RefreshDatabase;
public function test_it_dedupes_recent_photo_activity_notifications(): void
{
Carbon::setTestNow('2026-01-12 13:48:01');
$event = Event::factory()->create();
$listener = $this->app->make(SendPhotoUploadedNotification::class);
GuestNotification::factory()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'type' => GuestNotificationType::PHOTO_ACTIVITY,
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
'payload' => [
'photo_id' => 123,
'photo_ids' => [123],
'count' => 1,
],
'created_at' => now()->subSeconds(5),
'updated_at' => now()->subSeconds(5),
]);
$listener->handle(new GuestPhotoUploaded(
$event,
123,
'device-123',
'Fotospiel-Test'
));
$notification = GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->first();
$this->assertSame(1, GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->count());
$this->assertSame(1, (int) ($notification?->payload['count'] ?? 0));
}
public function test_it_groups_recent_photo_activity_notifications(): void
{
Carbon::setTestNow('2026-01-12 13:48:01');
$event = Event::factory()->create();
$listener = $this->app->make(SendPhotoUploadedNotification::class);
GuestNotification::factory()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'type' => GuestNotificationType::PHOTO_ACTIVITY,
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
'payload' => [
'photo_id' => 122,
'photo_ids' => [122],
'count' => 1,
],
'created_at' => now()->subMinutes(5),
'updated_at' => now()->subMinutes(5),
]);
$listener->handle(new GuestPhotoUploaded(
$event,
123,
'device-123',
'Fotospiel-Test'
));
$this->assertSame(1, GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->count());
$notification = GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->first();
$this->assertSame('Es gibt 2 neue Fotos!', $notification?->title);
$this->assertSame(2, (int) ($notification?->payload['count'] ?? 0));
$this->assertSame(1, GuestNotificationReceipt::query()
->where('guest_identifier', 'device-123')
->where('status', 'read')
->count());
}
public function test_it_creates_notification_outside_group_window(): void
{
Carbon::setTestNow('2026-01-12 13:48:01');
$event = Event::factory()->create();
$listener = $this->app->make(SendPhotoUploadedNotification::class);
GuestNotification::factory()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'type' => GuestNotificationType::PHOTO_ACTIVITY,
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
'payload' => [
'photo_id' => 122,
'photo_ids' => [122],
'count' => 1,
],
'created_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
$listener->handle(new GuestPhotoUploaded(
$event,
123,
'device-123',
'Fotospiel-Test'
));
$this->assertSame(2, GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->count());
$this->assertSame(1, GuestNotificationReceipt::query()
->where('guest_identifier', 'device-123')
->where('status', 'read')
->count());
}
}