diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 8245340..c226f5e 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -2018,6 +2018,8 @@ class EventPublicController extends BaseController [$event] = $result; $guestIdentifier = $this->resolveNotificationIdentifier($request); $limit = max(1, min(50, (int) $request->integer('limit', 35))); + $statusFilter = $request->string('status')->lower()->value(); + $scopeFilter = $request->string('scope')->lower()->value(); if (! Schema::hasTable('guest_notifications')) { return $this->emptyNotificationsResponse($request, $event->id, 'disabled'); @@ -2029,6 +2031,31 @@ class EventPublicController extends BaseController ->notExpired() ->visibleToGuest($guestIdentifier); + if ($statusFilter === 'unread') { + $baseQuery->where(function ($query) use ($guestIdentifier) { + $query->whereDoesntHave('receipts', fn ($receipt) => $receipt->where('guest_identifier', $guestIdentifier)) + ->orWhereHas('receipts', fn ($receipt) => $receipt + ->where('guest_identifier', $guestIdentifier) + ->where('status', GuestNotificationDeliveryStatus::NEW->value)); + }); + } elseif ($statusFilter === 'read') { + $baseQuery->whereHas('receipts', fn ($receipt) => $receipt + ->where('guest_identifier', $guestIdentifier) + ->where('status', GuestNotificationDeliveryStatus::READ->value)); + } elseif ($statusFilter === 'dismissed') { + $baseQuery->whereHas('receipts', fn ($receipt) => $receipt + ->where('guest_identifier', $guestIdentifier) + ->where('status', GuestNotificationDeliveryStatus::DISMISSED->value)); + } + + if ($scopeFilter === 'uploads') { + $baseQuery->whereIn('type', [GuestNotificationType::UPLOAD_ALERT->value, GuestNotificationType::PHOTO_ACTIVITY->value]); + } elseif ($scopeFilter === 'tips') { + $baseQuery->whereIn('type', [GuestNotificationType::SUPPORT_TIP->value, GuestNotificationType::ACHIEVEMENT_MAJOR->value]); + } elseif ($scopeFilter === 'general') { + $baseQuery->whereIn('type', [GuestNotificationType::BROADCAST->value, GuestNotificationType::FEEDBACK_REQUEST->value]); + } + $notifications = (clone $baseQuery) ->with(['receipts' => fn ($query) => $query->where('guest_identifier', $guestIdentifier)]) ->orderByDesc('priority') diff --git a/app/Http/Controllers/Api/Tenant/NotificationLogController.php b/app/Http/Controllers/Api/Tenant/NotificationLogController.php index 5fe1b8a..04721c7 100644 --- a/app/Http/Controllers/Api/Tenant/NotificationLogController.php +++ b/app/Http/Controllers/Api/Tenant/NotificationLogController.php @@ -3,7 +3,10 @@ namespace App\Http\Controllers\Api\Tenant; use App\Http\Controllers\Controller; +use App\Http\Requests\Tenant\NotificationMarkRequest; use App\Models\TenantNotificationLog; +use App\Models\TenantNotificationReceipt; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -23,8 +26,11 @@ class NotificationLogController extends Controller ], 403); } + $user = $request->user(); + $query = TenantNotificationLog::query() ->where('tenant_id', $tenant->id) + ->with('receipts') ->latest(); if ($type = $request->query('type')) { @@ -35,19 +41,85 @@ class NotificationLogController extends Controller $query->where('status', $status); } + if ($scope = $request->query('scope')) { + $query->where(function (Builder $inner) use ($scope) { + $inner->where('type', $scope) + ->orWhere(function (Builder $ctx) use ($scope) { + $ctx->whereJsonContains('context->scope', $scope); + }); + }); + } + + if ($eventId = $request->query('event_id')) { + $query->where(function (Builder $inner) use ($eventId) { + $inner->where('context->event_id', (int) $eventId) + ->orWhere('context->eventId', (int) $eventId); + }); + } + $perPage = (int) $request->query('per_page', 20); $perPage = max(1, min($perPage, 100)); $logs = $query->paginate($perPage); + $receipts = collect($logs->items()) + ->map(fn ($log) => $log->receipts ?? collect()) + ->flatten(); + + $unreadCount = $receipts + ->filter(fn ($receipt) => $user && $receipt->user_id === $user->id && $receipt->status !== 'read') + ->count(); + + $data = collect($logs->items())->map(function (TenantNotificationLog $log) use ($user) { + $receipt = $user + ? $log->receipts->firstWhere('user_id', $user->id) + : null; + + return array_merge($log->toArray(), [ + 'is_read' => $receipt ? $receipt->status === 'read' : false, + ]); + })->all(); + return response()->json([ - 'data' => $logs->items(), + 'data' => $data, 'meta' => [ 'current_page' => $logs->currentPage(), 'last_page' => $logs->lastPage(), 'per_page' => $logs->perPage(), 'total' => $logs->total(), + 'unread_count' => $unreadCount, ], ]); } + + public function mark(NotificationMarkRequest $request): JsonResponse + { + $tenant = $request->attributes->get('tenant') ?? $request->user()?->tenant; + + if (! $tenant) { + return response()->json([ + 'error' => [ + 'code' => 'tenant_context_missing', + 'title' => 'Tenant context missing', + 'message' => 'Unable to resolve tenant for notification logs.', + ], + ], 403); + } + + $userId = $request->user()?->id; + $status = $request->validated('status'); + $ids = $request->validated('ids'); + + TenantNotificationReceipt::query() + ->where('tenant_id', $tenant->id) + ->whereIn('notification_log_id', $ids) + ->when($userId, fn ($q) => $q->where(function ($inner) use ($userId) { + $inner->whereNull('user_id')->orWhere('user_id', $userId); + })) + ->update(['status' => $status]); + + return response()->json([ + 'message' => 'Notifications updated.', + ]); + } } diff --git a/app/Http/Requests/Tenant/NotificationMarkRequest.php b/app/Http/Requests/Tenant/NotificationMarkRequest.php new file mode 100644 index 0000000..293b404 --- /dev/null +++ b/app/Http/Requests/Tenant/NotificationMarkRequest.php @@ -0,0 +1,22 @@ + ['required', 'array'], + 'ids.*' => ['integer', 'min:1'], + 'status' => ['required', 'in:read,dismissed'], + ]; + } +} diff --git a/app/Jobs/Concerns/LogsTenantNotifications.php b/app/Jobs/Concerns/LogsTenantNotifications.php index 638b681..393ce34 100644 --- a/app/Jobs/Concerns/LogsTenantNotifications.php +++ b/app/Jobs/Concerns/LogsTenantNotifications.php @@ -2,9 +2,12 @@ namespace App\Jobs\Concerns; +use App\Models\TenantNotificationLog; +use App\Models\TenantNotificationReceipt; use App\Models\Tenant; use App\Services\Packages\TenantNotificationLogger; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Carbon; trait LogsTenantNotifications { @@ -18,6 +21,21 @@ trait LogsTenantNotifications $this->notificationLogger()->log($tenant, $attributes); } + protected function createNotificationReceipt( + Tenant $tenant, + TenantNotificationLog $log, + ?int $userId, + ?string $recipient + ): TenantNotificationReceipt { + return TenantNotificationReceipt::query()->create([ + 'tenant_id' => $tenant->id, + 'notification_log_id' => $log->id, + 'user_id' => $userId, + 'recipient' => $recipient, + 'status' => 'delivered', + ]); + } + protected function dispatchToRecipients( Tenant $tenant, iterable $recipients, @@ -29,7 +47,7 @@ trait LogsTenantNotifications try { $callback($recipient); - $this->logNotification($tenant, [ + $log = $this->notificationLogger()->log($tenant, [ 'type' => $type, 'channel' => 'mail', 'recipient' => $recipient, @@ -37,6 +55,13 @@ trait LogsTenantNotifications 'context' => $context, 'sent_at' => now(), ]); + + $this->createNotificationReceipt( + $tenant, + $log, + $tenant->user?->id, + $recipient + ); } catch (\Throwable $e) { Log::error('Tenant notification failed', [ 'tenant_id' => $tenant->id, @@ -57,4 +82,51 @@ trait LogsTenantNotifications } } } + + /** + * Simple idempotency guard to avoid duplicate notifications within a cooldown window. + * + * @param string[] $dedupeKeys + */ + protected function isDuplicateNotification( + Tenant $tenant, + string $type, + array $context, + array $dedupeKeys, + int $cooldownMinutes = 1440 + ): bool { + $window = Carbon::now()->subMinutes($cooldownMinutes); + + $logs = TenantNotificationLog::query() + ->where('tenant_id', $tenant->id) + ->where('type', $type) + ->whereIn('status', ['sent', 'queued']) + ->where(function ($query) use ($window) { + $query->whereNull('created_at') + ->orWhere('created_at', '>=', $window) + ->orWhere('sent_at', '>=', $window); + }) + ->get(); + + foreach ($logs as $log) { + $existing = is_array($log->context) ? $log->context : []; + $matches = true; + + foreach ($dedupeKeys as $key) { + $currentValue = $context[$key] ?? null; + $existingValue = $existing[$key] ?? null; + + if ($currentValue != $existingValue) { + $matches = false; + break; + } + } + + if ($matches) { + return true; + } + } + + return false; + } } diff --git a/app/Jobs/Packages/SendEventPackageGalleryExpired.php b/app/Jobs/Packages/SendEventPackageGalleryExpired.php index 1325650..0b3fdbd 100644 --- a/app/Jobs/Packages/SendEventPackageGalleryExpired.php +++ b/app/Jobs/Packages/SendEventPackageGalleryExpired.php @@ -41,11 +41,24 @@ class SendEventPackageGalleryExpired implements ShouldQueue } $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); + + $context = $this->context($eventPackage); + + if ($this->isDuplicateNotification($tenant, 'gallery_expired', $context, ['event_package_id'])) { + $this->logNotification($tenant, [ + 'type' => 'gallery_expired', + 'status' => 'skipped', + 'context' => array_merge($context, ['reason' => 'duplicate']), + ]); + + return; + } + if (! $preferences->shouldNotify($tenant, 'gallery_expired')) { $this->logNotification($tenant, [ 'type' => 'gallery_expired', 'status' => 'skipped', - 'context' => $this->context($eventPackage), + 'context' => $context, ]); return; @@ -65,14 +78,12 @@ class SendEventPackageGalleryExpired implements ShouldQueue $this->logNotification($tenant, [ 'type' => 'gallery_expired', 'status' => 'skipped', - 'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']), + 'context' => array_merge($context, ['reason' => 'no_recipient']), ]); return; } - $context = $this->context($eventPackage); - $this->dispatchToRecipients( $tenant, $emails, diff --git a/app/Jobs/Packages/SendEventPackageGalleryWarning.php b/app/Jobs/Packages/SendEventPackageGalleryWarning.php index f1ba151..4a16164 100644 --- a/app/Jobs/Packages/SendEventPackageGalleryWarning.php +++ b/app/Jobs/Packages/SendEventPackageGalleryWarning.php @@ -44,11 +44,24 @@ class SendEventPackageGalleryWarning implements ShouldQueue } $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); + + $context = $this->context($eventPackage); + + if ($this->isDuplicateNotification($tenant, 'gallery_warning', $context, ['event_package_id', 'days_remaining'])) { + $this->logNotification($tenant, [ + 'type' => 'gallery_warning', + 'status' => 'skipped', + 'context' => array_merge($context, ['reason' => 'duplicate']), + ]); + + return; + } + if (! $preferences->shouldNotify($tenant, 'gallery_warnings')) { $this->logNotification($tenant, [ 'type' => 'gallery_warning', 'status' => 'skipped', - 'context' => $this->context($eventPackage), + 'context' => $context, ]); return; @@ -69,14 +82,12 @@ class SendEventPackageGalleryWarning implements ShouldQueue $this->logNotification($tenant, [ 'type' => 'gallery_warning', 'status' => 'skipped', - 'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']), + 'context' => array_merge($context, ['reason' => 'no_recipient']), ]); return; } - $context = $this->context($eventPackage); - $this->dispatchToRecipients( $tenant, $emails, diff --git a/app/Jobs/Packages/SendEventPackageGuestLimitNotification.php b/app/Jobs/Packages/SendEventPackageGuestLimitNotification.php index b7ca00e..b60ed51 100644 --- a/app/Jobs/Packages/SendEventPackageGuestLimitNotification.php +++ b/app/Jobs/Packages/SendEventPackageGuestLimitNotification.php @@ -43,12 +43,24 @@ class SendEventPackageGuestLimitNotification implements ShouldQueue return; } + $context = $this->context($eventPackage); + + if ($this->isDuplicateNotification($tenant, 'guest_limit', $context, ['event_package_id', 'limit'])) { + $this->logNotification($tenant, [ + 'type' => 'guest_limit', + 'status' => 'skipped', + 'context' => array_merge($context, ['reason' => 'duplicate']), + ]); + + return; + } + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'guest_limits')) { $this->logNotification($tenant, [ 'type' => 'guest_limit', 'status' => 'skipped', - 'context' => $this->context($eventPackage), + 'context' => $context, ]); return; @@ -68,14 +80,12 @@ class SendEventPackageGuestLimitNotification implements ShouldQueue $this->logNotification($tenant, [ 'type' => 'guest_limit', 'status' => 'skipped', - 'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']), + 'context' => array_merge($context, ['reason' => 'no_recipient']), ]); return; } - $context = $this->context($eventPackage); - $this->dispatchToRecipients( $tenant, $emails, diff --git a/app/Jobs/Packages/SendEventPackageGuestThresholdWarning.php b/app/Jobs/Packages/SendEventPackageGuestThresholdWarning.php index c82baad..da10da2 100644 --- a/app/Jobs/Packages/SendEventPackageGuestThresholdWarning.php +++ b/app/Jobs/Packages/SendEventPackageGuestThresholdWarning.php @@ -45,12 +45,24 @@ class SendEventPackageGuestThresholdWarning implements ShouldQueue return; } + $context = $this->context($eventPackage); + + if ($this->isDuplicateNotification($tenant, 'guest_threshold', $context, ['event_package_id', 'threshold', 'limit'])) { + $this->logNotification($tenant, [ + 'type' => 'guest_threshold', + 'status' => 'skipped', + 'context' => array_merge($context, ['reason' => 'duplicate']), + ]); + + return; + } + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'guest_thresholds')) { $this->logNotification($tenant, [ 'type' => 'guest_threshold', 'status' => 'skipped', - 'context' => $this->context($eventPackage), + 'context' => $context, ]); return; @@ -71,14 +83,12 @@ class SendEventPackageGuestThresholdWarning implements ShouldQueue $this->logNotification($tenant, [ 'type' => 'guest_threshold', 'status' => 'skipped', - 'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']), + 'context' => array_merge($context, ['reason' => 'no_recipient']), ]); return; } - $context = $this->context($eventPackage); - $this->dispatchToRecipients( $tenant, $emails, diff --git a/app/Jobs/Packages/SendEventPackagePhotoLimitNotification.php b/app/Jobs/Packages/SendEventPackagePhotoLimitNotification.php index f6adf0c..6b1401a 100644 --- a/app/Jobs/Packages/SendEventPackagePhotoLimitNotification.php +++ b/app/Jobs/Packages/SendEventPackagePhotoLimitNotification.php @@ -43,12 +43,24 @@ class SendEventPackagePhotoLimitNotification implements ShouldQueue return; } + $context = $this->context($eventPackage); + + if ($this->isDuplicateNotification($tenant, 'photo_limit', $context, ['event_package_id', 'limit'])) { + $this->logNotification($tenant, [ + 'type' => 'photo_limit', + 'status' => 'skipped', + 'context' => array_merge($context, ['reason' => 'duplicate']), + ]); + + return; + } + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'photo_limits')) { $this->logNotification($tenant, [ 'type' => 'photo_limit', 'status' => 'skipped', - 'context' => $this->context($eventPackage), + 'context' => $context, ]); return; @@ -69,14 +81,12 @@ class SendEventPackagePhotoLimitNotification implements ShouldQueue $this->logNotification($tenant, [ 'type' => 'photo_limit', 'status' => 'skipped', - 'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']), + 'context' => array_merge($context, ['reason' => 'no_recipient']), ]); return; } - $context = $this->context($eventPackage); - $this->dispatchToRecipients( $tenant, $emails, diff --git a/app/Jobs/Packages/SendEventPackagePhotoThresholdWarning.php b/app/Jobs/Packages/SendEventPackagePhotoThresholdWarning.php index 1cf50ac..5c4adf9 100644 --- a/app/Jobs/Packages/SendEventPackagePhotoThresholdWarning.php +++ b/app/Jobs/Packages/SendEventPackagePhotoThresholdWarning.php @@ -45,12 +45,24 @@ class SendEventPackagePhotoThresholdWarning implements ShouldQueue return; } + $context = $this->context($eventPackage); + + if ($this->isDuplicateNotification($tenant, 'photo_threshold', $context, ['event_package_id', 'threshold', 'limit'])) { + $this->logNotification($tenant, [ + 'type' => 'photo_threshold', + 'status' => 'skipped', + 'context' => array_merge($context, ['reason' => 'duplicate']), + ]); + + return; + } + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'photo_thresholds')) { $this->logNotification($tenant, [ 'type' => 'photo_threshold', 'status' => 'skipped', - 'context' => $this->context($eventPackage), + 'context' => $context, ]); return; @@ -71,14 +83,12 @@ class SendEventPackagePhotoThresholdWarning implements ShouldQueue $this->logNotification($tenant, [ 'type' => 'photo_threshold', 'status' => 'skipped', - 'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']), + 'context' => array_merge($context, ['reason' => 'no_recipient']), ]); return; } - $context = $this->context($eventPackage); - $this->dispatchToRecipients( $tenant, $emails, diff --git a/app/Jobs/Packages/SendTenantPackageEventLimitNotification.php b/app/Jobs/Packages/SendTenantPackageEventLimitNotification.php index 7c8da4a..951c1ee 100644 --- a/app/Jobs/Packages/SendTenantPackageEventLimitNotification.php +++ b/app/Jobs/Packages/SendTenantPackageEventLimitNotification.php @@ -40,12 +40,24 @@ class SendTenantPackageEventLimitNotification implements ShouldQueue $tenant = $tenantPackage->tenant; + $context = $this->context($tenantPackage); + + if ($this->isDuplicateNotification($tenant, 'event_limit', $context, ['tenant_package_id', 'limit'])) { + $this->logNotification($tenant, [ + 'type' => 'event_limit', + 'status' => 'skipped', + 'context' => array_merge($context, ['reason' => 'duplicate']), + ]); + + return; + } + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'event_limits')) { $this->logNotification($tenant, [ 'type' => 'event_limit', 'status' => 'skipped', - 'context' => $this->context($tenantPackage), + 'context' => $context, ]); return; @@ -65,14 +77,12 @@ class SendTenantPackageEventLimitNotification implements ShouldQueue $this->logNotification($tenant, [ 'type' => 'event_limit', 'status' => 'skipped', - 'context' => array_merge($this->context($tenantPackage), ['reason' => 'no_recipient']), + 'context' => array_merge($context, ['reason' => 'no_recipient']), ]); return; } - $context = $this->context($tenantPackage); - $this->dispatchToRecipients( $tenant, $emails, diff --git a/app/Jobs/Packages/SendTenantPackageEventThresholdWarning.php b/app/Jobs/Packages/SendTenantPackageEventThresholdWarning.php index 5f8f895..5256b50 100644 --- a/app/Jobs/Packages/SendTenantPackageEventThresholdWarning.php +++ b/app/Jobs/Packages/SendTenantPackageEventThresholdWarning.php @@ -42,12 +42,24 @@ class SendTenantPackageEventThresholdWarning implements ShouldQueue $tenant = $tenantPackage->tenant; + $context = $this->context($tenantPackage); + + if ($this->isDuplicateNotification($tenant, 'event_threshold', $context, ['tenant_package_id', 'threshold', 'limit'])) { + $this->logNotification($tenant, [ + 'type' => 'event_threshold', + 'status' => 'skipped', + 'context' => array_merge($context, ['reason' => 'duplicate']), + ]); + + return; + } + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'event_thresholds')) { $this->logNotification($tenant, [ 'type' => 'event_threshold', 'status' => 'skipped', - 'context' => $this->context($tenantPackage), + 'context' => $context, ]); return; @@ -68,14 +80,12 @@ class SendTenantPackageEventThresholdWarning implements ShouldQueue $this->logNotification($tenant, [ 'type' => 'event_threshold', 'status' => 'skipped', - 'context' => array_merge($this->context($tenantPackage), ['reason' => 'no_recipient']), + 'context' => array_merge($context, ['reason' => 'no_recipient']), ]); return; } - $context = $this->context($tenantPackage); - $this->dispatchToRecipients( $tenant, $emails, diff --git a/app/Jobs/Packages/SendTenantPackageExpiredNotification.php b/app/Jobs/Packages/SendTenantPackageExpiredNotification.php index f508cdd..ed27691 100644 --- a/app/Jobs/Packages/SendTenantPackageExpiredNotification.php +++ b/app/Jobs/Packages/SendTenantPackageExpiredNotification.php @@ -37,15 +37,26 @@ class SendTenantPackageExpiredNotification implements ShouldQueue $tenant = $tenantPackage->tenant; + $context = [ + 'tenant_package_id' => $tenantPackage->id, + ]; + + if ($this->isDuplicateNotification($tenant, 'package_expired', $context, ['tenant_package_id'])) { + $this->logNotification($tenant, [ + 'type' => 'package_expired', + 'status' => 'skipped', + 'context' => array_merge($context, ['reason' => 'duplicate']), + ]); + + return; + } + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'package_expired')) { $this->logNotification($tenant, [ 'type' => 'package_expired', 'status' => 'skipped', - 'context' => [ - 'tenant_package_id' => $tenantPackage->id, - 'reason' => 'opt_out', - ], + 'context' => array_merge($context, ['reason' => 'opt_out']), ]); return; @@ -65,19 +76,12 @@ class SendTenantPackageExpiredNotification implements ShouldQueue $this->logNotification($tenant, [ 'type' => 'package_expired', 'status' => 'skipped', - 'context' => [ - 'tenant_package_id' => $tenantPackage->id, - 'reason' => 'no_recipient', - ], + 'context' => array_merge($context, ['reason' => 'no_recipient']), ]); return; } - $context = [ - 'tenant_package_id' => $tenantPackage->id, - ]; - $this->dispatchToRecipients( $tenant, $emails, diff --git a/app/Jobs/Packages/SendTenantPackageExpiringNotification.php b/app/Jobs/Packages/SendTenantPackageExpiringNotification.php index 8d064d9..952c3fe 100644 --- a/app/Jobs/Packages/SendTenantPackageExpiringNotification.php +++ b/app/Jobs/Packages/SendTenantPackageExpiringNotification.php @@ -40,16 +40,27 @@ class SendTenantPackageExpiringNotification implements ShouldQueue $tenant = $tenantPackage->tenant; + $context = [ + 'tenant_package_id' => $tenantPackage->id, + 'days_remaining' => $this->daysRemaining, + ]; + + if ($this->isDuplicateNotification($tenant, 'package_expiring', $context, ['tenant_package_id', 'days_remaining'])) { + $this->logNotification($tenant, [ + 'type' => 'package_expiring', + 'status' => 'skipped', + 'context' => array_merge($context, ['reason' => 'duplicate']), + ]); + + return; + } + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'package_expiring')) { $this->logNotification($tenant, [ 'type' => 'package_expiring', 'status' => 'skipped', - 'context' => [ - 'tenant_package_id' => $tenantPackage->id, - 'days_remaining' => $this->daysRemaining, - 'reason' => 'opt_out', - ], + 'context' => array_merge($context, ['reason' => 'opt_out']), ]); return; @@ -70,21 +81,12 @@ class SendTenantPackageExpiringNotification implements ShouldQueue $this->logNotification($tenant, [ 'type' => 'package_expiring', 'status' => 'skipped', - 'context' => [ - 'tenant_package_id' => $tenantPackage->id, - 'days_remaining' => $this->daysRemaining, - 'reason' => 'no_recipient', - ], + 'context' => array_merge($context, ['reason' => 'no_recipient']), ]); return; } - $context = [ - 'tenant_package_id' => $tenantPackage->id, - 'days_remaining' => $this->daysRemaining, - ]; - $this->dispatchToRecipients( $tenant, $emails, diff --git a/app/Models/TenantNotificationLog.php b/app/Models/TenantNotificationLog.php index 97b9141..645d5da 100644 --- a/app/Models/TenantNotificationLog.php +++ b/app/Models/TenantNotificationLog.php @@ -28,4 +28,9 @@ class TenantNotificationLog extends Model { return $this->belongsTo(Tenant::class); } + + public function receipts() + { + return $this->hasMany(TenantNotificationReceipt::class, 'notification_log_id'); + } } diff --git a/app/Models/TenantNotificationReceipt.php b/app/Models/TenantNotificationReceipt.php new file mode 100644 index 0000000..ac4bf15 --- /dev/null +++ b/app/Models/TenantNotificationReceipt.php @@ -0,0 +1,36 @@ + 'datetime', + 'updated_at' => 'datetime', + ]; + + public function tenant() + { + return $this->belongsTo(Tenant::class); + } + + public function notificationLog() + { + return $this->belongsTo(TenantNotificationLog::class, 'notification_log_id'); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/database/migrations/2025_03_01_000000_create_tenant_notification_receipts_table.php b/database/migrations/2025_03_01_000000_create_tenant_notification_receipts_table.php new file mode 100644 index 0000000..739493a --- /dev/null +++ b/database/migrations/2025_03_01_000000_create_tenant_notification_receipts_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('notification_log_id'); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('recipient')->nullable(); + $table->enum('status', ['delivered', 'read', 'dismissed'])->default('delivered'); + $table->timestamps(); + + $table->index(['tenant_id', 'notification_log_id']); + $table->index(['tenant_id', 'user_id']); + $table->index(['tenant_id', 'status']); + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('notification_log_id')->references('id')->on('tenant_notification_logs')->cascadeOnDelete(); + $table->foreign('user_id')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tenant_notification_receipts'); + } +}; diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 42e31ef..4c79dbf 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -386,6 +386,19 @@ export type NotificationPreferences = Record; export type NotificationPreferencesMeta = Record; +export type NotificationLogEntry = { + id: number; + type: string; + channel: string; + recipient: string | null; + status: string; + context: Record | null; + sent_at: string | null; + failed_at: string | null; + failure_reason: string | null; + is_read?: boolean; +}; + export type PaddleTransactionSummary = { id: string | null; status: string | null; @@ -2016,6 +2029,71 @@ export async function updateNotificationPreferences( }; } +function normalizeNotificationLog(entry: JsonValue): NotificationLogEntry | null { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return null; + } + + const row = entry as Record; + + return { + id: Number(row.id ?? 0), + type: typeof row.type === 'string' ? row.type : '', + channel: typeof row.channel === 'string' ? row.channel : '', + recipient: typeof row.recipient === 'string' ? row.recipient : null, + status: typeof row.status === 'string' ? row.status : '', + context: (row.context && typeof row.context === 'object' && !Array.isArray(row.context)) ? (row.context as Record) : null, + sent_at: typeof row.sent_at === 'string' ? row.sent_at : null, + failed_at: typeof row.failed_at === 'string' ? row.failed_at : null, + failure_reason: typeof row.failure_reason === 'string' ? row.failure_reason : null, + }; +} + +export async function listNotificationLogs(options?: { + page?: number; + perPage?: number; + type?: string; + status?: string; + scope?: string; + eventId?: number; +}): Promise<{ + data: NotificationLogEntry[]; + meta: PaginationMeta & { unread_count?: number }; +}> { + const params = new URLSearchParams(); + if (options?.page) params.set('page', String(options.page)); + if (options?.perPage) params.set('per_page', String(options.perPage)); + if (options?.type) params.set('type', options.type); + if (options?.status) params.set('status', options.status); + if (options?.scope) params.set('scope', options.scope); + if (options?.eventId) params.set('event_id', String(options.eventId)); + + const response = await authorizedFetch(`/api/v1/tenant/notifications/logs${params.toString() ? `?${params.toString()}` : ''}`); + const payload = await jsonOrThrow<{ data?: JsonValue[]; meta?: Partial }>( + response, + 'Failed to load notification logs' + ); + + const rows = Array.isArray(payload.data) ? payload.data : []; + const meta = buildPagination((payload.meta ?? {}) as JsonValue, 0) as PaginationMeta & { unread_count?: number }; + if (payload.meta && typeof (payload.meta as any).unread_count === 'number') { + meta.unread_count = (payload.meta as any).unread_count as number; + } + + return { + data: rows.map((row) => normalizeNotificationLog(row)).filter((row): row is NotificationLogEntry => Boolean(row)), + meta, + }; +} + +export async function markNotificationLogs(ids: number[], status: 'read' | 'dismissed'): Promise { + await authorizedFetch('/api/v1/tenant/notifications/logs/mark', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids, status }), + }); +} + export async function getTenantPaddleTransactions(cursor?: string): Promise<{ data: PaddleTransactionSummary[]; nextCursor: string | null; diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index 7bb7482..efac588 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -73,6 +73,12 @@ "photoLimit": "Für dieses Event ist das Foto-Upload-Limit erreicht.", "goToBilling": "Zur Paketverwaltung" }, + "common": { + "loadMore": "Mehr laden", + "processing": "Verarbeite …", + "close": "Schließen", + "reset": "Zurücksetzen" + }, "limits": { "photosTitle": "Foto-Limit", "photosWarning": "Nur noch {{remaining}} von {{limit}} Foto-Uploads verfügbar.", diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index d2f9972..1dd92fb 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -149,6 +149,13 @@ "title": "Achtung", "description": "Paket-Hinweise und Limits, die du im Blick behalten solltest." }, + "common": { + "all": "Alle", + "loadMore": "Mehr laden", + "processing": "Verarbeite …", + "close": "Schließen", + "reset": "Zurücksetzen" + }, "photos": { "moderation": { "title": "Fotos moderieren", @@ -173,6 +180,7 @@ "all": "Alle", "featured": "Highlights", "hidden": "Versteckt", + "pending": "In Prüfung", "photobooth": "Photobooth", "search": "Uploads durchsuchen …", "count": "{{count}} Uploads", @@ -185,11 +193,77 @@ "show": "Einblenden", "feature": "Als Highlight setzen", "unfeature": "Highlight entfernen", + "approve": "Freigeben", + "approve": "Freigeben", "delete": "Löschen", "copy": "Link kopieren", "copySuccess": "Link kopiert" } }, + "limits": { + "photosBlocked": "Upload-Limit erreicht. Kaufe weitere Fotos, um fortzufahren.", + "photosWarning": "{{remaining}} von {{limit}} Fotos verbleiben.", + "guestsBlocked": "Gäste-Limit erreicht.", + "guestsWarning": "{{remaining}} von {{limit}} Gästen verbleiben.", + "galleryExpired": "Galerie abgelaufen. Verlängere die Laufzeit.", + "galleryWarningDay": "Galerie läuft in {{days}} Tag ab.", + "galleryWarningDays": "Galerie läuft in {{days}} Tagen ab.", + "buyMorePhotos": "Mehr Fotos freischalten", + "extendGallery": "Galerie verlängern", + "buyMoreGuests": "Mehr Gäste freischalten" + }, + "notificationLogs": { + "photoLimit": { + "title": "Foto-Limit erreicht", + "body": "{{event}} hat das Foto-Limit von {{limit}} erreicht." + }, + "guestLimit": { + "title": "Gäste-Limit erreicht", + "body": "{{event}} hat das Gäste-Limit von {{limit}} erreicht." + }, + "eventLimit": { + "title": "Event-Kontingent erreicht", + "body": "Dein Paket erlaubt keine weiteren Events. Limit: {{limit}}." + }, + "galleryWarning": { + "title": "Galerie läuft bald ab", + "body": "{{event}} läuft in {{days}} Tagen ab." + }, + "galleryExpired": { + "title": "Galerie abgelaufen", + "body": "Galerie von {{event}} ist offline. Verlängern zum Reaktivieren." + }, + "photoThreshold": { + "title": "Foto-Nutzung Warnung", + "body": "{{event}} liegt bei {{used}} / {{limit}} Fotos." + }, + "guestThreshold": { + "title": "Gäste-Nutzung Warnung", + "body": "{{event}} liegt bei {{used}} / {{limit}} Gästen." + }, + "generic": { + "body": "Benachrichtigung über {{channel}}." + }, + "filterEmpty": "Keine Benachrichtigungen für dieses Event.", + "clearFilter": "Alle Benachrichtigungen anzeigen", + "filter": { + "unread": "Ungelesen", + "read": "Gelesen", + "all": "Alle" + }, + "scope": { + "all": "Alle Bereiche", + "photos": "Fotos", + "guests": "Gäste", + "gallery": "Galerie", + "events": "Events", + "package": "Paket", + "general": "Allgemein" + }, + "markAllRead": "Alle als gelesen markieren", + "markFailed": "Benachrichtigungen konnten nicht aktualisiert werden.", + "unread": "Ungelesen" + }, "events": { "detail": { "kpi": { @@ -564,6 +638,9 @@ "eventTasks": { "title": "Aufgaben & Missionen", "subtitle": "Stelle Mission Cards und Aufgaben für dieses Event zusammen.", + "search": "Aufgaben suchen", + "emotionFilter": "Emotionen filtern", + "allEmotions": "Alle", "actions": { "back": "Zurück zur Übersicht", "assign": "Ausgewählte Tasks zuweisen" @@ -579,6 +656,8 @@ "emotions": { "error": "Emotionen konnten nicht geladen werden." }, + "manageEmotions": "Emotionen verwalten", + "manageEmotionsHint": "Filtere und halte deine Taxonomie sauber.", "alerts": { "notFoundTitle": "Event nicht gefunden", "notFoundDescription": "Bitte kehre zur Eventliste zurück." @@ -1819,7 +1898,9 @@ "visibilityFailed": "Sichtbarkeit konnte nicht geändert werden.", "featureSuccess": "Als Highlight markiert", "unfeatureSuccess": "Highlight entfernt", - "featureFailed": "Highlight konnte nicht geändert werden." + "featureFailed": "Highlight konnte nicht geändert werden.", + "approveSuccess": "Foto freigegeben", + "approveFailed": "Freigabe fehlgeschlagen." }, "mobileProfile": { "title": "Profil", @@ -1952,6 +2033,7 @@ "mobileNotifications": { "title": "Benachrichtigungen", "empty": "Keine Benachrichtigungen vorhanden.", - "filterByEvent": "Nach Event filtern" + "filterByEvent": "Nach Event filtern", + "unknownEvent": "Event" } } diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index ea8acc9..d71f860 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -73,6 +73,12 @@ "photoLimit": "This event reached its photo upload limit.", "goToBilling": "Manage subscription" }, + "common": { + "loadMore": "Load more", + "processing": "Processing…", + "close": "Close", + "reset": "Reset" + }, "limits": { "photosTitle": "Photo limit", "photosWarning": "Only {{remaining}} of {{limit}} photo uploads remaining.", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index bdf7f2a..062596e 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -145,6 +145,13 @@ } } }, + "common": { + "all": "All", + "loadMore": "Load more", + "processing": "Processing…", + "close": "Close", + "reset": "Reset" + }, "photos": { "moderation": { "title": "Moderate photos", @@ -169,6 +176,7 @@ "all": "All", "featured": "Highlights", "hidden": "Hidden", + "pending": "Pending", "photobooth": "Photobooth", "search": "Search uploads …", "count": "{{count}} uploads", @@ -181,11 +189,76 @@ "show": "Show", "feature": "Set highlight", "unfeature": "Remove highlight", + "approve": "Approve", "delete": "Delete", "copy": "Copy link", "copySuccess": "Link copied" } }, + "limits": { + "photosBlocked": "Upload limit reached. Buy more photos to continue.", + "photosWarning": "{{remaining}} of {{limit}} photos remaining.", + "guestsBlocked": "Guest limit reached.", + "guestsWarning": "{{remaining}} of {{limit}} guests remaining.", + "galleryExpired": "Gallery expired. Extend to keep it online.", + "galleryWarningDay": "Gallery expires in {{days}} day.", + "galleryWarningDays": "Gallery expires in {{days}} days.", + "buyMorePhotos": "Buy more photos", + "extendGallery": "Extend gallery", + "buyMoreGuests": "Add more guests" + }, + "notificationLogs": { + "photoLimit": { + "title": "Photo limit reached", + "body": "{{event}} reached its photo limit of {{limit}}." + }, + "guestLimit": { + "title": "Guest limit reached", + "body": "{{event}} reached its guest limit of {{limit}}." + }, + "eventLimit": { + "title": "Event quota reached", + "body": "Your package allows no more events. Limit: {{limit}}." + }, + "galleryWarning": { + "title": "Gallery expiring soon", + "body": "{{event}} expires in {{days}} days." + }, + "galleryExpired": { + "title": "Gallery expired", + "body": "{{event}} gallery is offline. Extend to reactivate." + }, + "photoThreshold": { + "title": "Photo usage warning", + "body": "{{event}} is at {{used}} / {{limit}} photos." + }, + "guestThreshold": { + "title": "Guest usage warning", + "body": "{{event}} is at {{used}} / {{limit}} guests." + }, + "generic": { + "body": "Notification sent via {{channel}}." + }, + "filterEmpty": "No notifications for this event.", + "clearFilter": "Show all notifications", + "filter": { + "unread": "Unread", + "read": "Read", + "all": "All" + }, + "scope": { + "all": "All scopes", + "photos": "Photos", + "guests": "Guests", + "gallery": "Gallery", + "events": "Events", + "package": "Package", + "general": "General" + }, + "markAllRead": "Mark all read", + "markFailed": "Could not update notifications.", + "unread": "Unread" + }, "events": { "detail": { "kpi": { @@ -312,6 +385,9 @@ "eventTasks": { "title": "Tasks & missions", "subtitle": "Curate mission cards and tasks for this event.", + "search": "Search tasks", + "emotionFilter": "Emotion filter", + "allEmotions": "All", "actions": { "back": "Back to overview", "assign": "Assign selected tasks" @@ -327,6 +403,8 @@ "emotions": { "error": "Could not load emotions." }, + "manageEmotions": "Manage emotions", + "manageEmotionsHint": "Filter and keep your taxonomy tidy.", "alerts": { "notFoundTitle": "Event not found", "notFoundDescription": "Please return to the event list." @@ -1840,7 +1918,9 @@ "visibilityFailed": "Visibility could not be changed.", "featureSuccess": "Marked as highlight", "unfeatureSuccess": "Highlight removed", - "featureFailed": "Highlight could not be changed" + "featureFailed": "Highlight could not be changed", + "approveSuccess": "Photo approved", + "approveFailed": "Approval failed." }, "mobileProfile": { "title": "Profile", @@ -1973,6 +2053,7 @@ "mobileNotifications": { "title": "Notifications", "empty": "No notifications yet.", - "filterByEvent": "Filter by event" + "filterByEvent": "Filter by event", + "unknownEvent": "Event" } } diff --git a/resources/js/admin/mobile/EventPhotosPage.tsx b/resources/js/admin/mobile/EventPhotosPage.tsx index 1b4f282..f32884b 100644 --- a/resources/js/admin/mobile/EventPhotosPage.tsx +++ b/resources/js/admin/mobile/EventPhotosPage.tsx @@ -266,6 +266,18 @@ export default function MobileEventPhotosPage() { ))} + {!loading ? ( + { void handleCheckout(scopeOrKey, slug, catalogAddons, setBusyScope, t); }} + busyScope={busyScope} + translate={translateLimits(t)} + textColor={text} + borderColor={border} + /> + ) : null} + {loading ? ( {Array.from({ length: 4 }).map((_, idx) => ( @@ -281,15 +293,6 @@ export default function MobileEventPhotosPage() { ) : ( - { void handleCheckout(scopeOrKey, slug, catalogAddons, setBusyScope, t); }} - busyScope={busyScope} - translate={translateLimits(t)} - textColor={text} - borderColor={border} - /> {t('mobilePhotos.count', '{{count}} photos', { count: totalCount })} @@ -490,6 +493,7 @@ function translateLimits(t: (key: string, defaultValue?: string, options?: Recor galleryWarningDays: 'Gallery expires in {{days}} days.', buyMorePhotos: 'Buy more photos', extendGallery: 'Extend gallery', + buyMoreGuests: 'Add more guests', }; return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options); } @@ -524,7 +528,7 @@ function LimitWarnings({ {warning.message} - {(warning.scope === 'photos' || warning.scope === 'gallery') && addons.length ? ( + {(warning.scope === 'photos' || warning.scope === 'gallery' || warning.scope === 'guests') && addons.length ? ( onCheckout(warning.scope)} loading={busyScope === warning.scope} @@ -557,7 +561,7 @@ function MobileAddonsPicker({ onCheckout, translate, }: { - scope: 'photos' | 'gallery'; + scope: 'photos' | 'gallery' | 'guests'; addons: EventAddonCatalogItem[]; busy: boolean; onCheckout: (addonKey: string) => void; @@ -603,7 +607,13 @@ function MobileAddonsPicker({ ))} selected && onCheckout(selected)} loading={busy} @@ -640,15 +650,25 @@ function EventAddonList({ addons, textColor, mutedColor }: { addons: EventAddonS } async function handleCheckout( - scopeOrKey: 'photos' | 'gallery' | string, + scopeOrKey: 'photos' | 'gallery' | 'guests' | string, slug: string | null, addons: EventAddonCatalogItem[], setBusyScope: (scope: string | null) => void, t: (key: string, defaultValue?: string) => string, ): Promise { if (!slug) return; - const scope = scopeOrKey === 'photos' || scopeOrKey === 'gallery' ? scopeOrKey : scopeOrKey.includes('gallery') ? 'gallery' : 'photos'; - const addonKey = scopeOrKey === 'photos' || scopeOrKey === 'gallery' ? selectAddonKeyForScope(addons, scope) : scopeOrKey; + const scope = + scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests' + ? scopeOrKey + : scopeOrKey.includes('gallery') + ? 'gallery' + : scopeOrKey.includes('guest') + ? 'guests' + : 'photos'; + const addonKey = + scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests' + ? selectAddonKeyForScope(addons, scope) + : scopeOrKey; const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/photos`)}` : ''; const successUrl = `${currentUrl}?addon_success=1`; setBusyScope(scope); diff --git a/resources/js/admin/mobile/NotificationsPage.tsx b/resources/js/admin/mobile/NotificationsPage.tsx index 8e46efd..173c285 100644 --- a/resources/js/admin/mobile/NotificationsPage.tsx +++ b/resources/js/admin/mobile/NotificationsPage.tsx @@ -7,7 +7,7 @@ import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; import { MobileShell } from './components/MobileShell'; import { MobileCard, PillBadge } from './components/Primitives'; -import { GuestNotificationSummary, listGuestNotifications } from '../api'; +import { listNotificationLogs, markNotificationLogs, NotificationLogEntry } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import toast from 'react-hot-toast'; @@ -21,18 +21,184 @@ type NotificationItem = { body: string; time: string; tone: 'info' | 'warning'; + eventId?: number | null; + is_read?: boolean; + scope: 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general'; }; -async function loadNotifications(slug?: string): Promise { +function formatLog( + log: NotificationLogEntry, + t: (key: string, defaultValue?: string, options?: Record) => string, + eventName?: string | null +): NotificationItem { + const ctx = log.context ?? {}; + const limit = typeof ctx.limit === 'number' ? ctx.limit : null; + const used = typeof ctx.used === 'number' ? ctx.used : null; + const remaining = typeof ctx.remaining === 'number' ? ctx.remaining : null; + const days = typeof ctx.day === 'number' ? ctx.day : null; + const ctxEventId = ctx.event_id ?? ctx.eventId; + const eventId = typeof ctxEventId === 'string' ? Number(ctxEventId) : (typeof ctxEventId === 'number' ? ctxEventId : null); + const name = eventName ?? t('mobileNotifications.unknownEvent', 'Event'); + const isRead = log.is_read === true; + const scope = (() => { + switch (log.type) { + case 'photo_limit': + case 'photo_threshold': + return 'photos'; + case 'guest_limit': + case 'guest_threshold': + return 'guests'; + case 'gallery_warning': + case 'gallery_expired': + return 'gallery'; + case 'event_limit': + case 'event_threshold': + return 'events'; + case 'package_expiring': + case 'package_expired': + return 'package'; + default: + return 'general'; + } + })(); + + switch (log.type) { + case 'photo_limit': + return { + id: String(log.id), + title: t('notificationLogs.photoLimit.title', 'Photo limit reached'), + body: t('notificationLogs.photoLimit.body', '{{event}} reached its photo limit of {{limit}}.', { + event: name, + limit: limit ?? '—', + }), + time: log.sent_at ?? log.failed_at ?? '', + tone: 'warning', + eventId, + is_read: isRead, + scope, + }; + case 'guest_limit': + return { + id: String(log.id), + title: t('notificationLogs.guestLimit.title', 'Guest limit reached'), + body: t('notificationLogs.guestLimit.body', '{{event}} reached its guest limit of {{limit}}.', { + event: name, + limit: limit ?? '—', + }), + time: log.sent_at ?? log.failed_at ?? '', + tone: 'warning', + eventId, + is_read: isRead, + scope, + }; + case 'event_limit': + return { + id: String(log.id), + title: t('notificationLogs.eventLimit.title', 'Event quota reached'), + body: t('notificationLogs.eventLimit.body', 'Your package allows no more events. Limit: {{limit}}.', { + limit: limit ?? '—', + }), + time: log.sent_at ?? log.failed_at ?? '', + tone: 'warning', + eventId, + is_read: isRead, + scope, + }; + case 'gallery_warning': + return { + id: String(log.id), + title: t('notificationLogs.galleryWarning.title', 'Gallery expiring soon'), + body: t('notificationLogs.galleryWarning.body', '{{event}} expires in {{days}} days.', { + event: name, + days: days ?? ctx.threshold ?? '—', + }), + time: log.sent_at ?? log.failed_at ?? '', + tone: 'warning', + eventId, + is_read: isRead, + scope, + }; + case 'gallery_expired': + return { + id: String(log.id), + title: t('notificationLogs.galleryExpired.title', 'Gallery expired'), + body: t('notificationLogs.galleryExpired.body', '{{event}} gallery is offline. Extend to reactivate.', { + event: name, + }), + time: log.sent_at ?? log.failed_at ?? '', + tone: 'warning', + eventId, + is_read: isRead, + scope, + }; + case 'photo_threshold': + return { + id: String(log.id), + title: t('notificationLogs.photoThreshold.title', 'Photo usage warning'), + body: t('notificationLogs.photoThreshold.body', '{{event}} is at {{used}} / {{limit}} photos.', { + event: name, + used: used ?? '—', + limit: limit ?? '—', + }), + time: log.sent_at ?? log.failed_at ?? '', + tone: 'info', + eventId, + is_read: isRead, + scope, + }; + case 'guest_threshold': + return { + id: String(log.id), + title: t('notificationLogs.guestThreshold.title', 'Guest usage warning'), + body: t('notificationLogs.guestThreshold.body', '{{event}} is at {{used}} / {{limit}} guests.', { + event: name, + used: used ?? '—', + limit: limit ?? '—', + }), + time: log.sent_at ?? log.failed_at ?? '', + tone: 'info', + eventId, + is_read: isRead, + scope, + }; + default: + return { + id: String(log.id), + title: log.type, + body: t('notificationLogs.generic.body', 'Notification sent via {{channel}}.', { channel: log.channel }), + time: log.sent_at ?? log.failed_at ?? '', + tone: 'info', + eventId, + is_read: isRead, + scope, + }; + } +} + +async function loadNotifications( + t: (key: string, defaultValue?: string, options?: Record) => string, + events?: TenantEvent[], + filters?: { scope?: string; status?: string; eventSlug?: string } +): Promise { try { - const result = slug ? await listGuestNotifications(slug) : []; - return (result ?? []).map((item: GuestNotificationSummary) => ({ - id: String(item.id), - title: item.title || 'Notification', - body: item.body ?? '', - time: item.created_at ?? '', - tone: item.type === 'support_tip' ? 'warning' : 'info', - })); + const eventId = filters?.eventSlug ? (events ?? []).find((ev) => ev.slug === filters.eventSlug)?.id ?? undefined : undefined; + const response = await listNotificationLogs({ + perPage: 50, + scope: filters?.scope && filters.scope !== 'all' ? filters.scope : undefined, + status: filters?.status === 'all' ? undefined : filters?.status, + eventId: eventId, + }); + const lookup = new Map(); + (events ?? []).forEach((event) => { + lookup.set(event.id, typeof event.name === 'string' ? event.name : (event.name as Record)?.en ?? ''); + }); + + return (response.data ?? []) + .map((log) => { + const ctxEventId = log.context?.event_id ?? log.context?.eventId; + const parsed = typeof ctxEventId === 'string' ? Number(ctxEventId) : ctxEventId; + return formatLog(log, t, lookup.get(parsed as number)); + }); } catch (err) { throw err; } @@ -43,6 +209,8 @@ export default function MobileNotificationsPage() { const { t } = useTranslation('management'); const search = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : ''); const slug = search.get('event') ?? undefined; + const scopeParam = search.get('scope') ?? 'all'; + const statusParam = search.get('status') ?? 'unread'; const [notifications, setNotifications] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); @@ -62,7 +230,7 @@ export default function MobileNotificationsPage() { const reload = React.useCallback(async () => { setLoading(true); try { - const data = await loadNotifications(slug ?? undefined); + const data = await loadNotifications(t, events, { scope: scopeParam, status: statusParam, eventSlug: slug }); setNotifications(data); setError(null); } catch (err) { @@ -74,7 +242,7 @@ export default function MobileNotificationsPage() { } finally { setLoading(false); } - }, [slug, t]); + }, [slug, t, events]); React.useEffect(() => { void reload(); @@ -91,6 +259,31 @@ export default function MobileNotificationsPage() { })(); }, []); + const filtered = React.useMemo(() => { + if (!slug) return notifications; + const target = events.find((ev) => ev.slug === slug); + if (!target) return notifications; + return notifications.filter((item) => item.eventId === target.id || item.body.includes(String(target.name)) || item.title.includes(String(target.name))); + }, [notifications, slug, events]); + + const scoped = React.useMemo(() => { + if (scopeParam === 'all') return filtered; + return filtered.filter((item) => item.scope === scopeParam); + }, [filtered, scopeParam]); + + const statusFiltered = React.useMemo(() => { + if (statusParam === 'all') return scoped; + if (statusParam === 'read') return scoped.filter((item) => item.is_read); + return scoped.filter((item) => !item.is_read); + }, [scoped, statusParam]); + + const unreadIds = React.useMemo( + () => scoped.filter((item) => !item.is_read).map((item) => Number(item.id)).filter((id) => Number.isFinite(id)), + [scoped] + ); + + const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0; + return ( ) : null} + {showFilterNotice ? ( + + + {t('notificationLogs.filterEmpty', 'No notifications for this event.')} + + { + navigate('/admin/mobile/notifications', { replace: true }); + }} + > + + {t('notificationLogs.clearFilter', 'Show all notifications')} + + + + ) : null} + + + + + {unreadIds.length ? ( + { + try { + await markNotificationLogs(unreadIds, 'read'); + void reload(); + } catch { + toast.error(t('notificationLogs.markFailed', 'Could not update notifications.')); + } + }} + tone="ghost" + /> + ) : null} + + {loading ? ( {Array.from({ length: 4 }).map((_, idx) => ( ))} - ) : notifications.length === 0 ? ( + ) : statusFiltered.length === 0 ? ( @@ -132,8 +381,8 @@ export default function MobileNotificationsPage() { ) : null} - {notifications.map((item) => ( - + {statusFiltered.map((item) => ( + + {!item.is_read ? {t('notificationLogs.unread', 'Unread')} : null} {item.time} diff --git a/resources/js/admin/mobile/addons.ts b/resources/js/admin/mobile/addons.ts index 3b3c95c..deee64a 100644 --- a/resources/js/admin/mobile/addons.ts +++ b/resources/js/admin/mobile/addons.ts @@ -1,12 +1,13 @@ import type { EventAddonCatalogItem } from '../api'; -export const scopeDefaults: Record<'photos' | 'gallery', string[]> = { +export const scopeDefaults: Record<'photos' | 'gallery' | 'guests', string[]> = { photos: ['extra_photos_500', 'extra_photos_2000'], gallery: ['extend_gallery_30d', 'extend_gallery_90d'], + guests: ['extra_guests_300', 'extra_guests_100', 'extra_guests_50', 'extra_guests'], }; -export function selectAddonKeyForScope(addons: EventAddonCatalogItem[], scope: 'photos' | 'gallery'): string { - const fallback = scope === 'photos' ? 'extra_photos_500' : 'extend_gallery_30d'; +export function selectAddonKeyForScope(addons: EventAddonCatalogItem[], scope: 'photos' | 'gallery' | 'guests'): string { + const fallback = scope === 'photos' ? 'extra_photos_500' : scope === 'gallery' ? 'extend_gallery_30d' : 'extra_guests'; const filtered = addons.filter((addon) => addon.price_id && scopeDefaults[scope].includes(addon.key)); if (filtered.length) { return filtered[0].key; diff --git a/resources/js/admin/mobile/hooks/useNotificationsBadge.ts b/resources/js/admin/mobile/hooks/useNotificationsBadge.ts index d415917..c4b3fd7 100644 --- a/resources/js/admin/mobile/hooks/useNotificationsBadge.ts +++ b/resources/js/admin/mobile/hooks/useNotificationsBadge.ts @@ -1,27 +1,24 @@ import React from 'react'; import { useQuery } from '@tanstack/react-query'; -import { useEventContext } from '../../context/EventContext'; -import { listGuestNotifications } from '../../api'; +import { listNotificationLogs } from '../../api'; /** * Badge count for notifications bell in the mobile shell. - * Fetches guest notifications for the active event and returns count. + * Uses tenant notification logs so the badge matches the notifications screen. */ export function useNotificationsBadge() { - const { activeEvent } = useEventContext(); - const slug = activeEvent?.slug; - const { data: count = 0 } = useQuery({ - queryKey: ['mobile', 'notifications', 'badge', slug], - enabled: Boolean(slug), + queryKey: ['mobile', 'notifications', 'badge', 'tenant'], staleTime: 60_000, queryFn: async () => { - if (!slug) { - return 0; + const logs = await listNotificationLogs({ perPage: 1 }); + const meta: any = logs.meta ?? {}; + if (typeof meta.unread_count === 'number') { + return meta.unread_count; } - const notifications = await listGuestNotifications(slug); - return Array.isArray(notifications) ? notifications.length : 0; + return Array.isArray(logs.data) ? logs.data.filter((log) => log.is_read === false).length : 0; }, + retry: 1, }); return React.useMemo(() => ({ count }), [count]); diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index 007e85b..05c8257 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { createPortal } from 'react-dom'; import { Link } from 'react-router-dom'; import AppearanceToggleDropdown from '@/components/appearance-dropdown'; import { @@ -147,6 +148,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string const { event, status } = useEventData(); const notificationCenter = useOptionalNotificationCenter(); const [notificationsOpen, setNotificationsOpen] = React.useState(false); + const [statusFilter, setStatusFilter] = React.useState<'new' | 'read' | 'dismissed' | 'all'>('new'); const taskProgress = useGuestTaskProgress(eventToken); const panelRef = React.useRef(null); const checklistItems = React.useMemo( @@ -277,11 +279,13 @@ type NotificationButtonProps = { type PushState = ReturnType; function NotificationButton({ center, eventToken, open, onToggle, panelRef, checklistItems, taskProgress, t }: NotificationButtonProps) { - const badgeCount = center.totalCount; + const badgeCount = center.unreadCount; 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'); + const [statusFilter, setStatusFilter] = React.useState<'new' | 'read' | 'dismissed' | 'all'>('new'); + const [scopeFilter, setScopeFilter] = React.useState<'all' | 'uploads' | 'tips' | 'general'>('all'); const pushState = usePushSubscription(eventToken); React.useEffect(() => { @@ -300,18 +304,39 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec ); const filteredNotifications = React.useMemo(() => { + let base: typeof center.notifications = []; switch (activeTab) { case 'unread': - return unreadNotifications; + base = unreadNotifications; + break; case 'status': - return uploadNotifications; + base = uploadNotifications; + break; default: - return center.notifications; + base = center.notifications; } - }, [activeTab, center.notifications, unreadNotifications, uploadNotifications]); + + if (statusFilter === 'all') return base; + return base.filter((item) => item.status === (statusFilter === 'new' ? 'new' : statusFilter)); + }, [activeTab, center.notifications, unreadNotifications, uploadNotifications, statusFilter]); + + const scopedNotifications = React.useMemo(() => { + if (scopeFilter === 'all') { + return filteredNotifications; + } + return filteredNotifications.filter((item) => { + if (scopeFilter === 'uploads') { + return item.type === 'upload_alert' || item.type === 'photo_activity'; + } + if (scopeFilter === 'tips') { + return item.type === 'support_tip' || item.type === 'achievement_major'; + } + return item.type === 'broadcast' || item.type === 'feedback_request'; + }); + }, [filteredNotifications, scopeFilter]); return ( -
+
- {open && ( + {open && createPortal(
@@ -358,6 +383,40 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec activeTab={activeTab} onTabChange={(next) => setActiveTab(next as typeof activeTab)} /> +
+ + + +
{center.loading ? ( - ) : filteredNotifications.length === 0 ? ( + ) : scopedNotifications.length === 0 ? ( ) : ( - filteredNotifications.map((item) => ( + scopedNotifications.map((item) => ( ))} -
+
, + typeof document !== 'undefined' ? document.body : undefined )}
); diff --git a/resources/js/guest/context/NotificationCenterContext.tsx b/resources/js/guest/context/NotificationCenterContext.tsx index f52e475..4a6cb80 100644 --- a/resources/js/guest/context/NotificationCenterContext.tsx +++ b/resources/js/guest/context/NotificationCenterContext.tsx @@ -16,6 +16,7 @@ export type NotificationCenterValue = { totalCount: number; loading: boolean; refresh: () => Promise; + setFilters: (filters: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => void; markAsRead: (id: number) => Promise; dismiss: (id: number) => Promise; eventToken: string; @@ -30,6 +31,10 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke const [notifications, setNotifications] = React.useState([]); const [unreadCount, setUnreadCount] = React.useState(0); const [loadingNotifications, setLoadingNotifications] = React.useState(true); + const [filters, setFiltersState] = React.useState<{ status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }>({ + status: 'new', + scope: 'all', + }); const etagRef = React.useRef(null); const fetchLockRef = React.useRef(false); const [lastFetchedAt, setLastFetchedAt] = React.useState(null); @@ -59,7 +64,11 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke } try { - const result = await fetchGuestNotifications(eventToken, etagRef.current); + const statusFilter = filters.status && filters.status !== 'all' ? (filters.status === 'new' ? 'unread' : filters.status) : undefined; + const result = await fetchGuestNotifications(eventToken, etagRef.current, { + status: statusFilter as any, + scope: filters.scope, + }); if (!result.notModified) { setNotifications(result.notifications); setUnreadCount(result.unreadCount); @@ -217,6 +226,11 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke [eventToken, loadNotifications] ); + const setFilters = React.useCallback((next: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => { + setFiltersState((prev) => ({ ...prev, ...next })); + void loadNotifications({ silent: true }); + }, [loadNotifications]); + const refresh = React.useCallback(async () => { await Promise.all([loadNotifications(), refreshQueue()]); }, [loadNotifications, refreshQueue]); @@ -232,6 +246,7 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke totalCount, loading, refresh, + setFilters, markAsRead, dismiss, eventToken, diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index b3f5b98..0751829 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -152,7 +152,7 @@ export default function UploadPage() { const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); const [uploadWarning, setUploadWarning] = useState(null); -const [immersiveMode, setImmersiveMode] = useState(true); +const [immersiveMode, setImmersiveMode] = useState(false); const [showCelebration, setShowCelebration] = useState(false); const [showHeroOverlay, setShowHeroOverlay] = useState(true); diff --git a/resources/js/guest/services/notificationApi.ts b/resources/js/guest/services/notificationApi.ts index 3f9b029..fc3cf07 100644 --- a/resources/js/guest/services/notificationApi.ts +++ b/resources/js/guest/services/notificationApi.ts @@ -74,8 +74,16 @@ function mapNotification(payload: GuestNotificationRow): GuestNotificationItem { }; } -export async function fetchGuestNotifications(eventToken: string, etag?: string | null): Promise { - const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications`, { +export async function fetchGuestNotifications( + eventToken: string, + etag?: string | null, + options?: { status?: 'unread' | 'read' | 'dismissed'; scope?: 'all' | 'uploads' | 'tips' | 'general' } +): Promise { + const params = new URLSearchParams(); + if (options?.status) params.set('status', options.status); + if (options?.scope && options.scope !== 'all') params.set('scope', options.scope); + + const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications${params.toString() ? `?${params.toString()}` : ''}`, { method: 'GET', headers: buildHeaders(etag), credentials: 'include', diff --git a/routes/api.php b/routes/api.php index 9bfd83a..732f9ab 100644 --- a/routes/api.php +++ b/routes/api.php @@ -284,6 +284,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::get('notifications/logs', [NotificationLogController::class, 'index']) ->middleware('tenant.admin') ->name('tenant.notifications.logs.index'); + Route::post('notifications/logs/mark', [NotificationLogController::class, 'mark']) + ->middleware('tenant.admin') + ->name('tenant.notifications.logs.mark'); Route::prefix('packages')->middleware('tenant.admin')->group(function () { Route::get('/', [PackageController::class, 'index'])->name('packages.index');