diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index 25c8ebf..5429342 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -525,13 +525,13 @@ class PhotoController extends Controller ]); // 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( 'insufficient_scope', 'Insufficient Scopes', 'You are not allowed to moderate photos for this event.', 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 { + $accessToken = $request->user()?->currentAccessToken(); + if ($accessToken && $accessToken->can($scope)) { + return true; + } + $scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []); if (! is_array($scopes)) { diff --git a/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php b/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php index d5cb599..1ba077b 100644 --- a/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php +++ b/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php @@ -5,11 +5,19 @@ namespace App\Listeners\GuestNotifications; use App\Enums\GuestNotificationAudience; use App\Enums\GuestNotificationType; use App\Events\GuestPhotoUploaded; +use App\Models\GuestNotification; use App\Models\Photo; use App\Services\GuestNotificationService; +use Illuminate\Support\Carbon; class SendPhotoUploadedNotification { + private const DEDUPE_WINDOW_SECONDS = 30; + + private const GROUP_WINDOW_MINUTES = 10; + + private const MAX_GROUP_PHOTOS = 6; + /** * @param int[] $milestones */ @@ -25,7 +33,20 @@ class SendPhotoUploadedNotification ? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel) : '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, GuestNotificationType::PHOTO_ACTIVITY, $title, @@ -34,11 +55,15 @@ class SendPhotoUploadedNotification 'audience_scope' => GuestNotificationAudience::ALL, 'payload' => [ 'photo_id' => $event->photoId, + 'photo_ids' => [$event->photoId], + 'count' => 1, ], 'expires_at' => now()->addHours(3), ] ); + $this->markUploaderRead($notification, $event->guestIdentifier); + $this->maybeCreateMilestoneNotification($event, $guestLabel); } @@ -87,4 +112,94 @@ class SendPhotoUploadedNotification 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); + } } diff --git a/app/Services/GuestNotificationService.php b/app/Services/GuestNotificationService.php index ed27b9b..8bbd94b 100644 --- a/app/Services/GuestNotificationService.php +++ b/app/Services/GuestNotificationService.php @@ -126,6 +126,36 @@ class GuestNotificationService 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'); if (is_array($cta)) { $cta = [ @@ -142,6 +172,9 @@ class GuestNotificationService $clean = array_filter([ 'cta' => $cta, + 'photo_id' => $photoId, + 'photo_ids' => $photoIds, + 'count' => $count, ]); return $clean === [] ? null : $clean; diff --git a/firefox_i0ktsA4zsn.png b/firefox_i0ktsA4zsn.png new file mode 100644 index 0000000..e11f435 Binary files /dev/null and b/firefox_i0ktsA4zsn.png differ diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index 224ca59..20b136d 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -27,7 +27,6 @@ import { SettingsSheet } from './settings-sheet'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext'; import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext'; -import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress'; import { usePushSubscription } from '../hooks/usePushSubscription'; import { getContrastingTextColor, relativeLuminance } from '../lib/color'; import { isTaskModeEnabled } from '../lib/engagement'; @@ -151,7 +150,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string const { event, status } = useEventData(); const notificationCenter = useOptionalNotificationCenter(); const [notificationsOpen, setNotificationsOpen] = React.useState(false); - const taskProgress = useGuestTaskProgress(eventToken); const tasksEnabled = isTaskModeEnabled(event); const panelRef = React.useRef(null); const notificationButtonRef = React.useRef(null); @@ -258,7 +256,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string onToggle={() => setNotificationsOpen((prev) => !prev)} panelRef={panelRef} buttonRef={notificationButtonRef} - taskProgress={tasksEnabled && taskProgress?.hydrated ? taskProgress : undefined} t={t} /> )} @@ -285,18 +282,14 @@ type NotificationButtonProps = { onToggle: () => void; panelRef: React.RefObject; buttonRef: React.RefObject; - taskProgress?: ReturnType; t: TranslateFn; }; type PushState = ReturnType; -function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, taskProgress, t }: NotificationButtonProps) { - const badgeCount = center.unreadCount + center.pendingCount + center.queueCount; - const progressRatio = taskProgress - ? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET) - : 0; - const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all'); +function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) { + const badgeCount = center.unreadCount; + const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all'); const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all'); const pushState = usePushSubscription(eventToken); @@ -321,7 +314,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt case 'unread': base = unreadNotifications; break; - case 'status': + case 'uploads': base = uploadNotifications; break; default: @@ -331,7 +324,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt }, [activeTab, center.notifications, unreadNotifications, uploadNotifications]); const scopedNotifications = React.useMemo(() => { - if (scopeFilter === 'all') { + if (activeTab === 'uploads' || scopeFilter === 'all') { return filteredNotifications; } return filteredNotifications.filter((item) => { @@ -365,10 +358,10 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt >
-

