From 2c14493604fe86d8e89ff69e7e340a8366d92574 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 1 Nov 2025 13:19:07 +0100 Subject: [PATCH] Implement package limit notification system --- app/Console/Commands/CheckEventPackages.php | 158 ++++++++ .../Packages/EventPackageGalleryExpired.php | 15 + .../Packages/EventPackageGalleryExpiring.php | 18 + .../EventPackageGuestLimitReached.php | 18 + .../EventPackageGuestThresholdReached.php | 20 + .../EventPackagePhotoLimitReached.php | 18 + .../EventPackagePhotoThresholdReached.php | 20 + app/Events/Packages/TenantCreditsLow.php | 19 + .../TenantPackageEventLimitReached.php | 18 + .../TenantPackageEventThresholdReached.php | 20 + app/Events/Packages/TenantPackageExpired.php | 15 + app/Events/Packages/TenantPackageExpiring.php | 15 + .../Controllers/Api/EventPublicController.php | 162 +++++--- .../Api/Tenant/PhotoController.php | 87 +++-- app/Http/Middleware/CreditCheckMiddleware.php | 20 +- app/Http/Middleware/PackageMiddleware.php | 50 ++- .../SendEventPackageGalleryExpired.php | 64 ++++ .../SendEventPackageGalleryWarning.php | 71 ++++ ...SendEventPackageGuestLimitNotification.php | 70 ++++ .../SendEventPackageGuestThresholdWarning.php | 75 ++++ ...SendEventPackagePhotoLimitNotification.php | 73 ++++ .../SendEventPackagePhotoThresholdWarning.php | 77 ++++ .../SendTenantCreditsLowNotification.php | 67 ++++ ...endTenantPackageEventLimitNotification.php | 65 ++++ ...SendTenantPackageEventThresholdWarning.php | 70 ++++ .../SendTenantPackageExpiredNotification.php | 59 +++ .../SendTenantPackageExpiringNotification.php | 66 ++++ .../QueueGalleryExpiredNotification.php | 14 + .../QueueGalleryWarningNotification.php | 17 + .../Packages/QueueGuestLimitNotification.php | 17 + .../QueueGuestThresholdNotification.php | 19 + .../Packages/QueuePhotoLimitNotification.php | 17 + .../QueuePhotoThresholdNotification.php | 19 + .../QueueTenantCreditsLowNotification.php | 18 + .../QueueTenantEventLimitNotification.php | 17 + .../QueueTenantEventThresholdNotification.php | 19 + .../QueueTenantPackageExpiredNotification.php | 14 + ...QueueTenantPackageExpiringNotification.php | 17 + app/Models/EventPackage.php | 17 +- app/Models/Tenant.php | 43 ++- app/Models/TenantPackage.php | 5 + ...EventPackageGalleryExpiredNotification.php | 46 +++ ...ventPackageGalleryExpiringNotification.php | 51 +++ .../EventPackageGuestLimitNotification.php | 49 +++ ...EventPackageGuestThresholdNotification.php | 57 +++ .../EventPackagePhotoLimitNotification.php | 49 +++ ...EventPackagePhotoThresholdNotification.php | 58 +++ .../Packages/TenantCreditsLowNotification.php | 42 ++ .../TenantPackageEventLimitNotification.php | 46 +++ ...enantPackageEventThresholdNotification.php | 55 +++ .../TenantPackageExpiredNotification.php | 43 +++ .../TenantPackageExpiringNotification.php | 48 +++ app/Providers/AppServiceProvider.php | 88 ++++- app/Services/EventJoinTokenService.php | 19 + .../Packages/PackageLimitEvaluator.php | 151 ++++++++ app/Services/Packages/PackageUsageTracker.php | 87 +++++ .../TenantNotificationPreferences.php | 37 ++ app/Services/Packages/TenantUsageTracker.php | 97 +++++ app/Support/ApiError.php | 30 ++ app/Support/JoinTokenLayoutRegistry.php | 12 +- bootstrap/app.php | 4 + config/package-limits.php | 29 ++ ...cation_columns_to_event_packages_table.php | 34 ++ ...ification_preferences_to_tenants_table.php | 40 ++ ...ation_columns_to_tenant_packages_table.php | 50 +++ database/seeders/InviteLayoutSeeder.php | 24 +- .../todo/package-limit-experience-overhaul.md | 67 ++++ resources/js/admin/api.ts | 17 +- .../js/admin/i18n/locales/de/common.json | 8 + .../js/admin/i18n/locales/en/common.json | 8 + resources/js/admin/lib/apiError.ts | 15 + resources/js/admin/pages/EventFormPage.tsx | 51 ++- resources/js/admin/pages/EventInvitesPage.tsx | 60 ++- .../InviteLayoutCustomizerPanel.tsx | 177 +++++++-- .../invite-layout/DesignerCanvas.tsx | 47 ++- .../pages/components/invite-layout/schema.ts | 361 ++++++++++++------ resources/js/guest/i18n/messages.ts | 16 + resources/js/guest/pages/UploadPage.tsx | 66 +++- resources/js/guest/services/photosApi.ts | 63 ++- resources/lang/de/emails.php | 73 ++++ resources/lang/en/emails.php | 73 ++++ .../Feature/Api/EventGuestUploadLimitTest.php | 134 +++++++ .../Console/CheckEventPackagesCommandTest.php | 216 +++++++++++ .../Services/PackageLimitEvaluatorTest.php | 128 +++++++ .../Unit/Services/PackageUsageTrackerTest.php | 148 +++++++ .../Unit/Services/TenantUsageTrackerTest.php | 115 ++++++ tests/Unit/TenantModelTest.php | 75 +++- 87 files changed, 4557 insertions(+), 290 deletions(-) create mode 100644 app/Console/Commands/CheckEventPackages.php create mode 100644 app/Events/Packages/EventPackageGalleryExpired.php create mode 100644 app/Events/Packages/EventPackageGalleryExpiring.php create mode 100644 app/Events/Packages/EventPackageGuestLimitReached.php create mode 100644 app/Events/Packages/EventPackageGuestThresholdReached.php create mode 100644 app/Events/Packages/EventPackagePhotoLimitReached.php create mode 100644 app/Events/Packages/EventPackagePhotoThresholdReached.php create mode 100644 app/Events/Packages/TenantCreditsLow.php create mode 100644 app/Events/Packages/TenantPackageEventLimitReached.php create mode 100644 app/Events/Packages/TenantPackageEventThresholdReached.php create mode 100644 app/Events/Packages/TenantPackageExpired.php create mode 100644 app/Events/Packages/TenantPackageExpiring.php create mode 100644 app/Jobs/Packages/SendEventPackageGalleryExpired.php create mode 100644 app/Jobs/Packages/SendEventPackageGalleryWarning.php create mode 100644 app/Jobs/Packages/SendEventPackageGuestLimitNotification.php create mode 100644 app/Jobs/Packages/SendEventPackageGuestThresholdWarning.php create mode 100644 app/Jobs/Packages/SendEventPackagePhotoLimitNotification.php create mode 100644 app/Jobs/Packages/SendEventPackagePhotoThresholdWarning.php create mode 100644 app/Jobs/Packages/SendTenantCreditsLowNotification.php create mode 100644 app/Jobs/Packages/SendTenantPackageEventLimitNotification.php create mode 100644 app/Jobs/Packages/SendTenantPackageEventThresholdWarning.php create mode 100644 app/Jobs/Packages/SendTenantPackageExpiredNotification.php create mode 100644 app/Jobs/Packages/SendTenantPackageExpiringNotification.php create mode 100644 app/Listeners/Packages/QueueGalleryExpiredNotification.php create mode 100644 app/Listeners/Packages/QueueGalleryWarningNotification.php create mode 100644 app/Listeners/Packages/QueueGuestLimitNotification.php create mode 100644 app/Listeners/Packages/QueueGuestThresholdNotification.php create mode 100644 app/Listeners/Packages/QueuePhotoLimitNotification.php create mode 100644 app/Listeners/Packages/QueuePhotoThresholdNotification.php create mode 100644 app/Listeners/Packages/QueueTenantCreditsLowNotification.php create mode 100644 app/Listeners/Packages/QueueTenantEventLimitNotification.php create mode 100644 app/Listeners/Packages/QueueTenantEventThresholdNotification.php create mode 100644 app/Listeners/Packages/QueueTenantPackageExpiredNotification.php create mode 100644 app/Listeners/Packages/QueueTenantPackageExpiringNotification.php create mode 100644 app/Notifications/Packages/EventPackageGalleryExpiredNotification.php create mode 100644 app/Notifications/Packages/EventPackageGalleryExpiringNotification.php create mode 100644 app/Notifications/Packages/EventPackageGuestLimitNotification.php create mode 100644 app/Notifications/Packages/EventPackageGuestThresholdNotification.php create mode 100644 app/Notifications/Packages/EventPackagePhotoLimitNotification.php create mode 100644 app/Notifications/Packages/EventPackagePhotoThresholdNotification.php create mode 100644 app/Notifications/Packages/TenantCreditsLowNotification.php create mode 100644 app/Notifications/Packages/TenantPackageEventLimitNotification.php create mode 100644 app/Notifications/Packages/TenantPackageEventThresholdNotification.php create mode 100644 app/Notifications/Packages/TenantPackageExpiredNotification.php create mode 100644 app/Notifications/Packages/TenantPackageExpiringNotification.php create mode 100644 app/Services/Packages/PackageLimitEvaluator.php create mode 100644 app/Services/Packages/PackageUsageTracker.php create mode 100644 app/Services/Packages/TenantNotificationPreferences.php create mode 100644 app/Services/Packages/TenantUsageTracker.php create mode 100644 app/Support/ApiError.php create mode 100644 config/package-limits.php create mode 100644 database/migrations/2025_11_01_121415_add_gallery_notification_columns_to_event_packages_table.php create mode 100644 database/migrations/2025_11_01_121900_add_notification_preferences_to_tenants_table.php create mode 100644 database/migrations/2025_11_01_123657_add_notification_columns_to_tenant_packages_table.php create mode 100644 docs/todo/package-limit-experience-overhaul.md create mode 100644 resources/js/admin/lib/apiError.ts create mode 100644 tests/Feature/Api/EventGuestUploadLimitTest.php create mode 100644 tests/Feature/Console/CheckEventPackagesCommandTest.php create mode 100644 tests/Unit/Services/PackageLimitEvaluatorTest.php create mode 100644 tests/Unit/Services/PackageUsageTrackerTest.php create mode 100644 tests/Unit/Services/TenantUsageTrackerTest.php diff --git a/app/Console/Commands/CheckEventPackages.php b/app/Console/Commands/CheckEventPackages.php new file mode 100644 index 0000000..7df9271 --- /dev/null +++ b/app/Console/Commands/CheckEventPackages.php @@ -0,0 +1,158 @@ +filter(fn ($day) => is_numeric($day) && $day > 0) + ->map(fn ($day) => (int) $day) + ->unique() + ->sortDesc() // handle larger days first + ->values(); + + $eventPackageExpiryDays = collect(config('package-limits.package_expiry_days', [])) + ->filter(fn ($day) => is_numeric($day) && $day > 0) + ->map(fn ($day) => (int) $day) + ->unique() + ->sortDesc() + ->values(); + + $creditThresholds = collect(config('package-limits.credit_thresholds', [])) + ->filter(fn ($value) => is_numeric($value) && $value >= 0) + ->map(fn ($value) => (int) $value) + ->unique() + ->sortDesc() + ->values(); + + $maxCreditThreshold = $creditThresholds->max(); + + $now = now(); + + if ($maxCreditThreshold !== null) { + \App\Models\Tenant::query() + ->select(['id', 'event_credits_balance', 'credit_warning_sent_at', 'credit_warning_threshold', 'contact_email']) + ->chunkById(100, function ($tenants) use ($creditThresholds, $maxCreditThreshold, $now) { + foreach ($tenants as $tenant) { + $balance = (int) ($tenant->event_credits_balance ?? 0); + + if ($balance > $maxCreditThreshold && $tenant->credit_warning_sent_at) { + $tenant->forceFill([ + 'credit_warning_sent_at' => null, + 'credit_warning_threshold' => null, + ])->save(); + + continue; + } + + foreach ($creditThresholds as $threshold) { + if ( + $balance <= $threshold + && ( + $tenant->credit_warning_sent_at === null + || $threshold < ($tenant->credit_warning_threshold ?? PHP_INT_MAX) + ) + ) { + event(new \App\Events\Packages\TenantCreditsLow($tenant, $balance, $threshold)); + $tenant->forceFill([ + 'credit_warning_sent_at' => $now, + 'credit_warning_threshold' => $threshold, + ])->save(); + break; + } + } + } + }); + } + + EventPackage::query() + ->whereNotNull('gallery_expires_at') + ->chunkById(100, function ($packages) use ($warningDays, $now) { + foreach ($packages as $package) { + $expiresAt = $package->gallery_expires_at; + if (! $expiresAt) { + continue; + } + + $daysDiff = $now->diffInDays($expiresAt, false); + + if ($daysDiff < 0) { + if (! $package->gallery_expired_notified_at) { + event(new EventPackageGalleryExpired($package)); + $package->forceFill([ + 'gallery_expired_notified_at' => $now, + ])->save(); + } + + continue; + } + + if ($warningDays->isEmpty()) { + continue; + } + + if ($package->gallery_warning_sent_at) { + continue; + } + + foreach ($warningDays as $day) { + if ($daysDiff <= $day && $daysDiff >= 0) { + event(new EventPackageGalleryExpiring($package, $day)); + $package->forceFill([ + 'gallery_warning_sent_at' => $now, + ])->save(); + break; + } + } + } + }); + + \App\Models\TenantPackage::query() + ->with(['tenant', 'package']) + ->whereNotNull('expires_at') + ->chunkById(100, function ($tenantPackages) use ($eventPackageExpiryDays, $now) { + foreach ($tenantPackages as $tenantPackage) { + $expiresAt = $tenantPackage->expires_at; + if (! $expiresAt) { + continue; + } + + $daysDiff = $now->diffInDays($expiresAt, false); + + if ($daysDiff < 0) { + if (! $tenantPackage->expired_notified_at) { + event(new \App\Events\Packages\TenantPackageExpired($tenantPackage)); + $tenantPackage->forceFill(['expired_notified_at' => $now])->save(); + } + + continue; + } + + if ($tenantPackage->expiry_warning_sent_at) { + continue; + } + + foreach ($eventPackageExpiryDays as $day) { + if ($daysDiff <= $day && $daysDiff >= 0) { + event(new \App\Events\Packages\TenantPackageExpiring($tenantPackage, $day)); + $tenantPackage->forceFill(['expiry_warning_sent_at' => $now])->save(); + break; + } + } + } + }); + + return self::SUCCESS; + } +} diff --git a/app/Events/Packages/EventPackageGalleryExpired.php b/app/Events/Packages/EventPackageGalleryExpired.php new file mode 100644 index 0000000..3b78e6e --- /dev/null +++ b/app/Events/Packages/EventPackageGalleryExpired.php @@ -0,0 +1,15 @@ + $diskName, 'error' => $e->getMessage(), ]); + continue; } @@ -952,7 +968,7 @@ class EventPublicController extends BaseController return $result; } - [$event] = $result; + [$event, $joinToken] = $result; $eventId = $event->id; $locale = $request->query('locale', 'de'); @@ -965,7 +981,7 @@ class EventPublicController extends BaseController 'emotions.id', 'emotions.name', 'emotions.icon as emoji', - 'emotions.description' + 'emotions.description', ]) ->orderBy('emotions.sort_order') ->get(); @@ -973,13 +989,13 @@ class EventPublicController extends BaseController $payload = $rows->map(function ($r) use ($locale) { $nameData = json_decode($r->name, true); $name = $nameData[$locale] ?? $nameData['de'] ?? $r->name; - + $descriptionData = json_decode($r->description, true); $description = $descriptionData ? ($descriptionData[$locale] ?? $descriptionData['de'] ?? '') : ''; - + return [ 'id' => (int) $r->id, - 'slug' => 'emotion-' . $r->id, // Generate slug from ID + 'slug' => 'emotion-'.$r->id, // Generate slug from ID 'name' => $name, 'emoji' => $r->emoji, 'description' => $description, @@ -1005,7 +1021,7 @@ class EventPublicController extends BaseController return $result; } - [$event] = $result; + [$event, $joinToken] = $result; $eventId = $event->id; $query = DB::table('tasks') @@ -1018,7 +1034,7 @@ class EventPublicController extends BaseController 'tasks.description', 'tasks.example_text as instructions', 'tasks.emotion_id', - 'tasks.sort_order' + 'tasks.sort_order', ]) ->orderBy('event_task_collection.sort_order') ->orderBy('tasks.sort_order') @@ -1033,8 +1049,10 @@ class EventPublicController extends BaseController $value = $r->$field; if (is_string($value) && json_decode($value) !== null) { $data = json_decode($value, true); + return $data[$locale] ?? $data['de'] ?? $default; } + return $value ?: $default; }; @@ -1051,7 +1069,7 @@ class EventPublicController extends BaseController $emotionName = $value ?: 'Unbekannte Emotion'; } $emotion = [ - 'slug' => 'emotion-' . $r->emotion_id, // Generate slug from ID + 'slug' => 'emotion-'.$r->emotion_id, // Generate slug from ID 'name' => $emotionName, ]; } @@ -1092,7 +1110,7 @@ class EventPublicController extends BaseController return $result; } - [$event] = $result; + [$event, $joinToken] = $result; $eventId = $event->id; $deviceId = (string) $request->header('X-Device-Id', 'anon'); @@ -1109,7 +1127,7 @@ class EventPublicController extends BaseController 'photos.emotion_id', 'photos.task_id', 'photos.guest_name', - 'tasks.title as task_title' + 'tasks.title as task_title', ]) ->where('photos.event_id', $eventId) ->orderByDesc('photos.created_at') @@ -1126,14 +1144,14 @@ class EventPublicController extends BaseController $locale = $request->query('locale', 'de'); $rows = $query->get()->map(function ($r) use ($locale) { - $r->file_path = $this->toPublicUrl((string)($r->file_path ?? '')); - $r->thumbnail_path = $this->toPublicUrl((string)($r->thumbnail_path ?? '')); - + $r->file_path = $this->toPublicUrl((string) ($r->file_path ?? '')); + $r->thumbnail_path = $this->toPublicUrl((string) ($r->thumbnail_path ?? '')); + // Localize task title if present if ($r->task_title) { $r->task_title = $this->getLocalized($r->task_title, $locale, 'Unbenannte Aufgabe'); } - + return $r; }); $latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at'); @@ -1146,10 +1164,11 @@ class EventPublicController extends BaseController if ($reqEtag && $reqEtag === $etag) { return response('', 304); } + return response()->json($payload) ->header('Cache-Control', 'no-store') ->header('ETag', $etag) - ->header('Last-Modified', (string)$latestPhotoAt); + ->header('Last-Modified', (string) $latestPhotoAt); } public function photo(int $id) @@ -1166,7 +1185,7 @@ class EventPublicController extends BaseController 'photos.emotion_id', 'photos.task_id', 'photos.created_at', - 'tasks.title as task_title' + 'tasks.title as task_title', ]) ->where('photos.id', $id) ->where('events.status', 'published') @@ -1174,15 +1193,15 @@ class EventPublicController extends BaseController if (! $row) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found or event not public']], 404); } - $row->file_path = $this->toPublicUrl((string)($row->file_path ?? '')); - $row->thumbnail_path = $this->toPublicUrl((string)($row->thumbnail_path ?? '')); - + $row->file_path = $this->toPublicUrl((string) ($row->file_path ?? '')); + $row->thumbnail_path = $this->toPublicUrl((string) ($row->thumbnail_path ?? '')); + // Localize task title if present $locale = request()->query('locale', 'de'); if ($row->task_title) { $row->task_title = $this->getLocalized($row->task_title, $locale, 'Unbenannte Aufgabe'); } - + return response()->json($row)->header('Cache-Control', 'no-store'); } @@ -1207,6 +1226,7 @@ class EventPublicController extends BaseController $exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists(); if ($exists) { $count = (int) DB::table('photos')->where('id', $id)->value('likes_count'); + return response()->json(['liked' => true, 'likes_count' => $count]); } @@ -1241,9 +1261,42 @@ class EventPublicController extends BaseController return $result; } - [$event] = $result; + [$event, $joinToken] = $result; $eventId = $event->id; + $eventModel = Event::with([ + 'tenant', + 'eventPackage.package', + 'eventPackages.package', + 'storageAssignments.storageTarget', + ])->findOrFail($eventId); + + $tenantModel = $eventModel->tenant; + + if (! $tenantModel) { + return ApiError::response( + 'event_not_found', + 'Event not accessible', + 'The selected event is no longer available.', + Response::HTTP_NOT_FOUND, + ['scope' => 'photos', 'event_id' => $eventId] + ); + } + + $violation = $this->packageLimitEvaluator->assessPhotoUpload($tenantModel, $eventId, $eventModel); + if ($violation !== null) { + return ApiError::response( + $violation['code'], + $violation['title'], + $violation['message'], + $violation['status'], + $violation['meta'] + ); + } + + $eventPackage = $this->packageLimitEvaluator + ->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel); + $deviceId = (string) $request->header('X-Device-Id', 'anon'); $deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64) ?: 'anon'; @@ -1263,7 +1316,19 @@ class EventPublicController extends BaseController Response::HTTP_TOO_MANY_REQUESTS ); - return response()->json(['error' => ['code' => 'limit_reached', 'message' => 'Upload-Limit erreicht']], 429); + return ApiError::response( + 'upload_device_limit', + 'Device upload limit reached', + 'This device cannot upload more photos for this event.', + Response::HTTP_TOO_MANY_REQUESTS, + [ + 'scope' => 'photos', + 'event_id' => $eventId, + 'device_id' => $deviceId, + 'limit' => 50, + 'used' => $deviceCount, + ] + ); } $validated = $request->validate([ @@ -1294,7 +1359,7 @@ class EventPublicController extends BaseController 'file_path' => $url, 'thumbnail_path' => $thumbUrl, 'likes_count' => 0, - + // Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default 'emotion_id' => $this->resolveEmotionId($validated, $eventId), 'is_featured' => 0, @@ -1333,6 +1398,13 @@ class EventPublicController extends BaseController ->where('id', $photoId) ->update(['media_asset_id' => $asset->id]); + if ($eventPackage) { + $previousUsed = (int) $eventPackage->used_photos; + $eventPackage->increment('used_photos'); + $eventPackage->refresh(); + $this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, 1); + } + $response = response()->json([ 'id' => $photoId, 'file_path' => $url, @@ -1378,7 +1450,7 @@ class EventPublicController extends BaseController ->where('emotions.id', $id) ->where('events.id', $eventId) ->exists(); - + if ($exists) { return $id; } @@ -1396,6 +1468,7 @@ class EventPublicController extends BaseController return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists) } + public function achievements(Request $request, string $identifier) { $result = $this->resolvePublishedEvent($request, $identifier, ['id']); @@ -1628,5 +1701,4 @@ class EventPublicController extends BaseController return $path; } } - } diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index e5861e0..fbdec2b 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -5,37 +5,69 @@ namespace App\Http\Controllers\Api\Tenant; use App\Http\Controllers\Controller; use App\Http\Requests\Tenant\PhotoStoreRequest; use App\Http\Resources\Tenant\PhotoResource; -use App\Models\Event; -use App\Models\Photo; -use App\Support\ImageHelper; -use App\Services\Storage\EventStorageManager; use App\Jobs\ProcessPhotoSecurityScan; -use Illuminate\Http\Request; +use App\Models\Event; +use App\Models\EventMediaAsset; +use App\Models\Photo; +use App\Services\Packages\PackageLimitEvaluator; +use App\Services\Packages\PackageUsageTracker; +use App\Services\Storage\EventStorageManager; +use App\Support\ApiError; +use App\Support\ImageHelper; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Storage; -use Illuminate\Support\Facades\Validator; -use Illuminate\Validation\ValidationException; -use Illuminate\Support\Str; use Illuminate\Support\Facades\Log; -use App\Models\EventMediaAsset; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; class PhotoController extends Controller { - public function __construct(private readonly EventStorageManager $eventStorageManager) - { - } + public function __construct( + private readonly EventStorageManager $eventStorageManager, + private readonly PackageLimitEvaluator $packageLimitEvaluator, + private readonly PackageUsageTracker $packageUsageTracker, + ) {} + /** * Display a listing of the event's photos. */ public function index(Request $request, string $eventSlug): AnonymousResourceCollection { $tenantId = $request->attributes->get('tenant_id'); - $event = Event::where('slug', $eventSlug) + $event = Event::with([ + 'tenant', + 'eventPackage.package', + 'eventPackages.package', + 'storageAssignments.storageTarget', + ])->where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); + $tenant = $event->tenant; + + if ($tenant) { + $violation = $this->packageLimitEvaluator->assessPhotoUpload($tenant, $event->id, $event); + + if ($violation !== null) { + return ApiError::response( + $violation['code'], + $violation['title'], + $violation['message'], + $violation['status'], + $violation['meta'] + ); + } + } + + $eventPackage = $tenant + ? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event) + : null; + + $previousUsedPhotos = $eventPackage?->used_photos ?? 0; + $query = Photo::where('event_id', $event->id) ->with('event')->withCount('likes') ->orderBy('created_at', 'desc'); @@ -68,7 +100,7 @@ class PhotoController extends Controller $validated = $request->validated(); $file = $request->file('photo'); - if (!$file) { + if (! $file) { throw ValidationException::withMessages([ 'photo' => 'No photo file uploaded.', ]); @@ -76,7 +108,7 @@ class PhotoController extends Controller // Validate file type and size $allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; - if (!in_array($file->getMimeType(), $allowedTypes)) { + if (! in_array($file->getMimeType(), $allowedTypes)) { throw ValidationException::withMessages([ 'photo' => 'Only JPEG, PNG, and WebP images are allowed.', ]); @@ -89,12 +121,11 @@ class PhotoController extends Controller } // Determine storage target - $event->load('storageAssignments.storageTarget'); $disk = $this->eventStorageManager->getHotDiskForEvent($event); // Generate unique filename $extension = $file->getClientOriginalExtension(); - $filename = Str::uuid() . '.' . $extension; + $filename = Str::uuid().'.'.$extension; $path = "events/{$eventSlug}/photos/{$filename}"; // Store original file @@ -160,6 +191,12 @@ class PhotoController extends Controller ProcessPhotoSecurityScan::dispatch($photo->id); + if ($eventPackage) { + $eventPackage->increment('used_photos'); + $eventPackage->refresh(); + $this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsedPhotos, 1); + } + $photo->load('event')->loadCount('likes'); return response()->json([ @@ -283,7 +320,6 @@ class PhotoController extends Controller /** * Bulk approve photos (admin only) */ - public function feature(Request $request, string $eventSlug, Photo $photo): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); @@ -317,6 +353,7 @@ class PhotoController extends Controller return response()->json(['message' => 'Photo removed from featured', 'data' => new PhotoResource($photo)]); } + public function bulkApprove(Request $request, string $eventSlug): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); @@ -436,7 +473,7 @@ class PhotoController extends Controller $totalPhotos = Photo::where('event_id', $event->id)->count(); $pendingPhotos = Photo::where('event_id', $event->id)->where('status', 'pending')->count(); $approvedPhotos = Photo::where('event_id', $event->id)->where('status', 'approved')->count(); - $totalLikes = DB::table('photo_likes')->whereIn('photo_id', + $totalLikes = DB::table('photo_likes')->whereIn('photo_id', Photo::where('event_id', $event->id)->pluck('id') )->count(); $totalStorage = Photo::where('event_id', $event->id)->sum('size'); @@ -492,13 +529,13 @@ class PhotoController extends Controller // Generate unique filename $extension = pathinfo($request->filename, PATHINFO_EXTENSION); - $filename = Str::uuid() . '.' . $extension; + $filename = Str::uuid().'.'.$extension; $path = "events/{$eventSlug}/pending/{$filename}"; // For local storage, return direct upload endpoint // For S3, use Storage::disk('s3')->temporaryUrl() or presigned URL $uploadUrl = url("/api/v1/tenant/events/{$eventSlug}/upload-direct"); - + $fields = [ 'event_id' => $event->id, 'filename' => $filename, @@ -605,9 +642,3 @@ class PhotoController extends Controller ], 201); } } - - - - - - diff --git a/app/Http/Middleware/CreditCheckMiddleware.php b/app/Http/Middleware/CreditCheckMiddleware.php index 6296580..1c49b6c 100644 --- a/app/Http/Middleware/CreditCheckMiddleware.php +++ b/app/Http/Middleware/CreditCheckMiddleware.php @@ -3,12 +3,16 @@ namespace App\Http\Middleware; use App\Models\Tenant; +use App\Services\Packages\PackageLimitEvaluator; +use App\Support\ApiError; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; class CreditCheckMiddleware { + public function __construct(private readonly PackageLimitEvaluator $limitEvaluator) {} + public function handle(Request $request, Closure $next): Response { $tenant = $request->attributes->get('tenant'); @@ -22,10 +26,18 @@ class CreditCheckMiddleware ]); } - if ($this->requiresCredits($request) && !$tenant->canCreateEvent()) { - return response()->json([ - 'error' => 'No available package for creating events. Please purchase a package.', - ], 402); + if ($this->requiresCredits($request)) { + $violation = $this->limitEvaluator->assessEventCreation($tenant); + + if ($violation !== null) { + return ApiError::response( + $violation['code'], + $violation['title'], + $violation['message'], + $violation['status'], + $violation['meta'] + ); + } } return $next($request); diff --git a/app/Http/Middleware/PackageMiddleware.php b/app/Http/Middleware/PackageMiddleware.php index 9dc424a..e2b91b4 100644 --- a/app/Http/Middleware/PackageMiddleware.php +++ b/app/Http/Middleware/PackageMiddleware.php @@ -2,14 +2,17 @@ namespace App\Http\Middleware; -use App\Models\Event; use App\Models\Tenant; +use App\Services\Packages\PackageLimitEvaluator; +use App\Support\ApiError; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; class PackageMiddleware { + public function __construct(private readonly PackageLimitEvaluator $limitEvaluator) {} + public function handle(Request $request, Closure $next): Response { $tenant = $request->attributes->get('tenant'); @@ -23,10 +26,18 @@ class PackageMiddleware ]); } - if ($this->requiresPackageCheck($request) && ! $this->canPerformAction($request, $tenant)) { - return response()->json([ - 'error' => 'Package limits exceeded. Please purchase or upgrade a package.', - ], 402); + if ($this->requiresPackageCheck($request)) { + $violation = $this->detectViolation($request, $tenant); + + if ($violation !== null) { + return ApiError::response( + $violation['code'], + $violation['title'], + $violation['message'], + $violation['status'], + $violation['meta'] + ); + } } return $next($request); @@ -40,29 +51,30 @@ class PackageMiddleware ); } - private function canPerformAction(Request $request, Tenant $tenant): bool + private function detectViolation(Request $request, Tenant $tenant): ?array { if ($request->routeIs('api.v1.tenant.events.store')) { - return $tenant->hasEventAllowance(); + return $this->limitEvaluator->assessEventCreation($tenant); } if ($request->routeIs('api.v1.tenant.events.photos.store')) { - $eventId = $request->input('event_id'); + $eventId = (int) $request->input('event_id'); if (! $eventId) { - return false; + return [ + 'code' => 'event_id_missing', + 'title' => 'Event required', + 'message' => 'An event must be specified to upload photos.', + 'status' => 422, + 'meta' => [ + 'scope' => 'photos', + ], + ]; } - $event = Event::query()->find($eventId); - if (! $event || $event->tenant_id !== $tenant->id) { - return false; - } - $eventPackage = $event->eventPackage; - if (! $eventPackage) { - return false; - } - return $eventPackage->used_photos < ($eventPackage->package->max_photos ?? PHP_INT_MAX); + + return $this->limitEvaluator->assessPhotoUpload($tenant, $eventId); } - return true; + return null; } private function resolveTenant(Request $request): Tenant diff --git a/app/Jobs/Packages/SendEventPackageGalleryExpired.php b/app/Jobs/Packages/SendEventPackageGalleryExpired.php new file mode 100644 index 0000000..2be5232 --- /dev/null +++ b/app/Jobs/Packages/SendEventPackageGalleryExpired.php @@ -0,0 +1,64 @@ +find($this->eventPackageId); + + if (! $eventPackage) { + Log::warning('Gallery expired job skipped; event package missing', [ + 'event_package_id' => $this->eventPackageId, + ]); + + return; + } + + $tenant = $eventPackage->event?->tenant; + if (! $tenant) { + return; + } + + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); + if (! $preferences->shouldNotify($tenant, 'gallery_expired')) { + return; + } + + $emails = collect([ + $tenant->contact_email, + $tenant->user?->email, + ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique(); + + if ($emails->isEmpty()) { + Log::info('Gallery expired notification skipped due to missing recipients', [ + 'event_package_id' => $eventPackage->id, + ]); + + return; + } + + foreach ($emails as $email) { + Notification::route('mail', $email)->notify(new EventPackageGalleryExpiredNotification($eventPackage)); + } + } +} diff --git a/app/Jobs/Packages/SendEventPackageGalleryWarning.php b/app/Jobs/Packages/SendEventPackageGalleryWarning.php new file mode 100644 index 0000000..bc5baa6 --- /dev/null +++ b/app/Jobs/Packages/SendEventPackageGalleryWarning.php @@ -0,0 +1,71 @@ +find($this->eventPackageId); + + if (! $eventPackage) { + Log::warning('Gallery warning job skipped; event package missing', [ + 'event_package_id' => $this->eventPackageId, + ]); + + return; + } + + $tenant = $eventPackage->event?->tenant; + if (! $tenant) { + return; + } + + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); + if (! $preferences->shouldNotify($tenant, 'gallery_warnings')) { + return; + } + + $emails = collect([ + $tenant->contact_email, + $tenant->user?->email, + ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique(); + + if ($emails->isEmpty()) { + Log::info('Gallery warning skipped due to missing recipients', [ + 'event_package_id' => $eventPackage->id, + 'days_remaining' => $this->daysRemaining, + ]); + + return; + } + + foreach ($emails as $email) { + Notification::route('mail', $email)->notify(new EventPackageGalleryExpiringNotification( + $eventPackage, + $this->daysRemaining, + )); + } + } +} diff --git a/app/Jobs/Packages/SendEventPackageGuestLimitNotification.php b/app/Jobs/Packages/SendEventPackageGuestLimitNotification.php new file mode 100644 index 0000000..1278100 --- /dev/null +++ b/app/Jobs/Packages/SendEventPackageGuestLimitNotification.php @@ -0,0 +1,70 @@ +find($this->eventPackageId); + + if (! $eventPackage) { + Log::warning('Guest limit job skipped; event package missing', [ + 'event_package_id' => $this->eventPackageId, + ]); + + return; + } + + $tenant = $eventPackage->event?->tenant; + if (! $tenant) { + return; + } + + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); + if (! $preferences->shouldNotify($tenant, 'guest_limits')) { + return; + } + + $emails = collect([ + $tenant->contact_email, + $tenant->user?->email, + ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique(); + + if ($emails->isEmpty()) { + Log::info('Guest limit notification skipped due to missing recipients', [ + 'event_package_id' => $eventPackage->id, + ]); + + return; + } + + foreach ($emails as $email) { + Notification::route('mail', $email)->notify(new EventPackageGuestLimitNotification( + $eventPackage, + $this->limit, + )); + } + } +} diff --git a/app/Jobs/Packages/SendEventPackageGuestThresholdWarning.php b/app/Jobs/Packages/SendEventPackageGuestThresholdWarning.php new file mode 100644 index 0000000..c4023bd --- /dev/null +++ b/app/Jobs/Packages/SendEventPackageGuestThresholdWarning.php @@ -0,0 +1,75 @@ +find($this->eventPackageId); + + if (! $eventPackage) { + Log::warning('Guest threshold job skipped; event package missing', [ + 'event_package_id' => $this->eventPackageId, + ]); + + return; + } + + $tenant = $eventPackage->event?->tenant; + if (! $tenant) { + return; + } + + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); + if (! $preferences->shouldNotify($tenant, 'guest_thresholds')) { + return; + } + + $emails = collect([ + $tenant->contact_email, + $tenant->user?->email, + ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique(); + + if ($emails->isEmpty()) { + Log::info('Guest threshold notification skipped due to missing recipients', [ + 'event_package_id' => $eventPackage->id, + 'threshold' => $this->threshold, + ]); + + return; + } + + foreach ($emails as $email) { + Notification::route('mail', $email)->notify(new EventPackageGuestThresholdNotification( + $eventPackage, + $this->threshold, + $this->limit, + $this->used, + )); + } + } +} diff --git a/app/Jobs/Packages/SendEventPackagePhotoLimitNotification.php b/app/Jobs/Packages/SendEventPackagePhotoLimitNotification.php new file mode 100644 index 0000000..d39d183 --- /dev/null +++ b/app/Jobs/Packages/SendEventPackagePhotoLimitNotification.php @@ -0,0 +1,73 @@ +find($this->eventPackageId); + + if (! $eventPackage) { + Log::warning('Package limit job skipped; event package missing', [ + 'event_package_id' => $this->eventPackageId, + ]); + + return; + } + + $tenant = $eventPackage->event?->tenant; + if (! $tenant) { + return; + } + + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); + if (! $preferences->shouldNotify($tenant, 'photo_limits')) { + return; + } + + $emails = collect([ + $tenant->contact_email, + $tenant->user?->email, + ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique(); + + if ($emails->isEmpty()) { + Log::info('Package limit notification skipped due to missing recipients', [ + 'event_package_id' => $eventPackage->id, + 'limit' => $this->limit, + ]); + + return; + } + + foreach ($emails as $email) { + Notification::route('mail', $email)->notify( + new EventPackagePhotoLimitNotification( + $eventPackage, + $this->limit, + ) + ); + } + } +} diff --git a/app/Jobs/Packages/SendEventPackagePhotoThresholdWarning.php b/app/Jobs/Packages/SendEventPackagePhotoThresholdWarning.php new file mode 100644 index 0000000..35f9737 --- /dev/null +++ b/app/Jobs/Packages/SendEventPackagePhotoThresholdWarning.php @@ -0,0 +1,77 @@ +find($this->eventPackageId); + + if (! $eventPackage) { + Log::warning('Package threshold job skipped; event package missing', [ + 'event_package_id' => $this->eventPackageId, + ]); + + return; + } + + $tenant = $eventPackage->event?->tenant; + if (! $tenant) { + return; + } + + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); + if (! $preferences->shouldNotify($tenant, 'photo_thresholds')) { + return; + } + + $emails = collect([ + $tenant->contact_email, + $tenant->user?->email, + ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique(); + + if ($emails->isEmpty()) { + Log::info('Package threshold notification skipped due to missing recipients', [ + 'event_package_id' => $eventPackage->id, + 'threshold' => $this->threshold, + ]); + + return; + } + + foreach ($emails as $email) { + Notification::route('mail', $email)->notify( + new EventPackagePhotoThresholdNotification( + $eventPackage, + $this->threshold, + $this->limit, + $this->used, + ) + ); + } + } +} diff --git a/app/Jobs/Packages/SendTenantCreditsLowNotification.php b/app/Jobs/Packages/SendTenantCreditsLowNotification.php new file mode 100644 index 0000000..4987a2d --- /dev/null +++ b/app/Jobs/Packages/SendTenantCreditsLowNotification.php @@ -0,0 +1,67 @@ +tenantId); + + if (! $tenant) { + Log::warning('Tenant credits low job skipped; tenant missing', [ + 'tenant_id' => $this->tenantId, + ]); + + return; + } + + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); + if (! $preferences->shouldNotify($tenant, 'credits_low')) { + return; + } + + $emails = collect([ + $tenant->contact_email, + $tenant->user?->email, + ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique(); + + if ($emails->isEmpty()) { + Log::info('Tenant credits low notification skipped due to missing recipients', [ + 'tenant_id' => $tenant->id, + ]); + + return; + } + + foreach ($emails as $email) { + Notification::route('mail', $email)->notify(new TenantCreditsLowNotification( + $tenant, + $this->balance, + $this->threshold, + )); + } + } +} diff --git a/app/Jobs/Packages/SendTenantPackageEventLimitNotification.php b/app/Jobs/Packages/SendTenantPackageEventLimitNotification.php new file mode 100644 index 0000000..6f0b525 --- /dev/null +++ b/app/Jobs/Packages/SendTenantPackageEventLimitNotification.php @@ -0,0 +1,65 @@ +find($this->tenantPackageId); + + if (! $tenantPackage || ! $tenantPackage->tenant) { + Log::warning('Tenant package event limit job skipped', [ + 'tenant_package_id' => $this->tenantPackageId, + ]); + + return; + } + + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); + if (! $preferences->shouldNotify($tenantPackage->tenant, 'event_limits')) { + return; + } + + $emails = collect([ + $tenantPackage->tenant->contact_email, + $tenantPackage->tenant->user?->email, + ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique(); + + if ($emails->isEmpty()) { + Log::info('Tenant package event limit notification skipped due to missing recipients', [ + 'tenant_package_id' => $tenantPackage->id, + ]); + + return; + } + + foreach ($emails as $email) { + Notification::route('mail', $email)->notify(new TenantPackageEventLimitNotification( + $tenantPackage, + $this->limit, + )); + } + } +} diff --git a/app/Jobs/Packages/SendTenantPackageEventThresholdWarning.php b/app/Jobs/Packages/SendTenantPackageEventThresholdWarning.php new file mode 100644 index 0000000..07e990d --- /dev/null +++ b/app/Jobs/Packages/SendTenantPackageEventThresholdWarning.php @@ -0,0 +1,70 @@ +find($this->tenantPackageId); + + if (! $tenantPackage || ! $tenantPackage->tenant) { + Log::warning('Tenant package event threshold job skipped', [ + 'tenant_package_id' => $this->tenantPackageId, + ]); + + return; + } + + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); + if (! $preferences->shouldNotify($tenantPackage->tenant, 'event_thresholds')) { + return; + } + + $emails = collect([ + $tenantPackage->tenant->contact_email, + $tenantPackage->tenant->user?->email, + ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique(); + + if ($emails->isEmpty()) { + Log::info('Tenant package event threshold notification skipped due to missing recipients', [ + 'tenant_package_id' => $tenantPackage->id, + 'threshold' => $this->threshold, + ]); + + return; + } + + foreach ($emails as $email) { + Notification::route('mail', $email)->notify(new TenantPackageEventThresholdNotification( + $tenantPackage, + $this->threshold, + $this->limit, + $this->used, + )); + } + } +} diff --git a/app/Jobs/Packages/SendTenantPackageExpiredNotification.php b/app/Jobs/Packages/SendTenantPackageExpiredNotification.php new file mode 100644 index 0000000..1887352 --- /dev/null +++ b/app/Jobs/Packages/SendTenantPackageExpiredNotification.php @@ -0,0 +1,59 @@ +find($this->tenantPackageId); + + if (! $tenantPackage || ! $tenantPackage->tenant) { + Log::warning('Tenant package expired job skipped', [ + 'tenant_package_id' => $this->tenantPackageId, + ]); + + return; + } + + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); + if (! $preferences->shouldNotify($tenantPackage->tenant, 'package_expired')) { + return; + } + + $emails = collect([ + $tenantPackage->tenant->contact_email, + $tenantPackage->tenant->user?->email, + ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique(); + + if ($emails->isEmpty()) { + Log::info('Tenant package expired notification skipped due to missing recipients', [ + 'tenant_package_id' => $tenantPackage->id, + ]); + + return; + } + + foreach ($emails as $email) { + Notification::route('mail', $email)->notify(new TenantPackageExpiredNotification($tenantPackage)); + } + } +} diff --git a/app/Jobs/Packages/SendTenantPackageExpiringNotification.php b/app/Jobs/Packages/SendTenantPackageExpiringNotification.php new file mode 100644 index 0000000..9e2643c --- /dev/null +++ b/app/Jobs/Packages/SendTenantPackageExpiringNotification.php @@ -0,0 +1,66 @@ +find($this->tenantPackageId); + + if (! $tenantPackage || ! $tenantPackage->tenant) { + Log::warning('Tenant package expiry warning skipped', [ + 'tenant_package_id' => $this->tenantPackageId, + ]); + + return; + } + + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); + if (! $preferences->shouldNotify($tenantPackage->tenant, 'package_expiring')) { + return; + } + + $emails = collect([ + $tenantPackage->tenant->contact_email, + $tenantPackage->tenant->user?->email, + ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique(); + + if ($emails->isEmpty()) { + Log::info('Tenant package expiry warning skipped due to missing recipients', [ + 'tenant_package_id' => $tenantPackage->id, + 'days_remaining' => $this->daysRemaining, + ]); + + return; + } + + foreach ($emails as $email) { + Notification::route('mail', $email)->notify(new TenantPackageExpiringNotification( + $tenantPackage, + $this->daysRemaining, + )); + } + } +} diff --git a/app/Listeners/Packages/QueueGalleryExpiredNotification.php b/app/Listeners/Packages/QueueGalleryExpiredNotification.php new file mode 100644 index 0000000..e9363af --- /dev/null +++ b/app/Listeners/Packages/QueueGalleryExpiredNotification.php @@ -0,0 +1,14 @@ +eventPackage->id); + } +} diff --git a/app/Listeners/Packages/QueueGalleryWarningNotification.php b/app/Listeners/Packages/QueueGalleryWarningNotification.php new file mode 100644 index 0000000..6c20cab --- /dev/null +++ b/app/Listeners/Packages/QueueGalleryWarningNotification.php @@ -0,0 +1,17 @@ +eventPackage->id, + $event->daysRemaining + ); + } +} diff --git a/app/Listeners/Packages/QueueGuestLimitNotification.php b/app/Listeners/Packages/QueueGuestLimitNotification.php new file mode 100644 index 0000000..1f80e08 --- /dev/null +++ b/app/Listeners/Packages/QueueGuestLimitNotification.php @@ -0,0 +1,17 @@ +eventPackage->id, + $event->limit + ); + } +} diff --git a/app/Listeners/Packages/QueueGuestThresholdNotification.php b/app/Listeners/Packages/QueueGuestThresholdNotification.php new file mode 100644 index 0000000..14edf63 --- /dev/null +++ b/app/Listeners/Packages/QueueGuestThresholdNotification.php @@ -0,0 +1,19 @@ +eventPackage->id, + $event->threshold, + $event->limit, + $event->used + ); + } +} diff --git a/app/Listeners/Packages/QueuePhotoLimitNotification.php b/app/Listeners/Packages/QueuePhotoLimitNotification.php new file mode 100644 index 0000000..84577d6 --- /dev/null +++ b/app/Listeners/Packages/QueuePhotoLimitNotification.php @@ -0,0 +1,17 @@ +eventPackage->id, + $event->limit + ); + } +} diff --git a/app/Listeners/Packages/QueuePhotoThresholdNotification.php b/app/Listeners/Packages/QueuePhotoThresholdNotification.php new file mode 100644 index 0000000..149bbe6 --- /dev/null +++ b/app/Listeners/Packages/QueuePhotoThresholdNotification.php @@ -0,0 +1,19 @@ +eventPackage->id, + $event->threshold, + $event->limit, + $event->used + ); + } +} diff --git a/app/Listeners/Packages/QueueTenantCreditsLowNotification.php b/app/Listeners/Packages/QueueTenantCreditsLowNotification.php new file mode 100644 index 0000000..4cc0f6a --- /dev/null +++ b/app/Listeners/Packages/QueueTenantCreditsLowNotification.php @@ -0,0 +1,18 @@ +tenant->id, + $event->balance, + $event->threshold + ); + } +} diff --git a/app/Listeners/Packages/QueueTenantEventLimitNotification.php b/app/Listeners/Packages/QueueTenantEventLimitNotification.php new file mode 100644 index 0000000..ebda106 --- /dev/null +++ b/app/Listeners/Packages/QueueTenantEventLimitNotification.php @@ -0,0 +1,17 @@ +tenantPackage->id, + $event->limit + ); + } +} diff --git a/app/Listeners/Packages/QueueTenantEventThresholdNotification.php b/app/Listeners/Packages/QueueTenantEventThresholdNotification.php new file mode 100644 index 0000000..c1ef911 --- /dev/null +++ b/app/Listeners/Packages/QueueTenantEventThresholdNotification.php @@ -0,0 +1,19 @@ +tenantPackage->id, + $event->threshold, + $event->limit, + $event->used + ); + } +} diff --git a/app/Listeners/Packages/QueueTenantPackageExpiredNotification.php b/app/Listeners/Packages/QueueTenantPackageExpiredNotification.php new file mode 100644 index 0000000..1bc2156 --- /dev/null +++ b/app/Listeners/Packages/QueueTenantPackageExpiredNotification.php @@ -0,0 +1,14 @@ +tenantPackage->id); + } +} diff --git a/app/Listeners/Packages/QueueTenantPackageExpiringNotification.php b/app/Listeners/Packages/QueueTenantPackageExpiringNotification.php new file mode 100644 index 0000000..df62c4b --- /dev/null +++ b/app/Listeners/Packages/QueueTenantPackageExpiringNotification.php @@ -0,0 +1,17 @@ +tenantPackage->id, + $event->daysRemaining + ); + } +} diff --git a/app/Models/EventPackage.php b/app/Models/EventPackage.php index 74a533e..96500ba 100644 --- a/app/Models/EventPackage.php +++ b/app/Models/EventPackage.php @@ -5,7 +5,6 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Carbon\Carbon; class EventPackage extends Model { @@ -27,6 +26,8 @@ class EventPackage extends Model 'purchased_price' => 'decimal:2', 'purchased_at' => 'datetime', 'gallery_expires_at' => 'datetime', + 'gallery_warning_sent_at' => 'datetime', + 'gallery_expired_notified_at' => 'datetime', 'used_photos' => 'integer', 'used_guests' => 'integer', ]; @@ -48,33 +49,37 @@ class EventPackage extends Model public function canUploadPhoto(): bool { - if (!$this->isActive()) { + if (! $this->isActive()) { return false; } $maxPhotos = $this->package->max_photos ?? 0; + return $this->used_photos < $maxPhotos; } public function canAddGuest(): bool { - if (!$this->isActive()) { + if (! $this->isActive()) { return false; } $maxGuests = $this->package->max_guests ?? 0; + return $this->used_guests < $maxGuests; } public function getRemainingPhotosAttribute(): int { $max = $this->package->max_photos ?? 0; + return max(0, $max - $this->used_photos); } public function getRemainingGuestsAttribute(): int { $max = $this->package->max_guests ?? 0; + return max(0, $max - $this->used_guests); } @@ -83,13 +88,13 @@ class EventPackage extends Model parent::boot(); static::creating(function ($eventPackage) { - if (!$eventPackage->purchased_at) { + if (! $eventPackage->purchased_at) { $eventPackage->purchased_at = now(); } - if (!$eventPackage->gallery_expires_at && $eventPackage->package) { + if (! $eventPackage->gallery_expires_at && $eventPackage->package) { $days = $eventPackage->package->gallery_days ?? 30; $eventPackage->gallery_expires_at = now()->addDays($days); } }); } -} \ No newline at end of file +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 8f7e70c..c3a33b2 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -5,30 +5,31 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; -use App\Models\TenantPackage; -use App\Models\EventCreditsLedger; class Tenant extends Model { use HasFactory; protected $table = 'tenants'; + protected $guarded = []; protected $casts = [ 'features' => 'array', 'settings' => 'array', + 'notification_preferences' => 'array', 'last_activity_at' => 'datetime', 'total_revenue' => 'decimal:2', 'settings_updated_at' => 'datetime', 'subscription_expires_at' => 'datetime', + 'credit_warning_sent_at' => 'datetime', + 'credit_warning_threshold' => 'integer', ]; public function events(): HasMany @@ -83,6 +84,7 @@ class Tenant extends Model } $package->increment('used_events', $amount); + return true; } @@ -90,6 +92,7 @@ class Tenant extends Model { if (is_string($value)) { $this->attributes['settings'] = $value; + return; } @@ -105,6 +108,22 @@ class Tenant extends Model $balance = (int) ($this->event_credits_balance ?? 0) + $amount; $this->forceFill(['event_credits_balance' => $balance])->save(); + $maxThreshold = collect(config('package-limits.credit_thresholds', [])) + ->filter(fn ($value) => is_numeric($value) && $value >= 0) + ->map(fn ($value) => (int) $value) + ->max(); + + if ( + $maxThreshold !== null + && $balance > $maxThreshold + && ($this->credit_warning_sent_at !== null || $this->credit_warning_threshold !== null) + ) { + $this->forceFill([ + 'credit_warning_sent_at' => null, + 'credit_warning_threshold' => null, + ])->save(); + } + EventCreditsLedger::create([ 'tenant_id' => $this->id, 'delta' => $amount, @@ -134,6 +153,12 @@ class Tenant extends Model $balance = $current - $amount; $this->forceFill(['event_credits_balance' => $balance])->save(); + app(\App\Services\Packages\TenantUsageTracker::class)->recordCreditBalance( + $this, + $current, + $balance + ); + EventCreditsLedger::create([ 'tenant_id' => $this->id, 'delta' => -$amount, @@ -166,7 +191,15 @@ class Tenant extends Model { $package = $this->getActiveResellerPackage(); if ($package && $package->canCreateEvent()) { + $previousUsed = (int) $package->used_events; $package->increment('used_events', $amount); + $package->refresh(); + + app(\App\Services\Packages\TenantUsageTracker::class)->recordEventUsage( + $package, + $previousUsed, + $amount + ); Log::info('Tenant package usage recorded', [ 'tenant_id' => $this->id, diff --git a/app/Models/TenantPackage.php b/app/Models/TenantPackage.php index ad892e8..c9399ef 100644 --- a/app/Models/TenantPackage.php +++ b/app/Models/TenantPackage.php @@ -30,6 +30,11 @@ class TenantPackage extends Model 'expires_at' => 'datetime', 'used_events' => 'integer', 'active' => 'boolean', + 'event_warning_sent_at' => 'datetime', + 'event_warning_threshold' => 'float', + 'event_limit_notified_at' => 'datetime', + 'expiry_warning_sent_at' => 'datetime', + 'expired_notified_at' => 'datetime', ]; public function tenant(): BelongsTo diff --git a/app/Notifications/Packages/EventPackageGalleryExpiredNotification.php b/app/Notifications/Packages/EventPackageGalleryExpiredNotification.php new file mode 100644 index 0000000..58f6655 --- /dev/null +++ b/app/Notifications/Packages/EventPackageGalleryExpiredNotification.php @@ -0,0 +1,46 @@ +eventPackage->event; + $tenant = $event?->tenant; + $package = $this->eventPackage->package; + + $eventName = $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback'); + $url = url('/tenant/events/'.($event?->slug ?? '')); + + return (new MailMessage) + ->subject(__('emails.package_limits.gallery_expired.subject', [ + 'event' => $eventName, + ])) + ->greeting(__('emails.package_limits.gallery_expired.greeting', [ + 'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'), + ])) + ->line(__('emails.package_limits.gallery_expired.body', [ + 'event' => $eventName, + 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), + 'date' => optional($this->eventPackage->gallery_expires_at)->toFormattedDateString(), + ])) + ->action(__('emails.package_limits.gallery_expired.action'), $url) + ->line(__('emails.package_limits.footer')); + } +} diff --git a/app/Notifications/Packages/EventPackageGalleryExpiringNotification.php b/app/Notifications/Packages/EventPackageGalleryExpiringNotification.php new file mode 100644 index 0000000..43f1fef --- /dev/null +++ b/app/Notifications/Packages/EventPackageGalleryExpiringNotification.php @@ -0,0 +1,51 @@ +eventPackage->event; + $tenant = $event?->tenant; + $package = $this->eventPackage->package; + + $eventName = $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback'); + $url = url('/tenant/events/'.($event?->slug ?? '')); + + return (new MailMessage) + ->subject(trans_choice('emails.package_limits.gallery_warning.subject', $this->daysRemaining, [ + 'event' => $eventName, + 'days' => $this->daysRemaining, + ])) + ->greeting(__('emails.package_limits.gallery_warning.greeting', [ + 'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'), + ])) + ->line(trans_choice('emails.package_limits.gallery_warning.body', $this->daysRemaining, [ + 'event' => $eventName, + 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), + 'days' => $this->daysRemaining, + 'date' => optional($this->eventPackage->gallery_expires_at)->toFormattedDateString(), + ])) + ->action(__('emails.package_limits.gallery_warning.action'), $url) + ->line(__('emails.package_limits.footer')); + } +} diff --git a/app/Notifications/Packages/EventPackageGuestLimitNotification.php b/app/Notifications/Packages/EventPackageGuestLimitNotification.php new file mode 100644 index 0000000..294a206 --- /dev/null +++ b/app/Notifications/Packages/EventPackageGuestLimitNotification.php @@ -0,0 +1,49 @@ +eventPackage->event; + $tenant = $event?->tenant; + $package = $this->eventPackage->package; + + $eventName = $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback'); + $url = url('/tenant/events/'.($event?->slug ?? '')); + + return (new MailMessage) + ->subject(__('emails.package_limits.guest_limit.subject', [ + 'event' => $eventName, + ])) + ->greeting(__('emails.package_limits.guest_limit.greeting', [ + 'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'), + ])) + ->line(__('emails.package_limits.guest_limit.body', [ + 'event' => $eventName, + 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), + 'limit' => $this->limit, + ])) + ->action(__('emails.package_limits.guest_limit.action'), $url) + ->line(__('emails.package_limits.footer')); + } +} diff --git a/app/Notifications/Packages/EventPackageGuestThresholdNotification.php b/app/Notifications/Packages/EventPackageGuestThresholdNotification.php new file mode 100644 index 0000000..f787260 --- /dev/null +++ b/app/Notifications/Packages/EventPackageGuestThresholdNotification.php @@ -0,0 +1,57 @@ +eventPackage->event; + $tenant = $event?->tenant; + $package = $this->eventPackage->package; + + $percentage = (int) round($this->threshold * 100); + $remaining = max(0, $this->limit - $this->used); + $eventName = $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback'); + $url = url('/tenant/events/'.($event?->slug ?? '')); + + return (new MailMessage) + ->subject(__('emails.package_limits.guest_threshold.subject', [ + 'event' => $eventName, + 'percentage' => $percentage, + ])) + ->greeting(__('emails.package_limits.guest_threshold.greeting', [ + 'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'), + ])) + ->line(__('emails.package_limits.guest_threshold.body', [ + 'event' => $eventName, + 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), + 'percentage' => $percentage, + 'used' => $this->used, + 'limit' => $this->limit, + 'remaining' => $remaining, + ])) + ->action(__('emails.package_limits.guest_threshold.action'), $url) + ->line(__('emails.package_limits.footer')); + } +} diff --git a/app/Notifications/Packages/EventPackagePhotoLimitNotification.php b/app/Notifications/Packages/EventPackagePhotoLimitNotification.php new file mode 100644 index 0000000..126919f --- /dev/null +++ b/app/Notifications/Packages/EventPackagePhotoLimitNotification.php @@ -0,0 +1,49 @@ +eventPackage->event; + $tenant = $event?->tenant; + $package = $this->eventPackage->package; + + $eventName = $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback'); + $url = url('/tenant/events/'.($event?->slug ?? '')); + + return (new MailMessage) + ->subject(__('emails.package_limits.photo_limit.subject', [ + 'event' => $eventName, + ])) + ->greeting(__('emails.package_limits.photo_limit.greeting', [ + 'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'), + ])) + ->line(__('emails.package_limits.photo_limit.body', [ + 'event' => $eventName, + 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), + 'limit' => $this->limit, + ])) + ->action(__('emails.package_limits.photo_limit.action'), $url) + ->line(__('emails.package_limits.footer')); + } +} diff --git a/app/Notifications/Packages/EventPackagePhotoThresholdNotification.php b/app/Notifications/Packages/EventPackagePhotoThresholdNotification.php new file mode 100644 index 0000000..f15facd --- /dev/null +++ b/app/Notifications/Packages/EventPackagePhotoThresholdNotification.php @@ -0,0 +1,58 @@ +eventPackage->event; + $tenant = $event?->tenant; + $package = $this->eventPackage->package; + + $percentage = (int) round($this->threshold * 100); + $remaining = max(0, $this->limit - $this->used); + $eventName = $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback'); + + $url = url('/tenant/events/'.($event?->slug ?? '')); + + return (new MailMessage) + ->subject(__('emails.package_limits.photo_threshold.subject', [ + 'event' => $eventName, + 'percentage' => $percentage, + ])) + ->greeting(__('emails.package_limits.photo_threshold.greeting', [ + 'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'), + ])) + ->line(__('emails.package_limits.photo_threshold.body', [ + 'event' => $eventName, + 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), + 'percentage' => $percentage, + 'used' => $this->used, + 'limit' => $this->limit, + 'remaining' => $remaining, + ])) + ->action(__('emails.package_limits.photo_threshold.action'), $url) + ->line(__('emails.package_limits.footer')); + } +} diff --git a/app/Notifications/Packages/TenantCreditsLowNotification.php b/app/Notifications/Packages/TenantCreditsLowNotification.php new file mode 100644 index 0000000..a83d79b --- /dev/null +++ b/app/Notifications/Packages/TenantCreditsLowNotification.php @@ -0,0 +1,42 @@ +subject(__('emails.package_limits.credits_low.subject')) + ->greeting(__('emails.package_limits.credits_low.greeting', [ + 'name' => $this->tenant->name ?? __('emails.package_limits.team_fallback'), + ])) + ->line(__('emails.package_limits.credits_low.body', [ + 'balance' => $this->balance, + 'threshold' => $this->threshold, + ])) + ->action(__('emails.package_limits.credits_low.action'), $url) + ->line(__('emails.package_limits.footer')); + } +} diff --git a/app/Notifications/Packages/TenantPackageEventLimitNotification.php b/app/Notifications/Packages/TenantPackageEventLimitNotification.php new file mode 100644 index 0000000..ec23740 --- /dev/null +++ b/app/Notifications/Packages/TenantPackageEventLimitNotification.php @@ -0,0 +1,46 @@ +tenantPackage->tenant; + $package = $this->tenantPackage->package; + + $url = url('/tenant/billing'); + + return (new MailMessage) + ->subject(__('emails.package_limits.event_limit.subject', [ + 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), + ])) + ->greeting(__('emails.package_limits.event_limit.greeting', [ + 'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'), + ])) + ->line(__('emails.package_limits.event_limit.body', [ + 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), + 'limit' => $this->limit, + ])) + ->action(__('emails.package_limits.event_limit.action'), $url) + ->line(__('emails.package_limits.footer')); + } +} diff --git a/app/Notifications/Packages/TenantPackageEventThresholdNotification.php b/app/Notifications/Packages/TenantPackageEventThresholdNotification.php new file mode 100644 index 0000000..de71ae0 --- /dev/null +++ b/app/Notifications/Packages/TenantPackageEventThresholdNotification.php @@ -0,0 +1,55 @@ +tenantPackage->tenant; + $package = $this->tenantPackage->package; + + $percentage = (int) round($this->threshold * 100); + $remaining = max(0, $this->limit - $this->used); + + $url = url('/tenant/billing'); + + return (new MailMessage) + ->subject(__('emails.package_limits.event_threshold.subject', [ + 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), + 'percentage' => $percentage, + ])) + ->greeting(__('emails.package_limits.event_threshold.greeting', [ + 'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'), + ])) + ->line(__('emails.package_limits.event_threshold.body', [ + 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), + 'percentage' => $percentage, + 'used' => $this->used, + 'limit' => $this->limit, + 'remaining' => $remaining, + ])) + ->action(__('emails.package_limits.event_threshold.action'), $url) + ->line(__('emails.package_limits.footer')); + } +} diff --git a/app/Notifications/Packages/TenantPackageExpiredNotification.php b/app/Notifications/Packages/TenantPackageExpiredNotification.php new file mode 100644 index 0000000..2e53d9c --- /dev/null +++ b/app/Notifications/Packages/TenantPackageExpiredNotification.php @@ -0,0 +1,43 @@ +tenantPackage->tenant; + $package = $this->tenantPackage->package; + + $url = url('/tenant/billing'); + + return (new MailMessage) + ->subject(__('emails.package_limits.package_expired.subject', [ + 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), + ])) + ->greeting(__('emails.package_limits.package_expired.greeting', [ + 'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'), + ])) + ->line(__('emails.package_limits.package_expired.body', [ + 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), + 'date' => optional($this->tenantPackage->expires_at)->toFormattedDateString(), + ])) + ->action(__('emails.package_limits.package_expired.action'), $url) + ->line(__('emails.package_limits.footer')); + } +} diff --git a/app/Notifications/Packages/TenantPackageExpiringNotification.php b/app/Notifications/Packages/TenantPackageExpiringNotification.php new file mode 100644 index 0000000..ae9f14b --- /dev/null +++ b/app/Notifications/Packages/TenantPackageExpiringNotification.php @@ -0,0 +1,48 @@ +tenantPackage->tenant; + $package = $this->tenantPackage->package; + + $url = url('/tenant/billing'); + + return (new MailMessage) + ->subject(trans_choice('emails.package_limits.package_expiring.subject', $this->daysRemaining, [ + 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), + 'days' => $this->daysRemaining, + ])) + ->greeting(__('emails.package_limits.package_expiring.greeting', [ + 'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'), + ])) + ->line(trans_choice('emails.package_limits.package_expiring.body', $this->daysRemaining, [ + 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), + 'days' => $this->daysRemaining, + 'date' => optional($this->tenantPackage->expires_at)->toFormattedDateString(), + ])) + ->action(__('emails.package_limits.package_expiring.action'), $url) + ->line(__('emails.package_limits.footer')); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f5a2538..4e434ca 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,16 +2,39 @@ namespace App\Providers; +use App\Events\Packages\EventPackageGalleryExpired; +use App\Events\Packages\EventPackageGalleryExpiring; +use App\Events\Packages\EventPackageGuestLimitReached; +use App\Events\Packages\EventPackageGuestThresholdReached; +use App\Events\Packages\EventPackagePhotoLimitReached; +use App\Events\Packages\EventPackagePhotoThresholdReached; +use App\Events\Packages\TenantCreditsLow; +use App\Events\Packages\TenantPackageEventLimitReached; +use App\Events\Packages\TenantPackageEventThresholdReached; +use App\Events\Packages\TenantPackageExpired; +use App\Events\Packages\TenantPackageExpiring; +use App\Listeners\Packages\QueueGalleryExpiredNotification; +use App\Listeners\Packages\QueueGalleryWarningNotification; +use App\Listeners\Packages\QueueGuestLimitNotification; +use App\Listeners\Packages\QueueGuestThresholdNotification; +use App\Listeners\Packages\QueuePhotoLimitNotification; +use App\Listeners\Packages\QueuePhotoThresholdNotification; +use App\Listeners\Packages\QueueTenantCreditsLowNotification; +use App\Listeners\Packages\QueueTenantEventLimitNotification; +use App\Listeners\Packages\QueueTenantEventThresholdNotification; +use App\Listeners\Packages\QueueTenantPackageExpiredNotification; +use App\Listeners\Packages\QueueTenantPackageExpiringNotification; +use App\Notifications\UploadPipelineFailed; use App\Services\Checkout\CheckoutAssignmentService; use App\Services\Checkout\CheckoutPaymentService; use App\Services\Checkout\CheckoutSessionService; -use App\Notifications\UploadPipelineFailed; +use App\Services\Security\PhotoSecurityScanner; use App\Services\Storage\EventStorageManager; use App\Services\Storage\StorageHealthService; -use App\Services\Security\PhotoSecurityScanner; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Queue\Events\JobFailed; +use Illuminate\Support\Facades\Event as EventFacade; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\RateLimiter; @@ -40,25 +63,80 @@ class AppServiceProvider extends ServiceProvider { $this->app->make(EventStorageManager::class)->registerDynamicDisks(); + EventFacade::listen( + EventPackagePhotoThresholdReached::class, + [QueuePhotoThresholdNotification::class, 'handle'] + ); + + EventFacade::listen( + EventPackagePhotoLimitReached::class, + [QueuePhotoLimitNotification::class, 'handle'] + ); + + EventFacade::listen( + EventPackageGuestThresholdReached::class, + [QueueGuestThresholdNotification::class, 'handle'] + ); + + EventFacade::listen( + EventPackageGuestLimitReached::class, + [QueueGuestLimitNotification::class, 'handle'] + ); + + EventFacade::listen( + EventPackageGalleryExpiring::class, + [QueueGalleryWarningNotification::class, 'handle'] + ); + + EventFacade::listen( + EventPackageGalleryExpired::class, + [QueueGalleryExpiredNotification::class, 'handle'] + ); + + EventFacade::listen( + TenantPackageEventThresholdReached::class, + [QueueTenantEventThresholdNotification::class, 'handle'] + ); + + EventFacade::listen( + TenantPackageEventLimitReached::class, + [QueueTenantEventLimitNotification::class, 'handle'] + ); + + EventFacade::listen( + TenantPackageExpiring::class, + [QueueTenantPackageExpiringNotification::class, 'handle'] + ); + + EventFacade::listen( + TenantPackageExpired::class, + [QueueTenantPackageExpiredNotification::class, 'handle'] + ); + + EventFacade::listen( + TenantCreditsLow::class, + [QueueTenantCreditsLowNotification::class, 'handle'] + ); + RateLimiter::for('tenant-api', function (Request $request) { $tenantId = $request->attributes->get('tenant_id') ?? $request->user()?->tenant_id ?? $request->user()?->tenant?->id; - $key = $tenantId ? 'tenant:' . $tenantId : ('ip:' . ($request->ip() ?? 'unknown')); + $key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown')); return Limit::perMinute(100)->by($key); }); RateLimiter::for('oauth', function (Request $request) { - return Limit::perMinute(10)->by('oauth:' . ($request->ip() ?? 'unknown')); + return Limit::perMinute(10)->by('oauth:'.($request->ip() ?? 'unknown')); }); Inertia::share('locale', fn () => app()->getLocale()); Inertia::share('analytics', static function () { $config = config('services.matomo'); - if (!($config['enabled'] ?? false)) { + if (! ($config['enabled'] ?? false)) { return [ 'matomo' => [ 'enabled' => false, diff --git a/app/Services/EventJoinTokenService.php b/app/Services/EventJoinTokenService.php index c0c7fdc..9b4878b 100644 --- a/app/Services/EventJoinTokenService.php +++ b/app/Services/EventJoinTokenService.php @@ -58,6 +58,25 @@ class EventJoinTokenService public function incrementUsage(EventJoinToken $joinToken): void { $joinToken->increment('usage_count'); + + $event = $joinToken->event() + ->with(['eventPackage.package', 'eventPackages.package', 'tenant']) + ->first(); + + if ($event && $event->tenant) { + $usageTracker = app(\App\Services\Packages\PackageUsageTracker::class); + $limitEvaluator = app(\App\Services\Packages\PackageLimitEvaluator::class); + + $eventPackage = $limitEvaluator->resolveEventPackageForPhotoUpload($event->tenant, $event->id, $event); + + if ($eventPackage && $eventPackage->package?->max_guests !== null) { + $previous = (int) $eventPackage->used_guests; + $eventPackage->increment('used_guests'); + $eventPackage->refresh(); + + $usageTracker->recordGuestUsage($eventPackage, $previous, 1); + } + } } public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken diff --git a/app/Services/Packages/PackageLimitEvaluator.php b/app/Services/Packages/PackageLimitEvaluator.php new file mode 100644 index 0000000..c68649e --- /dev/null +++ b/app/Services/Packages/PackageLimitEvaluator.php @@ -0,0 +1,151 @@ +hasEventAllowance()) { + return null; + } + + $package = $tenant->getActiveResellerPackage(); + + if ($package) { + $limit = $package->package->max_events_per_year ?? 0; + + return [ + 'code' => 'event_limit_exceeded', + 'title' => 'Event quota reached', + 'message' => 'Your current package has no remaining event slots. Please upgrade or renew your subscription.', + 'status' => 402, + 'meta' => [ + 'scope' => 'events', + 'used' => (int) $package->used_events, + 'limit' => $limit, + 'remaining' => max(0, $limit - $package->used_events), + 'tenant_package_id' => $package->id, + 'package_id' => $package->package_id, + ], + ]; + } + + return [ + 'code' => 'event_credits_exhausted', + 'title' => 'No event credits remaining', + 'message' => 'You have no event credits remaining. Purchase additional credits or a package to create new events.', + 'status' => 402, + 'meta' => [ + 'scope' => 'credits', + 'balance' => (int) ($tenant->event_credits_balance ?? 0), + ], + ]; + } + + public function assessPhotoUpload(Tenant $tenant, int $eventId, ?Event $preloadedEvent = null): ?array + { + [$event, $eventPackage] = $this->resolveEventAndPackage($tenant, $eventId, $preloadedEvent); + + if (! $event) { + return [ + 'code' => 'event_not_found', + 'title' => 'Event not accessible', + 'message' => 'The selected event could not be found or belongs to another tenant.', + 'status' => 404, + 'meta' => [ + 'scope' => 'photos', + 'event_id' => $eventId, + ], + ]; + } + + if (! $eventPackage || ! $eventPackage->package) { + return [ + 'code' => 'event_package_missing', + 'title' => 'Event package missing', + 'message' => 'No package is attached to this event. Assign a package to enable uploads.', + 'status' => 409, + 'meta' => [ + 'scope' => 'photos', + 'event_id' => $event->id, + ], + ]; + } + + $maxPhotos = $eventPackage->package->max_photos; + + if ($maxPhotos === null) { + return null; + } + + if ($eventPackage->used_photos >= $maxPhotos) { + return [ + 'code' => 'photo_limit_exceeded', + 'title' => 'Photo upload limit reached', + 'message' => 'This event has reached its photo allowance. Upgrade the event package to accept more uploads.', + 'status' => 402, + 'meta' => [ + 'scope' => 'photos', + 'used' => (int) $eventPackage->used_photos, + 'limit' => (int) $maxPhotos, + 'remaining' => 0, + 'event_id' => $event->id, + 'package_id' => $eventPackage->package_id, + ], + ]; + } + + return null; + } + + public function resolveEventPackageForPhotoUpload( + Tenant $tenant, + int $eventId, + ?Event $preloadedEvent = null + ): ?EventPackage { + [, $eventPackage] = $this->resolveEventAndPackage($tenant, $eventId, $preloadedEvent); + + return $eventPackage; + } + + /** + * @return array{0: ?Event, 1: ?\App\Models\EventPackage} + */ + private function resolveEventAndPackage( + Tenant $tenant, + int $eventId, + ?Event $preloadedEvent = null + ): array { + $event = $preloadedEvent; + + if (! $event) { + $event = Event::with(['eventPackage.package', 'eventPackages.package']) + ->find($eventId); + } + + if (! $event || $event->tenant_id !== $tenant->id) { + return [null, null]; + } + + $eventPackage = $event->eventPackage; + + if (! $eventPackage && method_exists($event, 'eventPackages')) { + $eventPackage = $event->eventPackages() + ->with('package') + ->orderByDesc('purchased_at') + ->orderByDesc('created_at') + ->first(); + } + + if ($eventPackage && ! $eventPackage->relationLoaded('package')) { + $eventPackage->load('package'); + } + + return [$event, $eventPackage]; + } +} diff --git a/app/Services/Packages/PackageUsageTracker.php b/app/Services/Packages/PackageUsageTracker.php new file mode 100644 index 0000000..c2c73ca --- /dev/null +++ b/app/Services/Packages/PackageUsageTracker.php @@ -0,0 +1,87 @@ +package?->max_photos; + + if ($limit === null || $limit <= 0) { + return; + } + + $newUsed = $eventPackage->used_photos; + + $thresholds = collect(config('package-limits.photo_thresholds', [])) + ->filter(fn (float $value) => $value > 0 && $value < 1) + ->sort() + ->values(); + + if ($limit > 0) { + $previousRatio = $previousUsed / $limit; + $newRatio = $newUsed / $limit; + + foreach ($thresholds as $threshold) { + if ($previousRatio < $threshold && $newRatio >= $threshold) { + $this->dispatcher->dispatch(new EventPackagePhotoThresholdReached( + $eventPackage, + $threshold, + $limit, + $newUsed, + )); + } + } + } + + if ($newUsed >= $limit && ($previousUsed < $limit)) { + $this->dispatcher->dispatch(new EventPackagePhotoLimitReached($eventPackage, $limit)); + } + } + + public function recordGuestUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void + { + $limit = $eventPackage->package?->max_guests; + + if ($limit === null || $limit <= 0) { + return; + } + + $newUsed = $eventPackage->used_guests; + + $thresholds = collect(config('package-limits.guest_thresholds', [])) + ->filter(fn (float $value) => $value > 0 && $value < 1) + ->sort() + ->values(); + + if ($limit > 0) { + $previousRatio = $previousUsed / $limit; + $newRatio = $newUsed / $limit; + + foreach ($thresholds as $threshold) { + if ($previousRatio < $threshold && $newRatio >= $threshold) { + $this->dispatcher->dispatch(new EventPackageGuestThresholdReached( + $eventPackage, + $threshold, + $limit, + $newUsed, + )); + } + } + } + + if ($newUsed >= $limit && ($previousUsed < $limit)) { + $this->dispatcher->dispatch(new EventPackageGuestLimitReached($eventPackage, $limit)); + } + } +} diff --git a/app/Services/Packages/TenantNotificationPreferences.php b/app/Services/Packages/TenantNotificationPreferences.php new file mode 100644 index 0000000..c054817 --- /dev/null +++ b/app/Services/Packages/TenantNotificationPreferences.php @@ -0,0 +1,37 @@ + true, + 'photo_limits' => true, + 'guest_thresholds' => true, + 'guest_limits' => true, + 'gallery_warnings' => true, + 'gallery_expired' => true, + 'event_thresholds' => true, + 'event_limits' => true, + 'package_expiring' => true, + 'package_expired' => true, + 'credits_low' => true, + ]; + + public function shouldNotify(Tenant $tenant, string $preferenceKey): bool + { + $preferences = $tenant->notification_preferences ?? []; + + if (! is_array($preferences)) { + $preferences = []; + } + + if (array_key_exists($preferenceKey, $preferences)) { + return (bool) $preferences[$preferenceKey]; + } + + return self::DEFAULTS[$preferenceKey] ?? true; + } +} diff --git a/app/Services/Packages/TenantUsageTracker.php b/app/Services/Packages/TenantUsageTracker.php new file mode 100644 index 0000000..2a2fb6c --- /dev/null +++ b/app/Services/Packages/TenantUsageTracker.php @@ -0,0 +1,97 @@ +package?->max_events_per_year; + + if ($limit === null || $limit <= 0) { + return; + } + + $newUsed = (int) $tenantPackage->used_events; + + $thresholds = collect(config('package-limits.event_thresholds', [])) + ->filter(fn (float $value) => $value > 0 && $value < 1) + ->sort() + ->values(); + + if ($limit > 0) { + $previousRatio = $previousUsed / $limit; + $newRatio = $newUsed / $limit; + $currentThreshold = $tenantPackage->event_warning_threshold ?? null; + + foreach ($thresholds as $threshold) { + if ($previousRatio < $threshold && $newRatio >= $threshold) { + if ($currentThreshold !== null && $currentThreshold >= $threshold) { + continue; + } + + $tenantPackage->forceFill([ + 'event_warning_sent_at' => now(), + 'event_warning_threshold' => $threshold, + ])->save(); + + $this->dispatcher->dispatch(new TenantPackageEventThresholdReached( + $tenantPackage, + $threshold, + $limit, + $newUsed, + )); + + $currentThreshold = $threshold; + } + } + } + + if ($newUsed >= $limit && $previousUsed < $limit) { + if (! $tenantPackage->event_limit_notified_at) { + $tenantPackage->forceFill([ + 'event_limit_notified_at' => now(), + ])->save(); + } + + $this->dispatcher->dispatch(new TenantPackageEventLimitReached($tenantPackage, $limit)); + } + } + + public function recordCreditBalance(Tenant $tenant, int $previousBalance, int $newBalance): void + { + $thresholds = collect(config('package-limits.credit_thresholds', [])) + ->filter(fn ($value) => is_numeric($value) && $value >= 0) + ->map(fn ($value) => (int) $value) + ->sortDesc() + ->values(); + + $currentThreshold = $tenant->credit_warning_threshold ?? null; + + foreach ($thresholds as $threshold) { + if ($previousBalance > $threshold && $newBalance <= $threshold) { + if ($currentThreshold !== null && $threshold >= $currentThreshold) { + continue; + } + + $tenant->forceFill([ + 'credit_warning_sent_at' => now(), + 'credit_warning_threshold' => $threshold, + ])->save(); + + $this->dispatcher->dispatch(new TenantCreditsLow($tenant, $newBalance, $threshold)); + + break; + } + } + } +} diff --git a/app/Support/ApiError.php b/app/Support/ApiError.php new file mode 100644 index 0000000..e518247 --- /dev/null +++ b/app/Support/ApiError.php @@ -0,0 +1,30 @@ + [ + 'code' => $code, + 'title' => $title, + 'message' => $message, + ], + ]; + + if ($meta !== []) { + $payload['error']['meta'] = $meta; + } + + return response()->json($payload, $status); + } +} diff --git a/app/Support/JoinTokenLayoutRegistry.php b/app/Support/JoinTokenLayoutRegistry.php index 00e1949..6edf163 100644 --- a/app/Support/JoinTokenLayoutRegistry.php +++ b/app/Support/JoinTokenLayoutRegistry.php @@ -33,7 +33,7 @@ class JoinTokenLayoutRegistry 'link_heading' => 'Falls der Scan nicht klappt', 'cta_label' => 'Gästegalerie öffnen', 'cta_caption' => 'Jetzt Erinnerungen sammeln', - 'qr' => ['size_px' => 520], + 'qr' => ['size_px' => 640], 'svg' => ['width' => 1240, 'height' => 1754], 'instructions' => [ 'QR-Code scannen und mit eurem Lieblingsnamen anmelden.', @@ -62,7 +62,7 @@ class JoinTokenLayoutRegistry 'link_heading' => 'Link teilen statt scannen', 'cta_label' => 'Jetzt Event-Hub öffnen', 'cta_caption' => 'Programm, Uploads & Highlights', - 'qr' => ['size_px' => 560], + 'qr' => ['size_px' => 640], 'svg' => ['width' => 1240, 'height' => 1754], 'instructions' => [ 'QR-Code scannen oder Kurzlink eingeben.', @@ -91,7 +91,7 @@ class JoinTokenLayoutRegistry 'link_heading' => 'Alternativ zum Scannen', 'cta_label' => 'Gästebuch öffnen', 'cta_caption' => 'Eure Grüße festhalten', - 'qr' => ['size_px' => 520], + 'qr' => ['size_px' => 660], 'svg' => ['width' => 1240, 'height' => 1754], 'instructions' => [ 'QR-Code scannen und Namen eintragen.', @@ -120,7 +120,7 @@ class JoinTokenLayoutRegistry 'link_heading' => 'QR funktioniert nicht?', 'cta_label' => 'Partyfeed starten', 'cta_caption' => 'Momente live teilen', - 'qr' => ['size_px' => 560], + 'qr' => ['size_px' => 680], 'svg' => ['width' => 1240, 'height' => 1754], 'instructions' => [ 'Code scannen und kurz registrieren.', @@ -149,7 +149,7 @@ class JoinTokenLayoutRegistry 'link_heading' => 'Kurzlink für Gäste', 'cta_label' => 'Zur Geburtstagswand', 'cta_caption' => 'Fotos & Grüße posten', - 'qr' => ['size_px' => 520], + 'qr' => ['size_px' => 680], 'svg' => ['width' => 1240, 'height' => 1754], 'instructions' => [ 'QR-Code scannen und Wunschname auswählen.', @@ -223,7 +223,7 @@ class JoinTokenLayoutRegistry 'link_label' => null, 'logo_url' => null, 'qr' => [ - 'size_px' => 360, + 'size_px' => 640, ], 'svg' => [ 'width' => 1240, diff --git a/bootstrap/app.php b/bootstrap/app.php index f59a817..a15b258 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -22,7 +22,11 @@ return Application::configure(basePath: dirname(__DIR__)) \App\Console\Commands\OAuthRotateKeysCommand::class, \App\Console\Commands\OAuthListKeysCommand::class, \App\Console\Commands\OAuthPruneKeysCommand::class, + \App\Console\Commands\CheckEventPackages::class, ]) + ->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) { + $schedule->command('package:check-status')->dailyAt('06:00'); + }) ->withMiddleware(function (Middleware $middleware) { $middleware->alias([ 'tenant.token' => TenantTokenGuard::class, diff --git a/config/package-limits.php b/config/package-limits.php new file mode 100644 index 0000000..3264341 --- /dev/null +++ b/config/package-limits.php @@ -0,0 +1,29 @@ + [ + 0.8, + 0.95, + ], + 'guest_thresholds' => [ + 0.8, + 0.95, + ], + 'gallery_warning_days' => [ + 7, + 1, + ], + 'event_thresholds' => [ + 0.8, + 0.95, + ], + 'package_expiry_days' => [ + 30, + 7, + 1, + ], + 'credit_thresholds' => [ + 5, + 1, + ], +]; diff --git a/database/migrations/2025_11_01_121415_add_gallery_notification_columns_to_event_packages_table.php b/database/migrations/2025_11_01_121415_add_gallery_notification_columns_to_event_packages_table.php new file mode 100644 index 0000000..2a5e8dd --- /dev/null +++ b/database/migrations/2025_11_01_121415_add_gallery_notification_columns_to_event_packages_table.php @@ -0,0 +1,34 @@ +timestamp('gallery_warning_sent_at')->nullable()->after('gallery_expires_at'); + } + + if (! Schema::hasColumn('event_packages', 'gallery_expired_notified_at')) { + $table->timestamp('gallery_expired_notified_at')->nullable()->after('gallery_warning_sent_at'); + } + }); + } + + public function down(): void + { + Schema::table('event_packages', function (Blueprint $table) { + if (Schema::hasColumn('event_packages', 'gallery_warning_sent_at')) { + $table->dropColumn('gallery_warning_sent_at'); + } + + if (Schema::hasColumn('event_packages', 'gallery_expired_notified_at')) { + $table->dropColumn('gallery_expired_notified_at'); + } + }); + } +}; diff --git a/database/migrations/2025_11_01_121900_add_notification_preferences_to_tenants_table.php b/database/migrations/2025_11_01_121900_add_notification_preferences_to_tenants_table.php new file mode 100644 index 0000000..55b7bf1 --- /dev/null +++ b/database/migrations/2025_11_01_121900_add_notification_preferences_to_tenants_table.php @@ -0,0 +1,40 @@ +json('notification_preferences')->nullable()->after('settings'); + } + + if (! Schema::hasColumn('tenants', 'credit_warning_sent_at')) { + $table->timestamp('credit_warning_sent_at')->nullable()->after('notification_preferences'); + } + + if (! Schema::hasColumn('tenants', 'credit_warning_threshold')) { + $table->unsignedInteger('credit_warning_threshold')->nullable()->after('credit_warning_sent_at'); + } + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + foreach ([ + 'credit_warning_threshold', + 'credit_warning_sent_at', + 'notification_preferences', + ] as $column) { + if (Schema::hasColumn('tenants', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/database/migrations/2025_11_01_123657_add_notification_columns_to_tenant_packages_table.php b/database/migrations/2025_11_01_123657_add_notification_columns_to_tenant_packages_table.php new file mode 100644 index 0000000..f88c093 --- /dev/null +++ b/database/migrations/2025_11_01_123657_add_notification_columns_to_tenant_packages_table.php @@ -0,0 +1,50 @@ +timestamp('event_warning_sent_at')->nullable()->after('used_events'); + } + + if (! Schema::hasColumn('tenant_packages', 'event_warning_threshold')) { + $table->decimal('event_warning_threshold', 5, 2)->nullable()->after('event_warning_sent_at'); + } + + if (! Schema::hasColumn('tenant_packages', 'event_limit_notified_at')) { + $table->timestamp('event_limit_notified_at')->nullable()->after('event_warning_threshold'); + } + + if (! Schema::hasColumn('tenant_packages', 'expiry_warning_sent_at')) { + $table->timestamp('expiry_warning_sent_at')->nullable()->after('event_limit_notified_at'); + } + + if (! Schema::hasColumn('tenant_packages', 'expired_notified_at')) { + $table->timestamp('expired_notified_at')->nullable()->after('expiry_warning_sent_at'); + } + }); + } + + public function down(): void + { + Schema::table('tenant_packages', function (Blueprint $table) { + foreach ([ + 'expired_notified_at', + 'expiry_warning_sent_at', + 'event_limit_notified_at', + 'event_warning_threshold', + 'event_warning_sent_at', + ] as $column) { + if (Schema::hasColumn('tenant_packages', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/database/seeders/InviteLayoutSeeder.php b/database/seeders/InviteLayoutSeeder.php index a1295e0..4e0abf5 100644 --- a/database/seeders/InviteLayoutSeeder.php +++ b/database/seeders/InviteLayoutSeeder.php @@ -14,8 +14,28 @@ class InviteLayoutSeeder extends Seeder $reflection = new ReflectionClass(JoinTokenLayoutRegistry::class); $layoutsConst = $reflection->getReflectionConstant('LAYOUTS'); $fallbackLayouts = $layoutsConst ? $layoutsConst->getValue() : []; + $qrSizeOverrides = [ + 'evergreen-vows' => 640, + 'midnight-gala' => 640, + 'garden-brunch' => 660, + 'sparkler-soiree' => 680, + 'confetti-bash' => 680, + ]; + $defaultQrSize = 640; + $targetSvgWidth = 1240; + $targetSvgHeight = 1754; foreach ($fallbackLayouts as $layout) { + $layoutId = $layout['id'] ?? null; + $forcedQrSize = $qrSizeOverrides[$layoutId] ?? $defaultQrSize; + $existingQrSize = (int) ($layout['qr']['size_px'] ?? $layout['qr_size_px'] ?? 0); + $qrSize = max($existingQrSize, $forcedQrSize); + + $existingSvgWidth = (int) ($layout['svg']['width'] ?? $layout['svg_width'] ?? 0); + $existingSvgHeight = (int) ($layout['svg']['height'] ?? $layout['svg_height'] ?? 0); + $svgWidth = max($existingSvgWidth, $targetSvgWidth); + $svgHeight = max($existingSvgHeight, $targetSvgHeight); + $preview = [ 'background' => $layout['background'] ?? null, 'background_gradient' => $layout['background_gradient'] ?? null, @@ -23,8 +43,8 @@ class InviteLayoutSeeder extends Seeder 'secondary' => $layout['secondary'] ?? null, 'text' => $layout['text'] ?? null, 'badge' => $layout['badge'] ?? null, - 'qr' => $layout['qr'] ?? ['size_px' => 500], - 'svg' => $layout['svg'] ?? ['width' => 1240, 'height' => 1754], + 'qr' => ['size_px' => $qrSize], + 'svg' => ['width' => $svgWidth, 'height' => $svgHeight], ]; $options = [ diff --git a/docs/todo/package-limit-experience-overhaul.md b/docs/todo/package-limit-experience-overhaul.md new file mode 100644 index 0000000..8392b24 --- /dev/null +++ b/docs/todo/package-limit-experience-overhaul.md @@ -0,0 +1,67 @@ +# Package Limit Experience Overhaul + +**Status:** Planned +**Owner:** Codegen Agent +**Related Areas:** Packages, Tenant Admin PWA, Guest PWA, Notifications + +## Motivation +- Uneinheitliche Limit-Prüfungen (Public Upload vs. Admin Upload vs. Event-Erstellung). +- Fehlende, generische oder kryptische Fehlermeldungen im Admin-Frontend. +- Keine Frühwarnungen bei 80 %/95 % Auslastung, kein Countdown für Galerien. +- Fehlende Automatisierung für E-Mail-/In-App-Warnungen bei kritischen Zuständen. + +## High-Level Goals +1. Konsistente Limit-Prüfungen & Fehlercodes im Backend. +2. Verbesserte UX in Guest & Tenant Admin PWA (Warnungen, klare Fehlermeldungen). +3. Proaktive Benachrichtigungen (E-Mail, In-App) bei bevorstehenden Grenzwerten / Ablauf. +4. Monitoring, Dokumentation, Tests für alle neuen Abläufe. + +## Work Breakdown + +### 1. Backend Unification +- [x] Paket-Limit-Service erstellen (Fotos, Gäste, Aufgaben, Events/Jahr, Galerie-Laufzeit). *(Initial evaluator + Middleware Integration für Events/Fotos)* +- [x] Public Uploads um Paketlimit-Prüfung erweitern (inkl. Events ohne Aktivpaket blockieren). *(Guest Upload prüft & erhöht Zähler)* +- [ ] Konsistentes Fehler-Response-Schema (`code`, `title`, `message`, `meta`) implementieren. *(Begonnen: Gästeadmin/Admin Uploads nutzen ApiError)* +- [ ] Domain-Events für Grenzwerte & Ablaufzustände emitten. +- [ ] Feature-/Unit-Tests für neue Services & Events. + +### 2. Threshold Detection & Storage +- [x] Schwellenwerte konfigurieren (Fotos/Gäste, Gallery D-7/D-1). +- [x] Scheduler/Jobs für regelmäßige Galerie-Checks. +- [x] Persistenz für Galerie-Benachrichtigungen (warning/expired timestamps). + +### 3. Guest PWA Improvements +- [ ] Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände). +- [ ] Upload-Fehlercodes auswerten und freundliche Dialoge zeigen. +- [ ] Galerie-Countdown/Badge für Ablaufdatum + Call-to-Action. +- [ ] E2E-Tests für Limitwarnungen & abgelaufene Galerie aktualisieren. + +### 4. Tenant Admin PWA Improvements +- [ ] Dashboard-Karten & Event-Header mit Ampelsystem für Limitfortschritt. +- [ ] Event-Formular: Warnhinweise bei 80 %/95 % + Upgrade-CTA. +- [ ] Globale Fehlerzustände aus Fehlerkontrakt (Toast/Dialog). +- [ ] Übersetzungen für alle neuen Messages hinzufügen. + +- [x] E-Mail-Schablonen & Notifications für Foto- und Gäste-Schwellen/Limits. +- [x] Galerie-Warnungen (D-7/D-1) & Ablauf-Mails + Cron Task. +- [ ] Weitere Benachrichtigungen (Paket-Ablauf, Reseller-Eventlimit, Credits fast leer). +- [x] Opt-in/Opt-out-Konfiguration pro Tenant implementieren. +- [ ] In-App/Toast-Benachrichtigungen für Admin UI (und optional Push/Slack intern). +- [ ] Audit-Log & Retry-Logik für gesendete Mails. + +### 6. Monitoring, Docs & Support +- [ ] Prometheus/Grafana-Metriken für Paketnutzung & Warns triggern. +- [ ] PRP & API-Doku mit neuem Fehlerschema & Limitverhalten aktualisieren. +- [ ] Support-Playbook & FAQ für Limitwarnungen erweitern. + +## Dependencies & Notes +- Bestehende Credit-Logik parallel weiter unterstützen (Legacy-Kunden). +- Paddle/E-Mail-Dienste müssen auf Sandbox getestet werden. +- Mehrsprachigkeit (de/en) sicherstellen. + +## Definition of Done +- Alle relevanten Grenzwerte serverseitig validiert und getestet. +- Frontends zeigen spezifische Warnungen + Handlungsanweisungen. +- Benachrichtigungssystem verschickt Mails bei konfigurierten Schwellen. +- Monitoring & Dokumentation sind aktualisiert. +- Regressionstests und neue Tests laufen grün. diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index cf3594f..3f08d08 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -1,4 +1,5 @@ import { authorizedFetch } from './auth/tokens'; +import { ApiError } from './lib/apiError'; import i18n from './i18n'; type JsonValue = Record; @@ -331,8 +332,20 @@ type EventSavePayload = { async function jsonOrThrow(response: Response, message: string): Promise { if (!response.ok) { const body = await safeJson(response); - console.error('[API]', message, response.status, body); - throw new Error(message); + const status = response.status; + const errorPayload = body && typeof body === 'object' ? (body as Record).error : null; + const errorMessage = (errorPayload && typeof errorPayload === 'object' && 'message' in errorPayload && typeof errorPayload.message === 'string') + ? errorPayload.message + : message; + const errorCode = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.code === 'string' + ? errorPayload.code + : undefined; + const errorMeta = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.meta === 'object' + ? errorPayload.meta as Record + : undefined; + + console.error('[API]', errorMessage, status, body); + throw new ApiError(errorMessage, status, errorCode, errorMeta); } return (await response.json()) as T; diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index 5e4f61d..ec79bce 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -20,5 +20,13 @@ "actions": { "open": "Öffnen", "viewAll": "Alle anzeigen" + }, + "errors": { + "generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", + "eventLimit": "Dein aktuelles Paket enthält keine freien Event-Slots mehr.", + "eventLimitDetails": "{used} von {limit} Events genutzt. {remaining} verbleiben.", + "creditsExhausted": "Keine Event-Credits mehr verfügbar. Bitte buche Credits oder upgrade dein Paket.", + "photoLimit": "Für dieses Event ist das Foto-Upload-Limit erreicht.", + "goToBilling": "Zur Paketverwaltung" } } diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index 642330b..75cce72 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -20,5 +20,13 @@ "actions": { "open": "Open", "viewAll": "View all" + }, + "errors": { + "generic": "Something went wrong. Please try again.", + "eventLimit": "Your current package has no remaining event slots.", + "eventLimitDetails": "{used} of {limit} events used. {remaining} remaining.", + "creditsExhausted": "You have no event credits remaining. Purchase credits or upgrade your package.", + "photoLimit": "This event reached its photo upload limit.", + "goToBilling": "Manage subscription" } } diff --git a/resources/js/admin/lib/apiError.ts b/resources/js/admin/lib/apiError.ts new file mode 100644 index 0000000..69fb1cb --- /dev/null +++ b/resources/js/admin/lib/apiError.ts @@ -0,0 +1,15 @@ +export class ApiError extends Error { + constructor( + message: string, + public readonly status?: number, + public readonly code?: string, + public readonly meta?: Record, + ) { + super(message); + this.name = 'ApiError'; + } +} + +export function isApiError(value: unknown): value is ApiError { + return value instanceof ApiError; +} diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx index 2e65b9d..2795eeb 100644 --- a/resources/js/admin/pages/EventFormPage.tsx +++ b/resources/js/admin/pages/EventFormPage.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; @@ -23,6 +24,7 @@ import { TenantEvent, } from '../api'; import { isAuthError } from '../auth/tokens'; +import { isApiError } from '../lib/apiError'; import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants'; interface EventFormState { @@ -63,6 +65,8 @@ export default function EventFormPage() { const isEdit = Boolean(slugParam); const navigate = useNavigate(); + const { t: tCommon } = useTranslation('common', { keyPrefix: 'errors' }); + const [form, setForm] = React.useState({ name: '', slug: '', @@ -76,6 +80,7 @@ export default function EventFormPage() { const slugSuffixRef = React.useRef(null); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); + const [showUpgradeHint, setShowUpgradeHint] = React.useState(false); const [readOnlyPackageName, setReadOnlyPackageName] = React.useState(null); const [eventPackageMeta, setEventPackageMeta] = React.useState(null); @@ -232,6 +237,7 @@ export default function EventFormPage() { setSaving(true); setError(null); + setShowUpgradeHint(false); const status: 'draft' | 'published' | 'archived' = form.isPublished ? 'published' : 'draft'; const packageIdForSubmit = form.package_id || activePackage?.package_id || null; @@ -256,14 +262,44 @@ export default function EventFormPage() { const targetSlug = originalSlug ?? slugParam!; const updated = await updateEvent(targetSlug, payload); setOriginalSlug(updated.slug); + setShowUpgradeHint(false); + setError(null); navigate(ADMIN_EVENT_VIEW_PATH(updated.slug)); } else { const { event: created } = await createEvent(payload); + setShowUpgradeHint(false); + setError(null); navigate(ADMIN_EVENT_VIEW_PATH(created.slug)); } } catch (err) { if (!isAuthError(err)) { - setError('Speichern fehlgeschlagen. Bitte prüfe deine Eingaben.'); + if (isApiError(err)) { + switch (err.code) { + case 'event_limit_exceeded': { + const limit = Number(err.meta?.limit ?? 0); + const used = Number(err.meta?.used ?? 0); + const remaining = Number(err.meta?.remaining ?? Math.max(0, limit - used)); + const detail = limit > 0 + ? tCommon('eventLimitDetails', { used, limit, remaining }) + : ''; + setError(`${tCommon('eventLimit')}${detail ? `\n${detail}` : ''}`); + setShowUpgradeHint(true); + break; + } + case 'event_credits_exhausted': { + setError(tCommon('creditsExhausted')); + setShowUpgradeHint(true); + break; + } + default: { + setError(err.message || tCommon('generic')); + setShowUpgradeHint(false); + } + } + } else { + setError(tCommon('generic')); + setShowUpgradeHint(false); + } } } finally { setSaving(false); @@ -360,7 +396,18 @@ export default function EventFormPage() { {error && ( Hinweis - {error} + + {error.split('\n').map((line, index) => ( + {line} + ))} + {showUpgradeHint && ( +
+ +
+ )} +
)} diff --git a/resources/js/admin/pages/EventInvitesPage.tsx b/resources/js/admin/pages/EventInvitesPage.tsx index e7ca499..daba9e8 100644 --- a/resources/js/admin/pages/EventInvitesPage.tsx +++ b/resources/js/admin/pages/EventInvitesPage.tsx @@ -31,7 +31,10 @@ import { import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel'; import { DesignerCanvas } from './components/invite-layout/DesignerCanvas'; import { + CANVAS_HEIGHT, + CANVAS_WIDTH, buildDefaultElements, + clamp, normalizeElements, payloadToElements, LayoutElement, @@ -171,6 +174,8 @@ export default function EventInvitesPage(): JSX.Element { const [exportDownloadBusy, setExportDownloadBusy] = React.useState(null); const [exportPrintBusy, setExportPrintBusy] = React.useState(null); const [exportError, setExportError] = React.useState(null); + const exportPreviewContainerRef = React.useRef(null); + const [exportScale, setExportScale] = React.useState(0.34); const load = React.useCallback(async () => { if (!slug) { @@ -190,10 +195,35 @@ export default function EventInvitesPage(): JSX.Element { } }, [slug]); + const recomputeExportScale = React.useCallback(() => { + const container = exportPreviewContainerRef.current; + if (!container) { + return; + } + + const widthRatio = container.clientWidth / CANVAS_WIDTH; + const heightRatio = container.clientHeight ? container.clientHeight / CANVAS_HEIGHT : Number.POSITIVE_INFINITY; + const base = Math.min(widthRatio, heightRatio); + const safeBase = Number.isFinite(base) && base > 0 ? Math.min(base, 1) : 1; + const clampedScale = clamp(safeBase, 0.1, 1); + + setExportScale((prev) => (Math.abs(prev - clampedScale) < 0.001 ? prev : clampedScale)); + }, []); + React.useEffect(() => { void load(); }, [load]); + React.useEffect(() => { + recomputeExportScale(); + }, [recomputeExportScale]); + + React.useEffect(() => { + const handleResize = () => recomputeExportScale(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [recomputeExportScale]); + React.useEffect(() => { const param = searchParams.get('tab'); const nextTab = param === 'export' || param === 'links' ? (param as TabKey) : 'layout'; @@ -369,6 +399,28 @@ export default function EventInvitesPage(): JSX.Element { ); }, [exportLayout, currentCustomization, selectedInvite?.url, eventName]); + React.useEffect(() => { + if (activeTab !== 'export') { + return; + } + recomputeExportScale(); + }, [activeTab, recomputeExportScale, exportElements.length, exportLayout?.id, selectedInvite?.id]); + + React.useEffect(() => { + if (typeof ResizeObserver !== 'function') { + return undefined; + } + const target = exportPreviewContainerRef.current; + if (!target) { + return undefined; + } + + const observer = new ResizeObserver(() => recomputeExportScale()); + observer.observe(target); + + return () => observer.disconnect(); + }, [recomputeExportScale, activeTab]); + const exportCanvasKey = React.useMemo( () => `export:${selectedInvite?.id ?? 'none'}:${exportLayout?.id ?? 'layout'}:${exportPreview?.mode ?? 'standard'}`, [selectedInvite?.id, exportLayout?.id, exportPreview?.mode] @@ -789,7 +841,10 @@ export default function EventInvitesPage(): JSX.Element {
{exportElements.length ? ( -
+
) : ( diff --git a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx index 3f831d7..4310516 100644 --- a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx +++ b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx @@ -34,19 +34,21 @@ import type { EventQrInvite, EventQrInviteLayout } from '../../api'; import { authorizedFetch } from '../../auth/tokens'; import { + CANVAS_HEIGHT, + CANVAS_WIDTH, QrLayoutCustomization, LayoutElement, LayoutElementPayload, LayoutElementType, LayoutSerializationContext, buildDefaultElements, + clamp, clampElement, elementsToPayload, normalizeElements, payloadToElements, } from './invite-layout/schema'; import { DesignerCanvas } from './invite-layout/DesignerCanvas'; -import { CANVAS_HEIGHT, CANVAS_WIDTH } from './invite-layout/schema'; import { generatePdfBytes, generatePngDataUrl, @@ -181,6 +183,9 @@ type InviteLayoutCustomizerPanelProps = { }; const MAX_INSTRUCTIONS = 5; +const ZOOM_MIN = 0.1; +const ZOOM_MAX = 2; +const ZOOM_STEP = 0.05; export function InviteLayoutCustomizerPanel({ invite, @@ -213,6 +218,10 @@ export function InviteLayoutCustomizerPanel({ const [elements, setElements] = React.useState([]); const [activeElementId, setActiveElementId] = React.useState(null); const [showFloatingActions, setShowFloatingActions] = React.useState(false); + const [zoomScale, setZoomScale] = React.useState(1); + const [fitScale, setFitScale] = React.useState(1); + const fitScaleRef = React.useRef(1); + const manualZoomRef = React.useRef(false); const actionsSentinelRef = React.useRef(null); const historyRef = React.useRef([]); const historyIndexRef = React.useRef(-1); @@ -223,6 +232,83 @@ export function InviteLayoutCustomizerPanel({ const canvasContainerRef = React.useRef(null); const isAdvanced = true; + const clampZoom = React.useCallback( + (value: number) => clamp(Number.isFinite(value) ? value : 1, ZOOM_MIN, ZOOM_MAX), + [], + ); + + const recomputeFitScale = React.useCallback(() => { + const viewport = designerViewportRef.current; + if (!viewport) { + return; + } + + const { clientWidth, clientHeight } = viewport; + if (!clientWidth || !clientHeight) { + return; + } + + const style = window.getComputedStyle(viewport); + const paddingX = parseFloat(style.paddingLeft ?? '0') + parseFloat(style.paddingRight ?? '0'); + const paddingY = parseFloat(style.paddingTop ?? '0') + parseFloat(style.paddingBottom ?? '0'); + + const availableWidth = clientWidth - paddingX; + const availableHeight = clientHeight - paddingY; + + if (availableWidth <= 0 || availableHeight <= 0) { + return; + } + + const widthScale = availableWidth / CANVAS_WIDTH; + const heightScale = availableHeight / CANVAS_HEIGHT; + const nextRaw = Math.min(widthScale, heightScale); + const baseScale = Number.isFinite(nextRaw) && nextRaw > 0 ? Math.min(nextRaw, 1) : 1; + const clamped = clampZoom(baseScale); + + fitScaleRef.current = clamped; + setFitScale((prev) => (Math.abs(prev - clamped) < 0.001 ? prev : clamped)); + if (!manualZoomRef.current) { + setZoomScale((prev) => (Math.abs(prev - clamped) < 0.001 ? prev : clamped)); + } + + console.debug('[Invites][Zoom] viewport size', { + availableWidth, + availableHeight, + widthScale, + heightScale, + clamped, + }); + }, [clampZoom]); + + React.useLayoutEffect(() => { + recomputeFitScale(); + }, [recomputeFitScale]); + + React.useEffect(() => { + const viewport = designerViewportRef.current; + + const handleResize = () => { + recomputeFitScale(); + }; + + window.addEventListener('resize', handleResize); + + let observer: ResizeObserver | null = null; + if (viewport && typeof ResizeObserver === 'function') { + observer = new ResizeObserver(() => recomputeFitScale()); + observer.observe(viewport); + } + + recomputeFitScale(); + + return () => { + window.removeEventListener('resize', handleResize); + if (observer) { + observer.disconnect(); + } + }; + }, [recomputeFitScale]); + const cloneElements = React.useCallback( (items: LayoutElement[]): LayoutElement[] => items.map((item) => ({ ...item })), [] @@ -355,6 +441,11 @@ export function InviteLayoutCustomizerPanel({ return availableLayouts[0]; }, [availableLayouts, selectedLayoutId]); + React.useEffect(() => { + manualZoomRef.current = false; + recomputeFitScale(); + }, [recomputeFitScale, activeLayout?.id, invite?.id]); + const activeLayoutQrSize = React.useMemo(() => { const qrElement = elements.find((element) => element.type === 'qr'); if (qrElement && typeof qrElement.width === 'number' && qrElement.width > 0) { @@ -371,6 +462,12 @@ export function InviteLayoutCustomizerPanel({ return activeLayout?.preview?.qr_size_px ?? 500; }, [elements, initialCustomization?.mode, initialCustomization?.elements, activeLayout?.preview?.qr_size_px]); + const effectiveScale = React.useMemo( + () => clampZoom(Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : fitScale), + [clampZoom, zoomScale, fitScale], + ); + const zoomPercent = Math.round(effectiveScale * 100); + const updateElement = React.useCallback( (id: string, updater: Partial | ((element: LayoutElement) => Partial), options?: { silent?: boolean }) => { commitElements( @@ -1702,27 +1799,62 @@ export function InviteLayoutCustomizerPanel({
-
- - +
+
+ + {t('invites.customizer.controls.zoom', 'Zoom')} + + { + manualZoomRef.current = true; + setZoomScale(clampZoom(Number(event.target.value))); + }} + className="h-1 w-36 overflow-hidden rounded-full" + disabled={false} + aria-label={t('invites.customizer.controls.zoom', 'Zoom')} + /> + {zoomPercent}% + +
+
+ + +
@@ -1744,6 +1876,7 @@ export function InviteLayoutCustomizerPanel({ badge={form.badge_color ?? form.accent_color ?? '#2563EB'} qrCodeDataUrl={qrCodeDataUrl} logoDataUrl={form.logo_data_url ?? form.logo_url ?? null} + scale={effectiveScale} layoutKey={`designer:${invite?.id ?? 'unknown'}:${activeLayout?.id ?? 'default'}`} />
diff --git a/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx b/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx index a50e5bf..9e25e28 100644 --- a/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx +++ b/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx @@ -22,6 +22,7 @@ type DesignerCanvasProps = { badge: string; qrCodeDataUrl: string | null; logoDataUrl: string | null; + scale?: number; layoutKey?: string; readOnly?: boolean; }; @@ -41,6 +42,7 @@ export function DesignerCanvas({ badge, qrCodeDataUrl, logoDataUrl, + scale = 1, layoutKey, readOnly = false, }: DesignerCanvasProps): React.JSX.Element { @@ -343,16 +345,43 @@ export function DesignerCanvas({ if (!canvas) { return; } - canvas.setZoom(1); - canvas.setDimensions( - { - width: CANVAS_WIDTH, - height: CANVAS_HEIGHT, - }, - { cssOnly: true }, - ); + + const normalizedScale = Number.isFinite(scale) && scale > 0 ? scale : 1; + + canvas.setZoom(normalizedScale); + + const cssWidth = CANVAS_WIDTH * normalizedScale; + const cssHeight = CANVAS_HEIGHT * normalizedScale; + + const element = canvas.getElement(); + if (element) { + element.style.width = `${cssWidth}px`; + element.style.height = `${cssHeight}px`; + } + + if (canvas.upperCanvasEl) { + canvas.upperCanvasEl.style.width = `${cssWidth}px`; + canvas.upperCanvasEl.style.height = `${cssHeight}px`; + } + + if (canvas.lowerCanvasEl) { + canvas.lowerCanvasEl.style.width = `${cssWidth}px`; + canvas.lowerCanvasEl.style.height = `${cssHeight}px`; + } + + if (canvas.wrapperEl) { + canvas.wrapperEl.style.width = `${cssWidth}px`; + canvas.wrapperEl.style.height = `${cssHeight}px`; + } + + if (containerRef.current) { + containerRef.current.style.width = `${cssWidth}px`; + containerRef.current.style.height = `${cssHeight}px`; + } + + canvas.calcOffset(); canvas.requestRenderAll(); - }, []); + }, [scale]); return (
diff --git a/resources/js/admin/pages/components/invite-layout/schema.ts b/resources/js/admin/pages/components/invite-layout/schema.ts index b8602ef..974d8a0 100644 --- a/resources/js/admin/pages/components/invite-layout/schema.ts +++ b/resources/js/admin/pages/components/invite-layout/schema.ts @@ -133,217 +133,361 @@ export function clampElement(element: LayoutElement): LayoutElement { } const DEFAULT_TYPE_STYLES: Record = { - headline: { width: 620, height: 200, fontSize: 68, align: 'left' }, - subtitle: { width: 580, height: 140, fontSize: 34, align: 'left' }, - description: { width: 620, height: 280, fontSize: 28, align: 'left' }, - link: { width: 400, height: 110, fontSize: 28, align: 'center' }, - badge: { width: 280, height: 80, fontSize: 24, align: 'center' }, - logo: { width: 240, height: 180, align: 'center' }, - cta: { width: 400, height: 110, fontSize: 26, align: 'center' }, - qr: { width: 520, height: 520 }, - text: { width: 560, height: 200, fontSize: 26, align: 'left' }, + headline: { width: 900, height: 240, fontSize: 82, align: 'left' }, + subtitle: { width: 760, height: 170, fontSize: 40, align: 'left' }, + description: { width: 920, height: 340, fontSize: 32, align: 'left' }, + link: { width: 520, height: 130, fontSize: 30, align: 'center' }, + badge: { width: 420, height: 100, fontSize: 26, align: 'center' }, + logo: { width: 320, height: 220, align: 'center' }, + cta: { width: 520, height: 130, fontSize: 28, align: 'center' }, + qr: { width: 640, height: 640 }, + text: { width: 720, height: 260, fontSize: 28, align: 'left' }, }; const DEFAULT_PRESET: LayoutPreset = [ - { id: 'badge', type: 'badge', x: 120, y: 140, width: 320, height: 80, align: 'center', fontSize: 24 }, - { id: 'headline', type: 'headline', x: 120, y: 260, width: 620, height: 200, fontSize: 68, align: 'left' }, - { id: 'subtitle', type: 'subtitle', x: 120, y: 440, width: 600, height: 140, fontSize: 34, align: 'left' }, - { id: 'description', type: 'description', x: 120, y: 600, width: 620, height: 280, fontSize: 28, align: 'left' }, + { id: 'badge', type: 'badge', x: 140, y: 160, width: 440, height: 100, align: 'center', fontSize: 28 }, + { + id: 'headline', + type: 'headline', + x: 140, + y: 300, + width: (context) => context.canvasWidth - 280, + height: 240, + fontSize: 84, + align: 'left', + }, + { + id: 'subtitle', + type: 'subtitle', + x: 140, + y: 560, + width: (context) => context.canvasWidth - 280, + height: 170, + fontSize: 42, + align: 'left', + }, + { + id: 'description', + type: 'description', + x: 140, + y: 750, + width: (context) => context.canvasWidth - 280, + height: 340, + fontSize: 32, + align: 'left', + }, { id: 'qr', type: 'qr', - x: (context) => context.canvasWidth - context.qrSize - 140, + x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 180, y: 360, - width: (context) => context.qrSize, - height: (context) => context.qrSize, + width: (context) => Math.min(context.qrSize, 680), + height: (context) => Math.min(context.qrSize, 680), }, { id: 'link', type: 'link', - x: (context) => context.canvasWidth - 420, - y: (context) => 400 + context.qrSize, - width: 400, - height: 110, + x: (context) => context.canvasWidth - 540, + y: (context) => 420 + Math.min(context.qrSize, 680), + width: 520, + height: 130, fontSize: 28, align: 'center', }, { id: 'cta', type: 'cta', - x: (context) => context.canvasWidth - 420, - y: (context) => 420 + context.qrSize + 140, - width: 400, - height: 110, - fontSize: 26, + x: (context) => context.canvasWidth - 540, + y: (context) => 460 + Math.min(context.qrSize, 680) + 160, + width: 520, + height: 130, + fontSize: 30, align: 'center', }, ]; const evergreenVowsPreset: LayoutPreset = [ - { id: 'logo', type: 'logo', x: 120, y: 140, width: 240, height: 180 }, - { id: 'badge', type: 'badge', x: 400, y: 160, width: 320, height: 80, align: 'center', fontSize: 24 }, - { id: 'headline', type: 'headline', x: 120, y: 360, width: 620, height: 220, fontSize: 70, align: 'left' }, - { id: 'subtitle', type: 'subtitle', x: 120, y: 560, width: 600, height: 140, fontSize: 34, align: 'left' }, - { id: 'description', type: 'description', x: 120, y: 720, width: 620, height: 280, fontSize: 28, align: 'left' }, + { id: 'logo', type: 'logo', x: 160, y: 140, width: 340, height: 240 }, + { id: 'badge', type: 'badge', x: 540, y: 160, width: 420, height: 100, align: 'center', fontSize: 28 }, + { + id: 'headline', + type: 'headline', + x: 160, + y: 360, + width: (context) => context.canvasWidth - 320, + height: 250, + fontSize: 86, + align: 'left', + }, + { + id: 'subtitle', + type: 'subtitle', + x: 160, + y: 630, + width: (context) => context.canvasWidth - 320, + height: 180, + fontSize: 42, + align: 'left', + }, + { + id: 'description', + type: 'description', + x: 160, + y: 840, + width: (context) => context.canvasWidth - 320, + height: 360, + fontSize: 34, + align: 'left', + }, { id: 'qr', type: 'qr', - x: (context) => context.canvasWidth - context.qrSize - 160, - y: 460, - width: (context) => context.qrSize, - height: (context) => context.qrSize, + x: (context) => context.canvasWidth - Math.min(context.qrSize, 640) - 200, + y: 420, + width: (context) => Math.min(context.qrSize, 640), + height: (context) => Math.min(context.qrSize, 640), }, { id: 'link', type: 'link', - x: (context) => context.canvasWidth - 420, - y: (context) => 500 + context.qrSize, - width: 400, - height: 110, + x: (context) => context.canvasWidth - 560, + y: (context) => 480 + Math.min(context.qrSize, 640), + width: 520, + height: 130, align: 'center', }, { id: 'cta', type: 'cta', - x: (context) => context.canvasWidth - 420, - y: (context) => 520 + context.qrSize + 150, - width: 400, - height: 110, + x: (context) => context.canvasWidth - 560, + y: (context) => 520 + Math.min(context.qrSize, 640) + 180, + width: 520, + height: 130, align: 'center', }, ]; const midnightGalaPreset: LayoutPreset = [ - { id: 'badge', type: 'badge', x: 360, y: 160, width: 520, height: 90, align: 'center', fontSize: 26 }, - { id: 'headline', type: 'headline', x: 220, y: 300, width: 800, height: 220, fontSize: 76, align: 'center' }, - { id: 'subtitle', type: 'subtitle', x: 260, y: 520, width: 720, height: 140, fontSize: 36, align: 'center' }, + { id: 'badge', type: 'badge', x: (context) => context.canvasWidth / 2 - 300, y: 180, width: 600, height: 120, align: 'center', fontSize: 32 }, + { + id: 'headline', + type: 'headline', + x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 220) / 2, + y: 340, + width: (context) => context.canvasWidth - 220, + height: 260, + fontSize: 90, + align: 'center', + }, + { + id: 'subtitle', + type: 'subtitle', + x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2, + y: 640, + width: (context) => context.canvasWidth - 320, + height: 200, + fontSize: 46, + align: 'center', + }, { id: 'qr', type: 'qr', - x: (context) => (context.canvasWidth - context.qrSize) / 2, - y: 700, - width: (context) => context.qrSize, - height: (context) => context.qrSize, + x: (context) => (context.canvasWidth - Math.min(context.qrSize, 640)) / 2, + y: 880, + width: (context) => Math.min(context.qrSize, 640), + height: (context) => Math.min(context.qrSize, 640), }, { id: 'link', type: 'link', - x: (context) => (context.canvasWidth - 420) / 2, - y: (context) => 740 + context.qrSize, - width: 420, - height: 120, + x: (context) => (context.canvasWidth - 560) / 2, + y: (context) => 940 + Math.min(context.qrSize, 640), + width: 560, + height: 140, align: 'center', }, { id: 'cta', type: 'cta', - x: (context) => (context.canvasWidth - 420) / 2, - y: (context) => 770 + context.qrSize + 150, - width: 420, - height: 120, + x: (context) => (context.canvasWidth - 560) / 2, + y: (context) => 980 + Math.min(context.qrSize, 640) + 200, + width: 560, + height: 140, + align: 'center', + }, + { + id: 'description', + type: 'description', + x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 240) / 2, + y: 1250, + width: (context) => context.canvasWidth - 240, + height: 360, + fontSize: 34, align: 'center', }, - { id: 'description', type: 'description', x: 200, y: 1040, width: 840, height: 260, fontSize: 28, align: 'center' }, ]; const gardenBrunchPreset: LayoutPreset = [ - { id: 'badge', type: 'badge', x: 160, y: 160, width: 360, height: 80, align: 'center', fontSize: 24 }, - { id: 'headline', type: 'headline', x: 160, y: 300, width: 560, height: 200, fontSize: 66, align: 'left' }, - { id: 'description', type: 'description', x: 160, y: 520, width: 560, height: 260, fontSize: 28, align: 'left' }, + { id: 'badge', type: 'badge', x: 180, y: 180, width: 500, height: 110, align: 'center', fontSize: 30 }, + { id: 'headline', type: 'headline', x: 180, y: 340, width: (context) => context.canvasWidth - 360, height: 260, fontSize: 86, align: 'left' }, + { id: 'description', type: 'description', x: 180, y: 630, width: (context) => context.canvasWidth - 360, height: 360, fontSize: 34, align: 'left' }, { id: 'qr', type: 'qr', - x: 160, - y: 840, - width: (context) => Math.min(context.qrSize, 520), - height: (context) => Math.min(context.qrSize, 520), + x: 180, + y: 1000, + width: (context) => Math.min(context.qrSize, 660), + height: (context) => Math.min(context.qrSize, 660), }, { id: 'link', type: 'link', - x: 160, - y: (context) => 880 + Math.min(context.qrSize, 520), - width: 420, - height: 110, + x: 180, + y: (context) => 1060 + Math.min(context.qrSize, 660), + width: 520, + height: 140, align: 'center', }, { id: 'cta', type: 'cta', - x: 160, - y: (context) => 910 + Math.min(context.qrSize, 520) + 140, - width: 420, - height: 110, + x: 180, + y: (context) => 1100 + Math.min(context.qrSize, 660) + 190, + width: 520, + height: 140, align: 'center', }, - { id: 'subtitle', type: 'subtitle', x: 780, y: 320, width: 320, height: 140, fontSize: 32, align: 'left' }, - { id: 'text-strip', type: 'text', x: 780, y: 480, width: 320, height: 320, fontSize: 24, align: 'left' }, + { id: 'subtitle', type: 'subtitle', x: (context) => context.canvasWidth - 460, y: 360, width: 420, height: 200, fontSize: 38, align: 'left' }, + { id: 'text-strip', type: 'text', x: (context) => context.canvasWidth - 460, y: 620, width: 420, height: 360, fontSize: 28, align: 'left' }, ]; const sparklerSoireePreset: LayoutPreset = [ - { id: 'badge', type: 'badge', x: 360, y: 150, width: 520, height: 90, align: 'center', fontSize: 26 }, - { id: 'headline', type: 'headline', x: 200, y: 300, width: 840, height: 220, fontSize: 72, align: 'center' }, - { id: 'subtitle', type: 'subtitle', x: 260, y: 520, width: 720, height: 140, fontSize: 34, align: 'center' }, - { id: 'description', type: 'description', x: 220, y: 680, width: 800, height: 240, fontSize: 28, align: 'center' }, + { id: 'badge', type: 'badge', x: (context) => context.canvasWidth / 2 - 320, y: 200, width: 640, height: 120, align: 'center', fontSize: 32 }, + { + id: 'headline', + type: 'headline', + x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 220) / 2, + y: 360, + width: (context) => context.canvasWidth - 220, + height: 280, + fontSize: 94, + align: 'center', + }, + { + id: 'subtitle', + type: 'subtitle', + x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2, + y: 660, + width: (context) => context.canvasWidth - 320, + height: 210, + fontSize: 46, + align: 'center', + }, + { + id: 'description', + type: 'description', + x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2, + y: 920, + width: (context) => context.canvasWidth - 320, + height: 380, + fontSize: 34, + align: 'center', + }, { id: 'qr', type: 'qr', - x: (context) => (context.canvasWidth - context.qrSize) / 2, - y: 960, - width: (context) => context.qrSize, - height: (context) => context.qrSize, + x: (context) => (context.canvasWidth - Math.min(context.qrSize, 680)) / 2, + y: 1200, + width: (context) => Math.min(context.qrSize, 680), + height: (context) => Math.min(context.qrSize, 680), }, { id: 'link', type: 'link', - x: (context) => (context.canvasWidth - 420) / 2, - y: (context) => 1000 + context.qrSize, - width: 420, - height: 110, + x: (context) => (context.canvasWidth - 580) / 2, + y: (context) => 1260 + Math.min(context.qrSize, 680), + width: 580, + height: 150, align: 'center', }, { id: 'cta', type: 'cta', - x: (context) => (context.canvasWidth - 420) / 2, - y: (context) => 1030 + context.qrSize + 140, - width: 420, - height: 110, + x: (context) => (context.canvasWidth - 580) / 2, + y: (context) => 1300 + Math.min(context.qrSize, 680) + 200, + width: 580, + height: 150, align: 'center', }, ]; const confettiBashPreset: LayoutPreset = [ - { id: 'badge', type: 'badge', x: 140, y: 180, width: 360, height: 90, align: 'center', fontSize: 24 }, - { id: 'headline', type: 'headline', x: 140, y: 320, width: 520, height: 220, fontSize: 68, align: 'left' }, - { id: 'subtitle', type: 'subtitle', x: 140, y: 520, width: 520, height: 140, fontSize: 34, align: 'left' }, - { id: 'description', type: 'description', x: 140, y: 680, width: 520, height: 240, fontSize: 26, align: 'left' }, + { id: 'badge', type: 'badge', x: 180, y: 220, width: 520, height: 120, align: 'center', fontSize: 32 }, + { + id: 'headline', + type: 'headline', + x: 180, + y: 380, + width: (context) => context.canvasWidth - 360, + height: 260, + fontSize: 90, + align: 'left', + }, + { + id: 'subtitle', + type: 'subtitle', + x: 180, + y: 660, + width: (context) => context.canvasWidth - 360, + height: 200, + fontSize: 46, + align: 'left', + }, + { + id: 'description', + type: 'description', + x: 180, + y: 910, + width: (context) => context.canvasWidth - 360, + height: 360, + fontSize: 34, + align: 'left', + }, { id: 'qr', type: 'qr', - x: (context) => context.canvasWidth - context.qrSize - 200, - y: 360, - width: (context) => context.qrSize, - height: (context) => context.qrSize, + x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 200, + y: 460, + width: (context) => Math.min(context.qrSize, 680), + height: (context) => Math.min(context.qrSize, 680), }, { id: 'link', type: 'link', - x: (context) => context.canvasWidth - 420, - y: (context) => 400 + context.qrSize, - width: 400, - height: 110, + x: (context) => context.canvasWidth - 560, + y: (context) => 520 + Math.min(context.qrSize, 680), + width: 520, + height: 140, align: 'center', }, { id: 'cta', type: 'cta', - x: (context) => context.canvasWidth - 420, - y: (context) => 430 + context.qrSize + 140, - width: 400, - height: 110, + x: (context) => context.canvasWidth - 560, + y: (context) => 560 + Math.min(context.qrSize, 680) + 200, + width: 520, + height: 140, align: 'center', }, - { id: 'text-strip', type: 'text', x: 140, y: 960, width: 860, height: 220, fontSize: 26, align: 'left' }, + { + id: 'text-strip', + type: 'text', + x: 180, + y: 1220, + width: (context) => context.canvasWidth - 360, + height: 360, + fontSize: 30, + align: 'left', + }, ]; const LAYOUT_PRESETS: Record = { @@ -513,6 +657,7 @@ export function elementsToPayload(elements: LayoutElement[]): LayoutElementPaylo })); } + export function normalizeElements(elements: LayoutElement[]): LayoutElement[] { const seen = new Set(); return elements diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index cd06d5e..a4cc9eb 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -297,6 +297,14 @@ export const messages: Record = { }, limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter für ein Upgrade.', limitUnlimited: 'unbegrenzt', + limitWarning: 'Nur noch {remaining} von {max} Fotos möglich. Bitte kontaktiere die Veranstalter für ein Upgrade.', + errors: { + photoLimit: 'Upload-Limit erreicht. Bitte kontaktiere die Veranstalter für ein Upgrade.', + deviceLimit: 'Dieses Gerät hat das Upload-Limit erreicht. Bitte wende dich an die Veranstalter.', + packageMissing: 'Dieses Event akzeptiert derzeit keine Uploads.', + galleryExpired: 'Die Galerie ist abgelaufen. Uploads sind nicht mehr möglich.', + generic: 'Upload fehlgeschlagen. Bitte versuche es erneut.', + }, cameraInactive: 'Kamera ist nicht aktiv. {hint}', cameraInactiveHint: 'Tippe auf "{label}", um loszulegen.', captureError: 'Foto konnte nicht erstellt werden.', @@ -652,6 +660,14 @@ export const messages: Record = { }, limitReached: 'Upload limit reached ({used} / {max} photos). Contact the organizers for an upgrade.', limitUnlimited: 'unlimited', + limitWarning: 'Only {remaining} of {max} photos left. Please contact the organizers for an upgrade.', + errors: { + photoLimit: 'Upload limit reached. Contact the organizers for an upgrade.', + deviceLimit: 'This device reached its upload limit. Please contact the organizers.', + packageMissing: 'This event is not accepting uploads right now.', + galleryExpired: 'The gallery has expired. Uploads are no longer possible.', + generic: 'Upload failed. Please try again.', + }, cameraInactive: 'Camera is not active. {hint}', cameraInactiveHint: 'Tap "{label}" to get started.', captureError: 'Photo could not be created.', diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index 0619d7c..236746f 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -5,7 +5,7 @@ import BottomNav from '../components/BottomNav'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; -import { uploadPhoto } from '../services/photosApi'; +import { uploadPhoto, type UploadError } from '../services/photosApi'; import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; import { cn } from '@/lib/utils'; import { @@ -117,6 +117,7 @@ export default function UploadPage() { const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null); const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); + const [uploadWarning, setUploadWarning] = useState(null); const [eventPackage, setEventPackage] = useState(null); const [canUpload, setCanUpload] = useState(true); @@ -262,10 +263,29 @@ export default function UploadPage() { setCanUpload(true); setUploadError(null); } + + if (pkg?.package?.max_photos) { + const max = Number(pkg.package.max_photos); + const used = Number(pkg.used_photos ?? 0); + const ratio = max > 0 ? used / max : 0; + if (ratio >= 0.8 && ratio < 1) { + const remaining = Math.max(0, max - used); + setUploadWarning( + t('upload.limitWarning') + .replace('{remaining}', `${remaining}`) + .replace('{max}', `${max}`) + ); + } else { + setUploadWarning(null); + } + } else { + setUploadWarning(null); + } } catch (err) { console.error('Failed to check package limits', err); setCanUpload(false); setUploadError(t('upload.limitCheckError')); + setUploadWarning(null); } }; @@ -520,7 +540,42 @@ export default function UploadPage() { navigateAfterUpload(photoId); } catch (error: unknown) { console.error('Upload failed', error); - setUploadError(getErrorMessage(error) || t('upload.status.failed')); + const uploadErr = error as UploadError; + setUploadWarning(null); + const meta = uploadErr.meta as Record | undefined; + switch (uploadErr.code) { + case 'photo_limit_exceeded': { + if (meta && typeof meta.used === 'number' && typeof meta.limit === 'number') { + const limitText = t('upload.limitReached') + .replace('{used}', `${meta.used}`) + .replace('{max}', `${meta.limit}`); + setUploadError(limitText); + } else { + setUploadError(t('upload.errors.photoLimit')); + } + setCanUpload(false); + break; + } + case 'upload_device_limit': { + setUploadError(t('upload.errors.deviceLimit')); + setCanUpload(false); + break; + } + case 'event_package_missing': + case 'event_not_found': { + setUploadError(t('upload.errors.packageMissing')); + setCanUpload(false); + break; + } + case 'gallery_expired': { + setUploadError(t('upload.errors.galleryExpired')); + setCanUpload(false); + break; + } + default: { + setUploadError(getErrorMessage(uploadErr) || t('upload.errors.generic')); + } + } setMode('review'); } finally { if (uploadProgressTimerRef.current) { @@ -773,6 +828,13 @@ export default function UploadPage() {
+ {uploadWarning && ( + + + {uploadWarning} + + + )} {uploadError && ( diff --git a/resources/js/guest/services/photosApi.ts b/resources/js/guest/services/photosApi.ts index 8481c7a..b5dcf95 100644 --- a/resources/js/guest/services/photosApi.ts +++ b/resources/js/guest/services/photosApi.ts @@ -1,5 +1,11 @@ import { getDeviceId } from '../lib/device'; +export type UploadError = Error & { + code?: string; + status?: number; + meta?: Record; +}; + function getCsrfToken(): string | null { // Method 1: Meta tag (preferred for SPA) const metaToken = document.querySelector('meta[name="csrf-token"]'); @@ -56,16 +62,30 @@ export async function likePhoto(id: number): Promise { }); if (!res.ok) { - const errorText = await res.text(); + let payload: any = null; + try { + payload = await res.clone().json(); + } catch {} + if (res.status === 419) { - throw new Error('CSRF Token mismatch. This usually means:\n\n' + - '1. The page needs to be refreshed\n' + - '2. Check if is present in HTML source\n' + - '3. API routes might need CSRF exemption in VerifyCsrfToken middleware'); + const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.'); + error.code = 'csrf_mismatch'; + error.status = res.status; + throw error; } - throw new Error(`Like failed: ${res.status} - ${errorText}`); + + const error: UploadError = new Error( + payload?.error?.message ?? `Like failed: ${res.status}` + ); + error.code = payload?.error?.code ?? 'like_failed'; + error.status = res.status; + if (payload?.error?.meta) { + error.meta = payload.error.meta as Record; + } + + throw error; } - + const json = await res.json(); return json.likes_count ?? json.data?.likes_count ?? 0; } @@ -85,15 +105,30 @@ export async function uploadPhoto(eventToken: string, file: File, taskId?: numbe }); if (!res.ok) { - const errorText = await res.text(); + let payload: any = null; + try { + payload = await res.clone().json(); + } catch {} + if (res.status === 419) { - throw new Error('CSRF Token mismatch during upload.\n\n' + - 'This usually means:\n' + - '1. API routes need CSRF exemption in VerifyCsrfToken middleware\n' + - '2. Check if is present in page source\n' + - '3. The page might need to be refreshed'); + const csrfError: UploadError = new Error( + 'CSRF token mismatch during upload. Please refresh the page and try again.' + ); + csrfError.code = 'csrf_mismatch'; + csrfError.status = res.status; + throw csrfError; } - throw new Error(`Upload failed: ${res.status} - ${errorText}`); + + const error: UploadError = new Error( + payload?.error?.message ?? `Upload failed: ${res.status}` + ); + error.code = payload?.error?.code ?? 'upload_failed'; + error.status = res.status; + if (payload?.error?.meta) { + error.meta = payload.error.meta as Record; + } + + throw error; } const json = await res.json(); diff --git a/resources/lang/de/emails.php b/resources/lang/de/emails.php index d620457..c6e82e0 100644 --- a/resources/lang/de/emails.php +++ b/resources/lang/de/emails.php @@ -56,4 +56,77 @@ return [ 'body' => 'Vielen Dank für Ihre Nachricht an das Fotospiel-Team. Wir melden uns so schnell wie möglich zurück.', 'footer' => 'Viele Grüße
Ihr Fotospiel-Team', ], + + 'package_limits' => [ + 'team_fallback' => 'Ihr Fotospiel-Team', + 'package_fallback' => 'Aktuelles Paket', + 'event_fallback' => 'Ihr Event', + 'photo_threshold' => [ + 'subject' => 'Event „:event“ hat :percentage% des Foto-Kontingents erreicht', + 'greeting' => 'Hallo :name,', + 'body' => 'Ihr Paket „:package“ für das Event „:event“ liegt bei :percentage% des Foto-Kontingents (:used von :limit Fotos). Es bleiben nur noch :remaining Uploads, bevor das Limit erreicht ist.', + 'action' => 'Event-Dashboard öffnen', + ], + 'photo_limit' => [ + 'subject' => 'Foto-Uploads für „:event“ sind aktuell blockiert', + 'greeting' => 'Hallo :name,', + 'body' => 'Das Paket „:package“ für das Event „:event“ hat das Maximum von :limit Fotos erreicht. Gäste können keine neuen Fotos hochladen, bis Sie das Paket upgraden.', + 'action' => 'Paket verwalten oder upgraden', + ], + 'guest_threshold' => [ + 'subject' => 'Event „:event“ hat :percentage% des Gäste-Kontingents erreicht', + 'greeting' => 'Hallo :name,', + 'body' => 'Ihr Paket „:package“ für das Event „:event“ liegt bei :percentage% des Gäste-Kontingents (:used von :limit Gäste). Es bleiben nur noch :remaining Plätze, bevor das Limit erreicht ist.', + 'action' => 'Event-Dashboard öffnen', + ], + 'guest_limit' => [ + 'subject' => 'Gästekontingent für „:event“ ist ausgeschöpft', + 'greeting' => 'Hallo :name,', + 'body' => 'Das Paket „:package“ für das Event „:event“ hat das Maximum von :limit Gästen erreicht. Neue Gästelinks können erst nach einem Upgrade erstellt werden.', + 'action' => 'Paket verwalten oder upgraden', + ], + 'event_threshold' => [ + 'subject' => 'Paket „:package“ liegt bei :percentage% des Event-Kontingents', + 'greeting' => 'Hallo :name,', + 'body' => 'Ihr Paket „:package“ hat :percentage% des Event-Kontingents erreicht (:used von :limit Events). Es bleiben nur noch :remaining Events.', + 'action' => 'Pakete überprüfen', + ], + 'event_limit' => [ + 'subject' => 'Paket „:package“ hat das Event-Kontingent ausgeschöpft', + 'greeting' => 'Hallo :name,', + 'body' => 'Ihr Paket „:package“ hat das Maximum von :limit Events erreicht. Bitte upgraden oder verlängern Sie, um weitere Events zu erstellen.', + 'action' => 'Paket upgraden', + ], + 'gallery_warning' => [ + 'subject' => 'Galerie für „:event“ läuft in :days Tag ab|Galerie für „:event“ läuft in :days Tagen ab', + 'greeting' => 'Hallo :name,', + 'body' => 'Die Galerie für das Event „:event“ (Paket „:package“) läuft am :date ab. Es bleibt nur noch :days Tag Zugriff.|Die Galerie für das Event „:event“ (Paket „:package“) läuft am :date ab. Es bleiben nur noch :days Tage Zugriff.', + 'action' => 'Galerie-Einstellungen öffnen', + ], + 'gallery_expired' => [ + 'subject' => 'Galerie für „:event“ ist abgelaufen', + 'greeting' => 'Hallo :name,', + 'body' => 'Die Galerie für das Event „:event“ (Paket „:package“) ist am :date abgelaufen. Gäste können keine Fotos mehr ansehen oder laden, bis Sie die Galerie verlängern.', + 'action' => 'Galerie verwalten', + ], + 'package_expiring' => [ + 'subject' => 'Paket „:package“ läuft in :days Tag ab|Paket „:package“ läuft in :days Tagen ab', + 'greeting' => 'Hallo :name,', + 'body' => 'Ihr Paket „:package“ endet am :date. Es bleibt nur noch :days Tag Laufzeit.|Ihr Paket „:package“ endet am :date. Es bleiben nur noch :days Tage Laufzeit.', + 'action' => 'Paket verlängern oder upgraden', + ], + 'package_expired' => [ + 'subject' => 'Paket „:package“ ist abgelaufen', + 'greeting' => 'Hallo :name,', + 'body' => 'Ihr Paket „:package“ ist am :date abgelaufen. Bitte verlängern oder upgraden Sie, um weitere Events zu erstellen.', + 'action' => 'Pakete verwalten', + ], + 'credits_low' => [ + 'subject' => 'Event-Credits werden knapp', + 'greeting' => 'Hallo :name,', + 'body' => 'Ihr Tenant hat nur noch :balance Credits (Schwelle: :threshold). Bitte kaufe weitere Credits oder upgrade dein Paket.', + 'action' => 'Credits kaufen oder Paket upgraden', + ], + 'footer' => 'Viele Grüße
Ihr Fotospiel-Team', + ], ]; diff --git a/resources/lang/en/emails.php b/resources/lang/en/emails.php index f60b298..fe9c356 100644 --- a/resources/lang/en/emails.php +++ b/resources/lang/en/emails.php @@ -56,4 +56,77 @@ return [ 'body' => 'Thank you for your message to the Fotospiel team. We will get back to you as soon as possible.', 'footer' => 'Best regards,
The Fotospiel Team', ], + + 'package_limits' => [ + 'team_fallback' => 'Fotospiel Team', + 'package_fallback' => 'Current Package', + 'event_fallback' => 'Your event', + 'photo_threshold' => [ + 'subject' => 'Event ":event" has used :percentage% of its photo allowance', + 'greeting' => 'Hello :name,', + 'body' => 'Your package ":package" for event ":event" has reached :percentage% of its photo allowance (:used / :limit photos). Only :remaining uploads remain before the limit is reached.', + 'action' => 'Open event dashboard', + ], + 'photo_limit' => [ + 'subject' => 'Photo uploads for ":event" are currently blocked', + 'greeting' => 'Hello :name,', + 'body' => 'The package ":package" for event ":event" has reached its maximum of :limit photos. Guests can no longer upload new photos until you upgrade the package.', + 'action' => 'Upgrade or manage package', + ], + 'guest_threshold' => [ + 'subject' => 'Event ":event" has used :percentage% of its guest allowance', + 'greeting' => 'Hello :name,', + 'body' => 'Your package ":package" for event ":event" has reached :percentage% of its guest allowance (:used / :limit guests). Only :remaining guest slots remain.', + 'action' => 'Open event dashboard', + ], + 'guest_limit' => [ + 'subject' => 'Guest slots for ":event" are currently exhausted', + 'greeting' => 'Hello :name,', + 'body' => 'The package ":package" for event ":event" has reached its maximum of :limit guests. New guest invites cannot be created until you upgrade the package.', + 'action' => 'Upgrade or manage package', + ], + 'event_threshold' => [ + 'subject' => 'Package ":package" has used :percentage% of its event allowance', + 'greeting' => 'Hello :name,', + 'body' => 'Your package ":package" has reached :percentage% of its event allowance (:used / :limit events). Only :remaining event slots remain.', + 'action' => 'Review packages', + ], + 'event_limit' => [ + 'subject' => 'Package ":package" event quota exhausted', + 'greeting' => 'Hello :name,', + 'body' => 'Your package ":package" has reached its maximum of :limit events. Please upgrade or renew to create additional events.', + 'action' => 'Upgrade package', + ], + 'gallery_warning' => [ + 'subject' => 'Gallery for ":event" expires in :days day|Gallery for ":event" expires in :days days', + 'greeting' => 'Hello :name,', + 'body' => 'The gallery for event ":event" (package ":package") expires on :date. Only :days day of access remains.|The gallery for event ":event" (package ":package") expires on :date. Only :days days of access remain.', + 'action' => 'View gallery settings', + ], + 'gallery_expired' => [ + 'subject' => 'Gallery for ":event" has expired', + 'greeting' => 'Hello :name,', + 'body' => 'The gallery for event ":event" (package ":package") expired on :date. Guests can no longer view or download photos until you extend the gallery duration.', + 'action' => 'Manage gallery settings', + ], + 'package_expiring' => [ + 'subject' => 'Package ":package" expires in :days day|Package ":package" expires in :days days', + 'greeting' => 'Hello :name,', + 'body' => 'Your package ":package" expires on :date. Only :days day of access remains.|Your package ":package" expires on :date. Only :days days of access remain.', + 'action' => 'Renew or upgrade package', + ], + 'package_expired' => [ + 'subject' => 'Package ":package" has expired', + 'greeting' => 'Hello :name,', + 'body' => 'Your package ":package" expired on :date. Please renew or upgrade to continue creating events.', + 'action' => 'Manage packages', + ], + 'credits_low' => [ + 'subject' => 'Event credits are running low', + 'greeting' => 'Hello :name,', + 'body' => 'Your tenant has only :balance credits remaining (threshold: :threshold). Purchase additional credits or upgrade your package to keep creating events.', + 'action' => 'Buy credits or upgrade', + ], + 'footer' => 'Best regards,
The Fotospiel Team', + ], ]; diff --git a/tests/Feature/Api/EventGuestUploadLimitTest.php b/tests/Feature/Api/EventGuestUploadLimitTest.php new file mode 100644 index 0000000..4781cc8 --- /dev/null +++ b/tests/Feature/Api/EventGuestUploadLimitTest.php @@ -0,0 +1,134 @@ +create([ + 'key' => 'local', + 'name' => 'Local', + 'driver' => 'local', + 'config' => [], + 'is_hot' => true, + 'is_default' => true, + 'is_active' => true, + 'priority' => 1, + ]); + } + + public function test_guest_upload_blocked_when_photo_limit_reached(): void + { + Bus::fake(); + + $tenant = Tenant::factory()->create(); + $event = Event::factory()->for($tenant)->create([ + 'status' => 'published', + ]); + + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 1, + 'max_guests' => null, + ]); + + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 1, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(7), + ]); + + $emotion = Emotion::factory()->create(); + $emotion->eventTypes()->attach($event->event_type_id); + + /** @var EventJoinTokenService $tokenService */ + $tokenService = $this->app->make(EventJoinTokenService::class); + $joinToken = $tokenService->createToken($event, ['label' => 'Test']); + $token = $joinToken->plain_token; + + $response = $this->post("/api/v1/events/{$token}/upload", [ + 'photo' => UploadedFile::fake()->image('limit.jpg', 800, 600), + ], [ + 'X-Device-Id' => 'device-123', + ]); + + $response->assertStatus(402); + $response->assertJsonPath('error.code', 'photo_limit_exceeded'); + + Bus::assertNothingDispatched(); + } + + public function test_guest_upload_increments_usage_and_succeeds(): void + { + Bus::fake(); + + $tenant = Tenant::factory()->create(); + $event = Event::factory()->for($tenant)->create([ + 'status' => 'published', + ]); + + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 2, + 'max_guests' => null, + ]); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 1, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(7), + ]); + + $emotion = Emotion::factory()->create(); + $emotion->eventTypes()->attach($event->event_type_id); + + /** @var EventJoinTokenService $tokenService */ + $tokenService = $this->app->make(EventJoinTokenService::class); + $token = $tokenService->createToken($event, ['label' => 'Test'])->plain_token; + + $response = $this->post("/api/v1/events/{$token}/upload", [ + 'photo' => UploadedFile::fake()->image('success.jpg', 1024, 768), + ], [ + 'X-Device-Id' => 'device-456', + ]); + + $response->assertCreated(); + $this->assertEquals( + 2, + $eventPackage->refresh()->used_photos + ); + + $thresholdJobs = Bus::dispatched(SendEventPackagePhotoThresholdWarning::class); + $this->assertGreaterThanOrEqual(2, $thresholdJobs->count()); + Bus::assertDispatched(SendEventPackagePhotoLimitNotification::class); + } +} diff --git a/tests/Feature/Console/CheckEventPackagesCommandTest.php b/tests/Feature/Console/CheckEventPackagesCommandTest.php new file mode 100644 index 0000000..01ee9c3 --- /dev/null +++ b/tests/Feature/Console/CheckEventPackagesCommandTest.php @@ -0,0 +1,216 @@ +create(); + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 100, + ]); + $event = Event::factory()->for($tenant)->create(); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now()->subMonth(), + 'used_photos' => 10, + 'used_guests' => 0, + 'gallery_expires_at' => now()->copy()->addDays(7), + ]); + + Artisan::call(CheckEventPackages::class); + + EventFacade::assertDispatched(EventPackageGalleryExpiring::class, function ($event) use ($eventPackage) { + return $event->eventPackage->is($eventPackage) && $event->daysRemaining === 7; + }); + + $this->assertNotNull($eventPackage->fresh()->gallery_warning_sent_at); + } + + public function test_dispatches_gallery_expired_and_updates_timestamp(): void + { + EventFacade::fake(); + + $tenant = Tenant::factory()->create(); + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 100, + ]); + $event = Event::factory()->for($tenant)->create(); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now()->subMonth(), + 'used_photos' => 10, + 'used_guests' => 0, + 'gallery_expires_at' => now()->copy()->subDay(), + ]); + + Artisan::call(CheckEventPackages::class); + + EventFacade::assertDispatched(EventPackageGalleryExpired::class, function ($event) use ($eventPackage) { + return $event->eventPackage->is($eventPackage); + }); + + $this->assertNotNull($eventPackage->fresh()->gallery_expired_notified_at); + } + + public function test_dispatches_tenant_package_expiry_warning_and_updates_timestamp(): void + { + EventFacade::fake(); + + Config::set('package-limits.package_expiry_days', [6, 1]); + + $now = now()->startOfMinute(); + Carbon::setTestNow($now); + + try { + $tenant = Tenant::factory()->create(); + $package = Package::factory()->reseller()->create([ + 'max_events_per_year' => 5, + ]); + + $tenantPackage = TenantPackage::factory()->for($tenant)->for($package)->create([ + 'expires_at' => $now->copy()->addDays(6), + 'expiry_warning_sent_at' => null, + ]); + + Artisan::call(CheckEventPackages::class); + + EventFacade::assertDispatched(TenantPackageExpiring::class, function ($event) use ($tenantPackage) { + return $event->tenantPackage->is($tenantPackage) && $event->daysRemaining === 6; + }); + + $this->assertNotNull($tenantPackage->fresh()->expiry_warning_sent_at); + } finally { + Carbon::setTestNow(); + } + } + + public function test_dispatches_tenant_package_expired_and_updates_timestamp(): void + { + EventFacade::fake(); + + $now = now()->startOfMinute(); + Carbon::setTestNow($now); + + try { + $tenant = Tenant::factory()->create(); + $package = Package::factory()->reseller()->create([ + 'max_events_per_year' => 5, + ]); + + $tenantPackage = TenantPackage::factory()->for($tenant)->for($package)->create([ + 'expires_at' => $now->copy()->subDay(), + 'expired_notified_at' => null, + ]); + + Artisan::call(CheckEventPackages::class); + + EventFacade::assertDispatched(TenantPackageExpired::class, function ($event) use ($tenantPackage) { + return $event->tenantPackage->is($tenantPackage); + }); + + $this->assertNotNull($tenantPackage->fresh()->expired_notified_at); + } finally { + Carbon::setTestNow(); + } + } + + public function test_dispatches_credit_warning_and_sets_threshold(): void + { + EventFacade::fake(); + + Config::set('package-limits.credit_thresholds', [5, 1]); + + $tenant = Tenant::factory()->create([ + 'event_credits_balance' => 5, + 'credit_warning_sent_at' => null, + 'credit_warning_threshold' => null, + ]); + + Artisan::call(CheckEventPackages::class); + + EventFacade::assertDispatched(TenantCreditsLow::class, function ($event) use ($tenant) { + return $event->tenant->is($tenant) && $event->threshold === 5 && $event->balance === 5; + }); + + $tenant->refresh(); + + $this->assertNotNull($tenant->credit_warning_sent_at); + $this->assertSame(5, $tenant->credit_warning_threshold); + } + + public function test_resets_credit_warning_when_balance_recovers(): void + { + EventFacade::fake(); + + Config::set('package-limits.credit_thresholds', [5, 1]); + + $tenant = Tenant::factory()->create([ + 'event_credits_balance' => 10, + 'credit_warning_sent_at' => now()->subDay(), + 'credit_warning_threshold' => 1, + ]); + + Artisan::call(CheckEventPackages::class); + + EventFacade::assertNotDispatched(TenantCreditsLow::class); + + $tenant->refresh(); + + $this->assertNull($tenant->credit_warning_sent_at); + $this->assertNull($tenant->credit_warning_threshold); + } + + public function test_dispatches_lower_credit_threshold_after_higher_warning(): void + { + EventFacade::fake(); + + Config::set('package-limits.credit_thresholds', [5, 1]); + + $tenant = Tenant::factory()->create([ + 'event_credits_balance' => 1, + 'credit_warning_sent_at' => now()->subDay(), + 'credit_warning_threshold' => 5, + ]); + + Artisan::call(CheckEventPackages::class); + + EventFacade::assertDispatched(TenantCreditsLow::class, function ($event) use ($tenant) { + return $event->tenant->is($tenant) && $event->threshold === 1; + }); + + $tenant->refresh(); + + $this->assertSame(1, $tenant->credit_warning_threshold); + $this->assertNotNull($tenant->credit_warning_sent_at); + } +} diff --git a/tests/Unit/Services/PackageLimitEvaluatorTest.php b/tests/Unit/Services/PackageLimitEvaluatorTest.php new file mode 100644 index 0000000..ca45036 --- /dev/null +++ b/tests/Unit/Services/PackageLimitEvaluatorTest.php @@ -0,0 +1,128 @@ +evaluator = $this->app->make(PackageLimitEvaluator::class); + } + + public function test_assess_event_creation_returns_null_when_allowance_available(): void + { + $tenant = Tenant::factory()->create(['event_credits_balance' => 2]); + + $violation = $this->evaluator->assessEventCreation($tenant); + + $this->assertNull($violation); + } + + public function test_assess_event_creation_returns_package_violation_when_quota_reached(): void + { + $package = Package::factory()->reseller()->create([ + 'max_events_per_year' => 1, + ]); + + $tenant = Tenant::factory()->create(['event_credits_balance' => 0]); + + TenantPackage::factory()->create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'used_events' => 1, + 'expires_at' => now()->addMonth(), + 'active' => true, + ]); + + $tenant->refresh(); + + $violation = $this->evaluator->assessEventCreation($tenant); + + $this->assertNotNull($violation); + $this->assertSame('event_limit_exceeded', $violation['code']); + $this->assertSame('events', $violation['meta']['scope']); + $this->assertSame(0, $violation['meta']['remaining']); + } + + public function test_assess_event_creation_returns_credit_violation_when_no_credits(): void + { + $tenant = Tenant::factory()->create(['event_credits_balance' => 0]); + + $violation = $this->evaluator->assessEventCreation($tenant); + + $this->assertNotNull($violation); + $this->assertSame('event_credits_exhausted', $violation['code']); + $this->assertSame('credits', $violation['meta']['scope']); + } + + public function test_assess_photo_upload_returns_violation_when_photo_limit_reached(): void + { + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 5, + ]); + + $tenant = Tenant::factory()->create(); + + $event = Event::factory() + ->for($tenant) + ->create(); + + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 5, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(14), + ]); + + $violation = $this->evaluator->assessPhotoUpload($tenant->fresh(), $event->id); + + $this->assertNotNull($violation); + $this->assertSame('photo_limit_exceeded', $violation['code']); + $this->assertSame('photos', $violation['meta']['scope']); + $this->assertSame(0, $violation['meta']['remaining']); + } + + public function test_assess_photo_upload_returns_null_when_below_limit(): void + { + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 10, + ]); + + $tenant = Tenant::factory()->create(); + + $event = Event::factory() + ->for($tenant) + ->create(); + + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 4, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(14), + ]); + + $violation = $this->evaluator->assessPhotoUpload($tenant->fresh(), $event->id); + + $this->assertNull($violation); + } +} diff --git a/tests/Unit/Services/PackageUsageTrackerTest.php b/tests/Unit/Services/PackageUsageTrackerTest.php new file mode 100644 index 0000000..a735490 --- /dev/null +++ b/tests/Unit/Services/PackageUsageTrackerTest.php @@ -0,0 +1,148 @@ +create(); + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 10, + ]); + $event = Event::factory()->for($tenant)->create(); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 8, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(7), + ])->fresh(['package']); + + /** @var PackageUsageTracker $tracker */ + $tracker = app(PackageUsageTracker::class); + + $tracker->recordPhotoUsage($eventPackage, 7, 1); + + EventFacade::assertDispatched(EventPackagePhotoThresholdReached::class); + EventFacade::assertNotDispatched(EventPackagePhotoLimitReached::class); + } + + public function test_dispatches_limit_event_when_reached(): void + { + EventFacade::fake([ + EventPackagePhotoThresholdReached::class, + EventPackagePhotoLimitReached::class, + ]); + + $tenant = Tenant::factory()->create(); + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 2, + 'max_guests' => null, + ]); + $event = Event::factory()->for($tenant)->create(); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 2, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(7), + ])->fresh(['package']); + + /** @var PackageUsageTracker $tracker */ + $tracker = app(PackageUsageTracker::class); + + $tracker->recordPhotoUsage($eventPackage, 1, 1); + + EventFacade::assertDispatched(EventPackagePhotoLimitReached::class); + } + + public function test_dispatches_guest_threshold_event_when_crossed(): void + { + EventFacade::fake([ + EventPackageGuestThresholdReached::class, + EventPackageGuestLimitReached::class, + ]); + + $tenant = Tenant::factory()->create(); + $package = Package::factory()->endcustomer()->create([ + 'max_guests' => 10, + ]); + $event = Event::factory()->for($tenant)->create(); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 0, + 'used_guests' => 8, + 'gallery_expires_at' => now()->addDays(7), + ])->fresh(['package']); + + /** @var PackageUsageTracker $tracker */ + $tracker = app(PackageUsageTracker::class); + + $tracker->recordGuestUsage($eventPackage, 7, 1); + + EventFacade::assertDispatched(EventPackageGuestThresholdReached::class); + EventFacade::assertNotDispatched(EventPackageGuestLimitReached::class); + } + + public function test_dispatches_guest_limit_event_when_reached(): void + { + EventFacade::fake([ + EventPackageGuestThresholdReached::class, + EventPackageGuestLimitReached::class, + ]); + + $tenant = Tenant::factory()->create(); + $package = Package::factory()->endcustomer()->create([ + 'max_guests' => 2, + ]); + $event = Event::factory()->for($tenant)->create(); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 0, + 'used_guests' => 2, + 'gallery_expires_at' => now()->addDays(7), + ])->fresh(['package']); + + /** @var PackageUsageTracker $tracker */ + $tracker = app(PackageUsageTracker::class); + + $tracker->recordGuestUsage($eventPackage, 1, 1); + + EventFacade::assertDispatched(EventPackageGuestLimitReached::class); + } +} diff --git a/tests/Unit/Services/TenantUsageTrackerTest.php b/tests/Unit/Services/TenantUsageTrackerTest.php new file mode 100644 index 0000000..b49ee87 --- /dev/null +++ b/tests/Unit/Services/TenantUsageTrackerTest.php @@ -0,0 +1,115 @@ +create(); + $package = Package::factory()->reseller()->create([ + 'max_events_per_year' => 10, + ]); + + $tenantPackage = TenantPackage::factory() + ->for($tenant) + ->for($package) + ->create([ + 'used_events' => 6, + ])->fresh(); + + /** @var TenantUsageTracker $tracker */ + $tracker = app(TenantUsageTracker::class); + + $tracker->recordEventUsage($tenantPackage, 4, 2); + + EventFacade::assertDispatched(TenantPackageEventThresholdReached::class); + EventFacade::assertNotDispatched(TenantPackageEventLimitReached::class); + + $tenantPackage->refresh(); + + $this->assertNotNull($tenantPackage->event_warning_sent_at); + $this->assertSame(0.5, (float) $tenantPackage->event_warning_threshold); + } + + public function test_record_event_usage_dispatches_limit_and_sets_timestamp(): void + { + EventFacade::fake([ + TenantPackageEventThresholdReached::class, + TenantPackageEventLimitReached::class, + ]); + + Config::set('package-limits.event_thresholds', [0.5]); + + $tenant = Tenant::factory()->create(); + $package = Package::factory()->reseller()->create([ + 'max_events_per_year' => 3, + ]); + + $tenantPackage = TenantPackage::factory() + ->for($tenant) + ->for($package) + ->create([ + 'used_events' => 3, + ])->fresh(); + + /** @var TenantUsageTracker $tracker */ + $tracker = app(TenantUsageTracker::class); + + $tracker->recordEventUsage($tenantPackage, 2, 1); + + EventFacade::assertDispatched(TenantPackageEventLimitReached::class); + + $tenantPackage->refresh(); + + $this->assertNotNull($tenantPackage->event_limit_notified_at); + } + + public function test_record_credit_balance_dispatches_event_and_updates_tenant(): void + { + EventFacade::fake([TenantCreditsLow::class]); + + Config::set('package-limits.credit_thresholds', [5, 1]); + + $tenant = Tenant::factory()->create([ + 'event_credits_balance' => 5, + 'credit_warning_sent_at' => null, + 'credit_warning_threshold' => null, + ]); + + /** @var TenantUsageTracker $tracker */ + $tracker = app(TenantUsageTracker::class); + + $tracker->recordCreditBalance($tenant, 6, 5); + + EventFacade::assertDispatched(TenantCreditsLow::class, function ($event) use ($tenant) { + return $event->tenant->is($tenant) && $event->threshold === 5; + }); + + $tenant->refresh(); + + $this->assertNotNull($tenant->credit_warning_sent_at); + $this->assertSame(5, $tenant->credit_warning_threshold); + } +} diff --git a/tests/Unit/TenantModelTest.php b/tests/Unit/TenantModelTest.php index ce380e1..9d2b8b7 100644 --- a/tests/Unit/TenantModelTest.php +++ b/tests/Unit/TenantModelTest.php @@ -2,6 +2,8 @@ namespace Tests\Unit; +use App\Events\Packages\TenantPackageEventLimitReached; +use App\Events\Packages\TenantPackageEventThresholdReached; use App\Models\Event; use App\Models\Package; use App\Models\PackagePurchase; @@ -9,13 +11,15 @@ use App\Models\Photo; use App\Models\Tenant; use App\Models\TenantPackage; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Event as EventFacade; use Tests\TestCase; class TenantModelTest extends TestCase { use RefreshDatabase; - public function testTenantHasManyEvents(): void + public function test_tenant_has_many_events(): void { $tenant = Tenant::factory()->create(); Event::factory()->count(3)->create(['tenant_id' => $tenant->id]); @@ -23,7 +27,7 @@ class TenantModelTest extends TestCase $this->assertCount(3, $tenant->events()->get()); } - public function testTenantHasPhotosThroughEvents(): void + public function test_tenant_has_photos_through_events(): void { $tenant = Tenant::factory()->create(); $event = Event::factory()->create(['tenant_id' => $tenant->id]); @@ -32,7 +36,7 @@ class TenantModelTest extends TestCase $this->assertCount(2, $tenant->photos()->get()); } - public function testTenantHasManyPackagePurchases(): void + public function test_tenant_has_many_package_purchases(): void { $tenant = Tenant::factory()->create(); $package = Package::factory()->create(); @@ -44,7 +48,7 @@ class TenantModelTest extends TestCase $this->assertCount(2, $tenant->purchases()->get()); } - public function testActiveSubscriptionAccessorReturnsTrueWhenActivePackageExists(): void + public function test_active_subscription_accessor_returns_true_when_active_package_exists(): void { $tenant = Tenant::factory()->create(); $package = Package::factory()->create(['type' => 'reseller']); @@ -58,21 +62,21 @@ class TenantModelTest extends TestCase $this->assertTrue($tenant->fresh()->active_subscription); } - public function testActiveSubscriptionAccessorReturnsFalseWithoutActivePackage(): void + public function test_active_subscription_accessor_returns_false_without_active_package(): void { $tenant = Tenant::factory()->create(); $this->assertFalse($tenant->fresh()->active_subscription); } - public function testIncrementUsedEventsReturnsFalseWithoutActivePackage(): void + public function test_increment_used_events_returns_false_without_active_package(): void { $tenant = Tenant::factory()->create(); $this->assertFalse($tenant->incrementUsedEvents()); } - public function testIncrementUsedEventsUpdatesActivePackage(): void + public function test_increment_used_events_updates_active_package(): void { $tenant = Tenant::factory()->create(); $package = Package::factory()->create(['type' => 'reseller']); @@ -87,7 +91,41 @@ class TenantModelTest extends TestCase $this->assertEquals(3, $tenantPackage->fresh()->used_events); } - public function testSettingsCastToArray(): void + public function test_consume_event_allowance_dispatches_notifications_and_updates_usage(): void + { + EventFacade::fake([ + TenantPackageEventThresholdReached::class, + TenantPackageEventLimitReached::class, + ]); + + Config::set('package-limits.event_thresholds', [0.5]); + + $tenant = Tenant::factory()->create(); + $package = Package::factory()->create([ + 'type' => 'reseller', + 'max_events_per_year' => 4, + ]); + + $tenantPackage = TenantPackage::factory()->create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'active' => true, + 'used_events' => 1, + ]); + + $this->assertTrue($tenant->consumeEventAllowance()); + + EventFacade::assertDispatched(TenantPackageEventThresholdReached::class); + EventFacade::assertNotDispatched(TenantPackageEventLimitReached::class); + + $tenantPackage->refresh(); + + $this->assertSame(2, $tenantPackage->used_events); + $this->assertNotNull($tenantPackage->event_warning_sent_at); + $this->assertSame(0.5, (float) $tenantPackage->event_warning_threshold); + } + + public function test_settings_cast_to_array(): void { $tenant = Tenant::factory()->create([ 'settings' => ['theme' => 'dark', 'logo' => 'logo.png'], @@ -97,7 +135,7 @@ class TenantModelTest extends TestCase $this->assertSame('dark', $tenant->settings['theme']); } - public function testFeaturesCastToArray(): void + public function test_features_cast_to_array(): void { $tenant = Tenant::factory()->create([ 'features' => ['photo_likes' => true, 'analytics' => false], @@ -107,4 +145,23 @@ class TenantModelTest extends TestCase $this->assertTrue($tenant->features['photo_likes']); $this->assertFalse($tenant->features['analytics']); } + + public function test_increment_credits_clears_warning_when_balance_above_threshold(): void + { + Config::set('package-limits.credit_thresholds', [5, 1]); + + $tenant = Tenant::factory()->create([ + 'event_credits_balance' => 1, + 'credit_warning_sent_at' => now()->subDay(), + 'credit_warning_threshold' => 1, + ]); + + $tenant->incrementCredits(10); + + $tenant->refresh(); + + $this->assertNull($tenant->credit_warning_sent_at); + $this->assertNull($tenant->credit_warning_threshold); + $this->assertSame(11, (int) $tenant->event_credits_balance); + } }