{t('header.notifications.title', 'Benachrichtigungen')}

+

{t('header.notifications.title', 'Updates')}

{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')}

@@ -384,67 +377,43 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
setActiveTab(next as typeof activeTab)} /> -
-
- {( - [ - { key: 'all', label: t('header.notifications.scope.all', 'Alle') }, - { key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') }, - { key: 'general', label: t('header.notifications.scope.general', 'Allgemein') }, - ] as const - ).map((option) => ( - - ))} + {activeTab !== 'uploads' && ( +
+
+ {( + [ + { key: 'all', label: t('header.notifications.scope.all', 'Alle') }, + { key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') }, + { key: 'general', label: t('header.notifications.scope.general', 'Allgemein') }, + ] as const + ).map((option) => ( + + ))} +
-
-
- {center.loading ? ( - - ) : scopedNotifications.length === 0 ? ( - - ) : ( - scopedNotifications.map((item) => ( - center.markAsRead(item.id)} - onDismiss={() => center.dismiss(item.id)} - t={t} - /> - )) - )} -
- {activeTab === 'status' && ( + )} + {activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
{center.pendingCount > 0 && (
@@ -478,30 +447,32 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt )}
)} - {taskProgress && ( -
-
-
-

{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}

-

- {taskProgress.completedCount}/{TASK_BADGE_TARGET} -

-
- - {t('header.notifications.tasksCta', 'Weiter')} - -
-
-
+ {center.loading ? ( + + ) : scopedNotifications.length === 0 ? ( + + ) : ( + scopedNotifications.map((item) => ( + center.markAsRead(item.id)} + onDismiss={() => center.dismiss(item.id)} + t={t} /> -
-
- )} + )) + )} +
({ queueItems: [], queueCount: 0, pendingCount: 0, - totalCount: 0, loading: false, pendingLoading: false, refresh: vi.fn(), @@ -97,10 +96,10 @@ describe('Header notifications toggle', () => { const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen'); fireEvent.click(bellButton); - expect(screen.getByText('Benachrichtigungen')).toBeInTheDocument(); + expect(screen.getByText('Updates')).toBeInTheDocument(); fireEvent.click(bellButton); - expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument(); + expect(screen.queryByText('Updates')).not.toBeInTheDocument(); }); }); diff --git a/resources/js/guest/context/NotificationCenterContext.tsx b/resources/js/guest/context/NotificationCenterContext.tsx index 817b8f0..ef023c0 100644 --- a/resources/js/guest/context/NotificationCenterContext.tsx +++ b/resources/js/guest/context/NotificationCenterContext.tsx @@ -16,7 +16,6 @@ export type NotificationCenterValue = { queueItems: QueueItem[]; queueCount: number; pendingCount: number; - totalCount: number; loading: boolean; pendingLoading: boolean; refresh: () => Promise; @@ -264,11 +263,9 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke }, [loadNotifications, refreshQueue, loadPendingUploads]); const loading = loadingNotifications || queueLoading || pendingLoading; - const totalCount = unreadCount + queueCount + pendingCount; - React.useEffect(() => { - void updateAppBadge(totalCount); - }, [totalCount]); + void updateAppBadge(unreadCount); + }, [unreadCount]); const value: NotificationCenterValue = { notifications, @@ -276,7 +273,6 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke queueItems: items, queueCount, pendingCount, - totalCount, loading, pendingLoading, refresh, diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index 6a8291f..83b15ec 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -42,7 +42,13 @@ export const messages: Record = { }, helpGallery: 'Hilfe zu Galerie & Teilen', 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: { @@ -774,7 +780,13 @@ export const messages: Record = { }, helpGallery: 'Help: Gallery & sharing', 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: { diff --git a/tests/Feature/Tenant/PhotoModerationControllerTest.php b/tests/Feature/Tenant/PhotoModerationControllerTest.php new file mode 100644 index 0000000..567125e --- /dev/null +++ b/tests/Feature/Tenant/PhotoModerationControllerTest.php @@ -0,0 +1,26 @@ +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); + } +} diff --git a/tests/Unit/SendPhotoUploadedNotificationTest.php b/tests/Unit/SendPhotoUploadedNotificationTest.php new file mode 100644 index 0000000..2fc38bf --- /dev/null +++ b/tests/Unit/SendPhotoUploadedNotificationTest.php @@ -0,0 +1,144 @@ +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()); + } +}