verbesserung von benachrichtungen und warnungen an nutzer abgeschlossen. layout editor nun auf gutem stand.
This commit is contained in:
250
app/Console/Commands/RetryTenantNotification.php
Normal file
250
app/Console/Commands/RetryTenantNotification.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Packages\SendEventPackageGalleryExpired;
|
||||
use App\Jobs\Packages\SendEventPackageGalleryWarning;
|
||||
use App\Jobs\Packages\SendEventPackageGuestLimitNotification;
|
||||
use App\Jobs\Packages\SendEventPackageGuestThresholdWarning;
|
||||
use App\Jobs\Packages\SendEventPackagePhotoLimitNotification;
|
||||
use App\Jobs\Packages\SendEventPackagePhotoThresholdWarning;
|
||||
use App\Jobs\Packages\SendTenantCreditsLowNotification;
|
||||
use App\Jobs\Packages\SendTenantPackageEventLimitNotification;
|
||||
use App\Jobs\Packages\SendTenantPackageEventThresholdWarning;
|
||||
use App\Jobs\Packages\SendTenantPackageExpiredNotification;
|
||||
use App\Jobs\Packages\SendTenantPackageExpiringNotification;
|
||||
use App\Models\TenantNotificationLog;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RetryTenantNotification extends Command
|
||||
{
|
||||
protected $signature = 'tenant:notifications:retry {log_id?} {--tenant=} {--status=failed}';
|
||||
|
||||
protected $description = 'Retry tenant package limit notifications based on stored notification logs.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$query = TenantNotificationLog::query();
|
||||
|
||||
if ($logId = $this->argument('log_id')) {
|
||||
$query->whereKey($logId);
|
||||
}
|
||||
|
||||
if ($tenantId = $this->option('tenant')) {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
if ($status = $this->option('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$logs = $query->orderBy('id')->get();
|
||||
|
||||
if ($logs->isEmpty()) {
|
||||
$this->warn('No notification logs matched the given filters.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$dispatched = $this->dispatchForLog($log);
|
||||
|
||||
if ($dispatched) {
|
||||
$log->forceFill([
|
||||
'status' => 'queued',
|
||||
'failed_at' => null,
|
||||
'failure_reason' => null,
|
||||
])->save();
|
||||
|
||||
$this->info("Queued retry for log #{$log->id} ({$log->type})");
|
||||
} else {
|
||||
$this->warn("Skipped log #{$log->id} ({$log->type}) – missing context");
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function dispatchForLog(TenantNotificationLog $log): bool
|
||||
{
|
||||
$context = $log->context ?? [];
|
||||
|
||||
return match ($log->type) {
|
||||
'photo_threshold' => $this->dispatchPhotoThreshold($context),
|
||||
'photo_limit' => $this->dispatchPhotoLimit($context),
|
||||
'guest_threshold' => $this->dispatchGuestThreshold($context),
|
||||
'guest_limit' => $this->dispatchGuestLimit($context),
|
||||
'gallery_warning' => $this->dispatchGalleryWarning($context),
|
||||
'gallery_expired' => $this->dispatchGalleryExpired($context),
|
||||
'credits_low' => $this->dispatchCreditsLow($log->tenant_id, $context),
|
||||
'event_threshold' => $this->dispatchEventThreshold($context),
|
||||
'event_limit' => $this->dispatchEventLimit($context),
|
||||
'package_expiring' => $this->dispatchPackageExpiring($context),
|
||||
'package_expired' => $this->dispatchPackageExpired($context),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private function dispatchPhotoThreshold(array $context): bool
|
||||
{
|
||||
$eventPackageId = Arr::get($context, 'event_package_id');
|
||||
$threshold = Arr::get($context, 'threshold');
|
||||
$limit = Arr::get($context, 'limit');
|
||||
$used = Arr::get($context, 'used');
|
||||
|
||||
if ($eventPackageId === null || $threshold === null || $limit === null || $used === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SendEventPackagePhotoThresholdWarning::dispatch($eventPackageId, (float) $threshold, (int) $limit, (int) $used);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function dispatchPhotoLimit(array $context): bool
|
||||
{
|
||||
$eventPackageId = Arr::get($context, 'event_package_id');
|
||||
$limit = Arr::get($context, 'limit');
|
||||
|
||||
if ($eventPackageId === null || $limit === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SendEventPackagePhotoLimitNotification::dispatch($eventPackageId, (int) $limit);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function dispatchGuestThreshold(array $context): bool
|
||||
{
|
||||
$eventPackageId = Arr::get($context, 'event_package_id');
|
||||
$threshold = Arr::get($context, 'threshold');
|
||||
$limit = Arr::get($context, 'limit');
|
||||
$used = Arr::get($context, 'used');
|
||||
|
||||
if ($eventPackageId === null || $threshold === null || $limit === null || $used === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SendEventPackageGuestThresholdWarning::dispatch($eventPackageId, (float) $threshold, (int) $limit, (int) $used);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function dispatchGuestLimit(array $context): bool
|
||||
{
|
||||
$eventPackageId = Arr::get($context, 'event_package_id');
|
||||
$limit = Arr::get($context, 'limit');
|
||||
|
||||
if ($eventPackageId === null || $limit === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SendEventPackageGuestLimitNotification::dispatch($eventPackageId, (int) $limit);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function dispatchGalleryWarning(array $context): bool
|
||||
{
|
||||
$eventPackageId = Arr::get($context, 'event_package_id');
|
||||
$days = Arr::get($context, 'days_remaining');
|
||||
|
||||
if ($eventPackageId === null || $days === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SendEventPackageGalleryWarning::dispatch($eventPackageId, (int) $days);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function dispatchGalleryExpired(array $context): bool
|
||||
{
|
||||
$eventPackageId = Arr::get($context, 'event_package_id');
|
||||
|
||||
if ($eventPackageId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SendEventPackageGalleryExpired::dispatch($eventPackageId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function dispatchCreditsLow(int $tenantId, array $context): bool
|
||||
{
|
||||
$balance = Arr::get($context, 'balance');
|
||||
$threshold = Arr::get($context, 'threshold');
|
||||
|
||||
if ($balance === null || $threshold === null) {
|
||||
Log::warning('credits_low retry missing balance or threshold', compact('tenantId', 'context'));
|
||||
}
|
||||
|
||||
$balance = $balance !== null ? (int) $balance : 0;
|
||||
$threshold = $threshold !== null ? (int) $threshold : 0;
|
||||
|
||||
SendTenantCreditsLowNotification::dispatch($tenantId, $balance, $threshold);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function dispatchEventThreshold(array $context): bool
|
||||
{
|
||||
$tenantPackageId = Arr::get($context, 'tenant_package_id');
|
||||
$threshold = Arr::get($context, 'threshold');
|
||||
$limit = Arr::get($context, 'limit');
|
||||
$used = Arr::get($context, 'used');
|
||||
|
||||
if ($tenantPackageId === null || $threshold === null || $limit === null || $used === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SendTenantPackageEventThresholdWarning::dispatch($tenantPackageId, (float) $threshold, (int) $limit, (int) $used);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function dispatchEventLimit(array $context): bool
|
||||
{
|
||||
$tenantPackageId = Arr::get($context, 'tenant_package_id');
|
||||
$limit = Arr::get($context, 'limit');
|
||||
|
||||
if ($tenantPackageId === null || $limit === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SendTenantPackageEventLimitNotification::dispatch($tenantPackageId, (int) $limit);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function dispatchPackageExpiring(array $context): bool
|
||||
{
|
||||
$tenantPackageId = Arr::get($context, 'tenant_package_id');
|
||||
$days = Arr::get($context, 'days_remaining');
|
||||
|
||||
if ($tenantPackageId === null || $days === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SendTenantPackageExpiringNotification::dispatch($tenantPackageId, (int) $days);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function dispatchPackageExpired(array $context): bool
|
||||
{
|
||||
$tenantPackageId = Arr::get($context, 'tenant_package_id');
|
||||
|
||||
if ($tenantPackageId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SendTenantPackageExpiredNotification::dispatch($tenantPackageId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TenantNotificationLog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NotificationLogController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant') ?? $request->user()?->tenant;
|
||||
|
||||
if (! $tenant) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'tenant_context_missing',
|
||||
'title' => 'Tenant context missing',
|
||||
'message' => 'Unable to resolve tenant for notification logs.',
|
||||
],
|
||||
], 403);
|
||||
}
|
||||
|
||||
$query = TenantNotificationLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->latest();
|
||||
|
||||
if ($type = $request->query('type')) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
if ($status = $request->query('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$perPage = (int) $request->query('per_page', 20);
|
||||
$perPage = max(1, min($perPage, 100));
|
||||
|
||||
$logs = $query->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'data' => $logs->items(),
|
||||
'meta' => [
|
||||
'current_page' => $logs->currentPage(),
|
||||
'last_page' => $logs->lastPage(),
|
||||
'per_page' => $logs->perPage(),
|
||||
'total' => $logs->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
60
app/Jobs/Concerns/LogsTenantNotifications.php
Normal file
60
app/Jobs/Concerns/LogsTenantNotifications.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Packages\TenantNotificationLogger;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
trait LogsTenantNotifications
|
||||
{
|
||||
protected function notificationLogger(): TenantNotificationLogger
|
||||
{
|
||||
return app(TenantNotificationLogger::class);
|
||||
}
|
||||
|
||||
protected function logNotification(Tenant $tenant, array $attributes): void
|
||||
{
|
||||
$this->notificationLogger()->log($tenant, $attributes);
|
||||
}
|
||||
|
||||
protected function dispatchToRecipients(
|
||||
Tenant $tenant,
|
||||
iterable $recipients,
|
||||
string $type,
|
||||
callable $callback,
|
||||
array $context = []
|
||||
): void {
|
||||
foreach ($recipients as $recipient) {
|
||||
try {
|
||||
$callback($recipient);
|
||||
|
||||
$this->logNotification($tenant, [
|
||||
'type' => $type,
|
||||
'channel' => 'mail',
|
||||
'recipient' => $recipient,
|
||||
'status' => 'sent',
|
||||
'context' => $context,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Tenant notification failed', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'type' => $type,
|
||||
'recipient' => $recipient,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->logNotification($tenant, [
|
||||
'type' => $type,
|
||||
'channel' => 'mail',
|
||||
'recipient' => $recipient,
|
||||
'status' => 'failed',
|
||||
'context' => $context,
|
||||
'failed_at' => now(),
|
||||
'failure_reason' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||
use App\Models\EventPackage;
|
||||
use App\Notifications\Packages\EventPackageGalleryExpiredNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -16,6 +17,7 @@ class SendEventPackageGalleryExpired implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use LogsTenantNotifications;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
@@ -40,6 +42,12 @@ class SendEventPackageGalleryExpired implements ShouldQueue
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenant, 'gallery_expired')) {
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'gallery_expired',
|
||||
'status' => 'skipped',
|
||||
'context' => $this->context($eventPackage),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,11 +62,33 @@ class SendEventPackageGalleryExpired implements ShouldQueue
|
||||
'event_package_id' => $eventPackage->id,
|
||||
]);
|
||||
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'gallery_expired',
|
||||
'status' => 'skipped',
|
||||
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$context = $this->context($eventPackage);
|
||||
|
||||
$this->dispatchToRecipients(
|
||||
$tenant,
|
||||
$emails,
|
||||
'gallery_expired',
|
||||
function (string $email) use ($eventPackage) {
|
||||
Notification::route('mail', $email)->notify(new EventPackageGalleryExpiredNotification($eventPackage));
|
||||
},
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
private function context(EventPackage $eventPackage): array
|
||||
{
|
||||
return [
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $eventPackage->event_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||
use App\Models\EventPackage;
|
||||
use App\Notifications\Packages\EventPackageGalleryExpiringNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -16,6 +17,7 @@ class SendEventPackageGalleryWarning implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use LogsTenantNotifications;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
@@ -43,6 +45,12 @@ class SendEventPackageGalleryWarning implements ShouldQueue
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenant, 'gallery_warnings')) {
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'gallery_warning',
|
||||
'status' => 'skipped',
|
||||
'context' => $this->context($eventPackage),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,14 +66,37 @@ class SendEventPackageGalleryWarning implements ShouldQueue
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
]);
|
||||
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'gallery_warning',
|
||||
'status' => 'skipped',
|
||||
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$context = $this->context($eventPackage);
|
||||
|
||||
$this->dispatchToRecipients(
|
||||
$tenant,
|
||||
$emails,
|
||||
'gallery_warning',
|
||||
function (string $email) use ($eventPackage) {
|
||||
Notification::route('mail', $email)->notify(new EventPackageGalleryExpiringNotification(
|
||||
$eventPackage,
|
||||
$this->daysRemaining,
|
||||
));
|
||||
},
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
private function context(EventPackage $eventPackage): array
|
||||
{
|
||||
return [
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $eventPackage->event_id,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||
use App\Models\EventPackage;
|
||||
use App\Notifications\Packages\EventPackageGuestLimitNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -16,6 +17,7 @@ class SendEventPackageGuestLimitNotification implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use LogsTenantNotifications;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
@@ -43,6 +45,12 @@ class SendEventPackageGuestLimitNotification implements ShouldQueue
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenant, 'guest_limits')) {
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'guest_limit',
|
||||
'status' => 'skipped',
|
||||
'context' => $this->context($eventPackage),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,14 +65,37 @@ class SendEventPackageGuestLimitNotification implements ShouldQueue
|
||||
'event_package_id' => $eventPackage->id,
|
||||
]);
|
||||
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'guest_limit',
|
||||
'status' => 'skipped',
|
||||
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$context = $this->context($eventPackage);
|
||||
|
||||
$this->dispatchToRecipients(
|
||||
$tenant,
|
||||
$emails,
|
||||
'guest_limit',
|
||||
function (string $email) use ($eventPackage) {
|
||||
Notification::route('mail', $email)->notify(new EventPackageGuestLimitNotification(
|
||||
$eventPackage,
|
||||
$this->limit,
|
||||
));
|
||||
},
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
private function context(EventPackage $eventPackage): array
|
||||
{
|
||||
return [
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $eventPackage->event_id,
|
||||
'limit' => $this->limit,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||
use App\Models\EventPackage;
|
||||
use App\Notifications\Packages\EventPackageGuestThresholdNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -16,6 +17,7 @@ class SendEventPackageGuestThresholdWarning implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use LogsTenantNotifications;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
@@ -45,6 +47,12 @@ class SendEventPackageGuestThresholdWarning implements ShouldQueue
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenant, 'guest_thresholds')) {
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'guest_threshold',
|
||||
'status' => 'skipped',
|
||||
'context' => $this->context($eventPackage),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,16 +68,41 @@ class SendEventPackageGuestThresholdWarning implements ShouldQueue
|
||||
'threshold' => $this->threshold,
|
||||
]);
|
||||
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'guest_threshold',
|
||||
'status' => 'skipped',
|
||||
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$context = $this->context($eventPackage);
|
||||
|
||||
$this->dispatchToRecipients(
|
||||
$tenant,
|
||||
$emails,
|
||||
'guest_threshold',
|
||||
function (string $email) use ($eventPackage) {
|
||||
Notification::route('mail', $email)->notify(new EventPackageGuestThresholdNotification(
|
||||
$eventPackage,
|
||||
$this->threshold,
|
||||
$this->limit,
|
||||
$this->used,
|
||||
));
|
||||
},
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
private function context(EventPackage $eventPackage): array
|
||||
{
|
||||
return [
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $eventPackage->event_id,
|
||||
'threshold' => $this->threshold,
|
||||
'limit' => $this->limit,
|
||||
'used' => $this->used,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||
use App\Models\EventPackage;
|
||||
use App\Notifications\Packages\EventPackagePhotoLimitNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -16,6 +17,7 @@ class SendEventPackagePhotoLimitNotification implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use LogsTenantNotifications;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
@@ -43,6 +45,12 @@ class SendEventPackagePhotoLimitNotification implements ShouldQueue
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenant, 'photo_limits')) {
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'photo_limit',
|
||||
'status' => 'skipped',
|
||||
'context' => $this->context($eventPackage),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,16 +66,39 @@ class SendEventPackagePhotoLimitNotification implements ShouldQueue
|
||||
'limit' => $this->limit,
|
||||
]);
|
||||
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'photo_limit',
|
||||
'status' => 'skipped',
|
||||
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$context = $this->context($eventPackage);
|
||||
|
||||
$this->dispatchToRecipients(
|
||||
$tenant,
|
||||
$emails,
|
||||
'photo_limit',
|
||||
function (string $email) use ($eventPackage) {
|
||||
Notification::route('mail', $email)->notify(
|
||||
new EventPackagePhotoLimitNotification(
|
||||
$eventPackage,
|
||||
$this->limit,
|
||||
)
|
||||
);
|
||||
},
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
private function context(EventPackage $eventPackage): array
|
||||
{
|
||||
return [
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $eventPackage->event_id,
|
||||
'limit' => $this->limit,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||
use App\Models\EventPackage;
|
||||
use App\Notifications\Packages\EventPackagePhotoThresholdNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -16,6 +17,7 @@ class SendEventPackagePhotoThresholdWarning implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use LogsTenantNotifications;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
@@ -45,6 +47,12 @@ class SendEventPackagePhotoThresholdWarning implements ShouldQueue
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenant, 'photo_thresholds')) {
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'photo_threshold',
|
||||
'status' => 'skipped',
|
||||
'context' => $this->context($eventPackage),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,10 +68,22 @@ class SendEventPackagePhotoThresholdWarning implements ShouldQueue
|
||||
'threshold' => $this->threshold,
|
||||
]);
|
||||
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'photo_threshold',
|
||||
'status' => 'skipped',
|
||||
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$context = $this->context($eventPackage);
|
||||
|
||||
$this->dispatchToRecipients(
|
||||
$tenant,
|
||||
$emails,
|
||||
'photo_threshold',
|
||||
function (string $email) use ($eventPackage) {
|
||||
Notification::route('mail', $email)->notify(
|
||||
new EventPackagePhotoThresholdNotification(
|
||||
$eventPackage,
|
||||
@@ -72,6 +92,19 @@ class SendEventPackagePhotoThresholdWarning implements ShouldQueue
|
||||
$this->used,
|
||||
)
|
||||
);
|
||||
},
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
private function context(EventPackage $eventPackage): array
|
||||
{
|
||||
return [
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $eventPackage->event_id,
|
||||
'threshold' => $this->threshold,
|
||||
'limit' => $this->limit,
|
||||
'used' => $this->used,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\Packages\TenantCreditsLowNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -16,6 +17,7 @@ class SendTenantCreditsLowNotification implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use LogsTenantNotifications;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
@@ -39,6 +41,16 @@ class SendTenantCreditsLowNotification implements ShouldQueue
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenant, 'credits_low')) {
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'credits_low',
|
||||
'status' => 'skipped',
|
||||
'context' => [
|
||||
'balance' => $this->balance,
|
||||
'threshold' => $this->threshold,
|
||||
'reason' => 'opt_out',
|
||||
],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,15 +65,36 @@ class SendTenantCreditsLowNotification implements ShouldQueue
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'credits_low',
|
||||
'status' => 'skipped',
|
||||
'context' => [
|
||||
'balance' => $this->balance,
|
||||
'threshold' => $this->threshold,
|
||||
'reason' => 'no_recipient',
|
||||
],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$context = [
|
||||
'balance' => $this->balance,
|
||||
'threshold' => $this->threshold,
|
||||
];
|
||||
|
||||
$this->dispatchToRecipients(
|
||||
$tenant,
|
||||
$emails,
|
||||
'credits_low',
|
||||
function (string $email) use ($tenant) {
|
||||
Notification::route('mail', $email)->notify(new TenantCreditsLowNotification(
|
||||
$tenant,
|
||||
$this->balance,
|
||||
$this->threshold,
|
||||
));
|
||||
}
|
||||
},
|
||||
$context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Notifications\Packages\TenantPackageEventLimitNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -16,6 +17,7 @@ class SendTenantPackageEventLimitNotification implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use LogsTenantNotifications;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
@@ -36,14 +38,22 @@ class SendTenantPackageEventLimitNotification implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $tenantPackage->tenant;
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenantPackage->tenant, 'event_limits')) {
|
||||
if (! $preferences->shouldNotify($tenant, 'event_limits')) {
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'event_limit',
|
||||
'status' => 'skipped',
|
||||
'context' => $this->context($tenantPackage),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenantPackage->tenant->contact_email,
|
||||
$tenantPackage->tenant->user?->email,
|
||||
$tenant->contact_email,
|
||||
$tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
@@ -52,14 +62,36 @@ class SendTenantPackageEventLimitNotification implements ShouldQueue
|
||||
'tenant_package_id' => $tenantPackage->id,
|
||||
]);
|
||||
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'event_limit',
|
||||
'status' => 'skipped',
|
||||
'context' => array_merge($this->context($tenantPackage), ['reason' => 'no_recipient']),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$context = $this->context($tenantPackage);
|
||||
|
||||
$this->dispatchToRecipients(
|
||||
$tenant,
|
||||
$emails,
|
||||
'event_limit',
|
||||
function (string $email) use ($tenantPackage) {
|
||||
Notification::route('mail', $email)->notify(new TenantPackageEventLimitNotification(
|
||||
$tenantPackage,
|
||||
$this->limit,
|
||||
));
|
||||
},
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
private function context(TenantPackage $tenantPackage): array
|
||||
{
|
||||
return [
|
||||
'tenant_package_id' => $tenantPackage->id,
|
||||
'limit' => $this->limit,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Notifications\Packages\TenantPackageEventThresholdNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -16,6 +17,7 @@ class SendTenantPackageEventThresholdWarning implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use LogsTenantNotifications;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
@@ -38,14 +40,22 @@ class SendTenantPackageEventThresholdWarning implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $tenantPackage->tenant;
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenantPackage->tenant, 'event_thresholds')) {
|
||||
if (! $preferences->shouldNotify($tenant, 'event_thresholds')) {
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'event_threshold',
|
||||
'status' => 'skipped',
|
||||
'context' => $this->context($tenantPackage),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenantPackage->tenant->contact_email,
|
||||
$tenantPackage->tenant->user?->email,
|
||||
$tenant->contact_email,
|
||||
$tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
@@ -55,16 +65,40 @@ class SendTenantPackageEventThresholdWarning implements ShouldQueue
|
||||
'threshold' => $this->threshold,
|
||||
]);
|
||||
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'event_threshold',
|
||||
'status' => 'skipped',
|
||||
'context' => array_merge($this->context($tenantPackage), ['reason' => 'no_recipient']),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$context = $this->context($tenantPackage);
|
||||
|
||||
$this->dispatchToRecipients(
|
||||
$tenant,
|
||||
$emails,
|
||||
'event_threshold',
|
||||
function (string $email) use ($tenantPackage) {
|
||||
Notification::route('mail', $email)->notify(new TenantPackageEventThresholdNotification(
|
||||
$tenantPackage,
|
||||
$this->threshold,
|
||||
$this->limit,
|
||||
$this->used,
|
||||
));
|
||||
},
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
private function context(TenantPackage $tenantPackage): array
|
||||
{
|
||||
return [
|
||||
'tenant_package_id' => $tenantPackage->id,
|
||||
'threshold' => $this->threshold,
|
||||
'limit' => $this->limit,
|
||||
'used' => $this->used,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Notifications\Packages\TenantPackageExpiredNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -16,6 +17,7 @@ class SendTenantPackageExpiredNotification implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use LogsTenantNotifications;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
@@ -33,14 +35,25 @@ class SendTenantPackageExpiredNotification implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $tenantPackage->tenant;
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenantPackage->tenant, 'package_expired')) {
|
||||
if (! $preferences->shouldNotify($tenant, 'package_expired')) {
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'package_expired',
|
||||
'status' => 'skipped',
|
||||
'context' => [
|
||||
'tenant_package_id' => $tenantPackage->id,
|
||||
'reason' => 'opt_out',
|
||||
],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenantPackage->tenant->contact_email,
|
||||
$tenantPackage->tenant->user?->email,
|
||||
$tenant->contact_email,
|
||||
$tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
@@ -49,11 +62,30 @@ class SendTenantPackageExpiredNotification implements ShouldQueue
|
||||
'tenant_package_id' => $tenantPackage->id,
|
||||
]);
|
||||
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'package_expired',
|
||||
'status' => 'skipped',
|
||||
'context' => [
|
||||
'tenant_package_id' => $tenantPackage->id,
|
||||
'reason' => 'no_recipient',
|
||||
],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$context = [
|
||||
'tenant_package_id' => $tenantPackage->id,
|
||||
];
|
||||
|
||||
$this->dispatchToRecipients(
|
||||
$tenant,
|
||||
$emails,
|
||||
'package_expired',
|
||||
function (string $email) use ($tenantPackage) {
|
||||
Notification::route('mail', $email)->notify(new TenantPackageExpiredNotification($tenantPackage));
|
||||
}
|
||||
},
|
||||
$context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Notifications\Packages\TenantPackageExpiringNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -16,6 +17,7 @@ class SendTenantPackageExpiringNotification implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use LogsTenantNotifications;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
@@ -36,14 +38,26 @@ class SendTenantPackageExpiringNotification implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $tenantPackage->tenant;
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenantPackage->tenant, 'package_expiring')) {
|
||||
if (! $preferences->shouldNotify($tenant, 'package_expiring')) {
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'package_expiring',
|
||||
'status' => 'skipped',
|
||||
'context' => [
|
||||
'tenant_package_id' => $tenantPackage->id,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
'reason' => 'opt_out',
|
||||
],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenantPackage->tenant->contact_email,
|
||||
$tenantPackage->tenant->user?->email,
|
||||
$tenant->contact_email,
|
||||
$tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
@@ -53,14 +67,35 @@ class SendTenantPackageExpiringNotification implements ShouldQueue
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
]);
|
||||
|
||||
$this->logNotification($tenant, [
|
||||
'type' => 'package_expiring',
|
||||
'status' => 'skipped',
|
||||
'context' => [
|
||||
'tenant_package_id' => $tenantPackage->id,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
'reason' => 'no_recipient',
|
||||
],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$context = [
|
||||
'tenant_package_id' => $tenantPackage->id,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
];
|
||||
|
||||
$this->dispatchToRecipients(
|
||||
$tenant,
|
||||
$emails,
|
||||
'package_expiring',
|
||||
function (string $email) use ($tenantPackage) {
|
||||
Notification::route('mail', $email)->notify(new TenantPackageExpiringNotification(
|
||||
$tenantPackage,
|
||||
$this->daysRemaining,
|
||||
));
|
||||
}
|
||||
},
|
||||
$context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@ class Tenant extends Model
|
||||
return $this->hasOne(TenantPackage::class)->where('active', true);
|
||||
}
|
||||
|
||||
public function notificationLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantNotificationLog::class);
|
||||
}
|
||||
|
||||
public function canCreateEvent(): bool
|
||||
{
|
||||
return $this->hasEventAllowance();
|
||||
|
||||
31
app/Models/TenantNotificationLog.php
Normal file
31
app/Models/TenantNotificationLog.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantNotificationLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'type',
|
||||
'channel',
|
||||
'recipient',
|
||||
'status',
|
||||
'context',
|
||||
'sent_at',
|
||||
'failed_at',
|
||||
'failure_reason',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'context' => 'array',
|
||||
'sent_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
37
app/Services/Packages/TenantNotificationLogger.php
Normal file
37
app/Services/Packages/TenantNotificationLogger.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Packages;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantNotificationLog;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TenantNotificationLogger
|
||||
{
|
||||
public function log(Tenant $tenant, array $attributes): TenantNotificationLog
|
||||
{
|
||||
$context = Arr::get($attributes, 'context', []);
|
||||
|
||||
$log = $tenant->notificationLogs()->create([
|
||||
'type' => $attributes['type'] ?? 'unknown',
|
||||
'channel' => $attributes['channel'] ?? 'mail',
|
||||
'recipient' => $attributes['recipient'] ?? null,
|
||||
'status' => $attributes['status'] ?? 'sent',
|
||||
'context' => $context,
|
||||
'sent_at' => $attributes['sent_at'] ?? now(),
|
||||
'failed_at' => $attributes['failed_at'] ?? null,
|
||||
'failure_reason' => $attributes['failure_reason'] ?? null,
|
||||
]);
|
||||
|
||||
Log::info('tenant_notification_log', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'log_id' => $log->id,
|
||||
'type' => $log->type,
|
||||
'status' => $log->status,
|
||||
'recipient' => $log->recipient,
|
||||
]);
|
||||
|
||||
return $log;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_notification_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('type');
|
||||
$table->string('channel', 20)->default('mail');
|
||||
$table->string('recipient')->nullable();
|
||||
$table->string('status', 20)->default('sent');
|
||||
$table->json('context')->nullable();
|
||||
$table->timestamp('sent_at')->nullable();
|
||||
$table->timestamp('failed_at')->nullable();
|
||||
$table->string('failure_reason')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['tenant_id', 'type']);
|
||||
$table->index(['tenant_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenant_notification_logs');
|
||||
}
|
||||
};
|
||||
@@ -17,6 +17,7 @@ Key Endpoints (abridged)
|
||||
- Emotions & Tasks: list, tenant overrides; task library scoping.
|
||||
- Purchases & Ledger: create purchase intent, webhook ingest, ledger list.
|
||||
- Settings: read/update tenant theme, limits, legal page links.
|
||||
- Notifications: `GET /api/v1/tenant/notifications/logs` (filterable by `type` / `status`) exposes recent package/limit alerts with timestamps and retry context.
|
||||
|
||||
Guest Polling (no WebSockets in v1)
|
||||
- GET `/events/{token}/stats` — lightweight counters for Home info bar.
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
- [x] Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).
|
||||
- [x] Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
|
||||
- [x] Galerie-Countdown/Badge für Ablaufdatum + Call-to-Action.
|
||||
- [ ] E2E-Tests für Limitwarnungen & abgelaufene Galerie aktualisieren.
|
||||
- [x] E2E-Tests für Limitwarnungen & abgelaufene Galerie aktualisieren.
|
||||
|
||||
### 4. Tenant Admin PWA Improvements
|
||||
- [x] Dashboard-Karten & Event-Header mit Ampelsystem für Limitfortschritt.
|
||||
@@ -44,10 +44,10 @@
|
||||
|
||||
- [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] 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.
|
||||
- [x] In-App/Toast-Benachrichtigungen für Admin UI (und optional Push/Slack intern).
|
||||
- [x] Audit-Log & Retry-Logik für gesendete Mails.
|
||||
|
||||
### 6. Monitoring, Docs & Support
|
||||
- [x] Prometheus/Grafana-Metriken für Paketnutzung & Warns triggern. *(`PackageLimitMetrics` + `php artisan metrics:package-limits` Snapshot)*
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -16,6 +16,7 @@ import {
|
||||
Package as PackageIcon,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -205,6 +206,23 @@ export default function DashboardPage() {
|
||||
[primaryEventLimits, limitTranslate],
|
||||
);
|
||||
|
||||
const shownToastsRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
limitWarnings.forEach((warning) => {
|
||||
const toastKey = `${warning.id}-${warning.message}`;
|
||||
if (shownToastsRef.current.has(toastKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
shownToastsRef.current.add(toastKey);
|
||||
toast(warning.message, {
|
||||
icon: warning.tone === 'danger' ? '🚨' : '⚠️',
|
||||
id: toastKey,
|
||||
});
|
||||
});
|
||||
}, [limitWarnings]);
|
||||
|
||||
const limitScopeLabels = React.useMemo(
|
||||
() => ({
|
||||
photos: tc('limits.photosTitle'),
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Sparkles,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -216,6 +217,23 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
[event?.limits, tCommon],
|
||||
);
|
||||
|
||||
const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
limitWarnings.forEach((warning) => {
|
||||
const id = `${warning.id}-${warning.message}`;
|
||||
if (shownWarningToasts.current.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
shownWarningToasts.current.add(id);
|
||||
toast(warning.message, {
|
||||
icon: warning.tone === 'danger' ? '🚨' : '⚠️',
|
||||
id,
|
||||
});
|
||||
});
|
||||
}, [limitWarnings]);
|
||||
|
||||
return (
|
||||
<AdminLayout title={eventName} subtitle={subtitle} actions={actions}>
|
||||
{error && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
@@ -203,6 +204,23 @@ export default function EventFormPage() {
|
||||
return buildLimitWarnings(loadedEvent?.limits, tLimits);
|
||||
}, [isEdit, loadedEvent?.limits, tLimits]);
|
||||
|
||||
const shownToastRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
limitWarnings.forEach((warning) => {
|
||||
const key = `${warning.id}-${warning.message}`;
|
||||
if (shownToastRef.current.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
shownToastRef.current.add(key);
|
||||
toast(warning.message, {
|
||||
icon: warning.tone === 'danger' ? '🚨' : '⚠️',
|
||||
id: key,
|
||||
});
|
||||
});
|
||||
}, [limitWarnings]);
|
||||
|
||||
const limitScopeLabels = React.useMemo(() => ({
|
||||
photos: tLimits('photosTitle'),
|
||||
guests: tLimits('guestsTitle'),
|
||||
|
||||
@@ -622,6 +622,8 @@ export function InviteLayoutCustomizerPanel({
|
||||
if (!invite || !activeLayout) {
|
||||
setForm({});
|
||||
setInstructions([]);
|
||||
commitElements(() => [], { silent: true });
|
||||
resetHistory([]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -635,7 +637,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
setInstructions(baseInstructions);
|
||||
|
||||
setForm({
|
||||
const newForm: QrLayoutCustomization = {
|
||||
layout_id: activeLayout.id,
|
||||
headline: reuseCustomization ? initialCustomization?.headline ?? eventName : eventName,
|
||||
subtitle: reuseCustomization ? initialCustomization?.subtitle ?? activeLayout.subtitle ?? '' : activeLayout.subtitle ?? '',
|
||||
@@ -652,53 +654,35 @@ export function InviteLayoutCustomizerPanel({
|
||||
badge_color: reuseCustomization ? initialCustomization?.badge_color ?? '#2563EB' : '#2563EB',
|
||||
background_gradient: reuseCustomization ? initialCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null : activeLayout.preview?.background_gradient ?? null,
|
||||
logo_data_url: reuseCustomization ? initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null : null,
|
||||
});
|
||||
mode: initialCustomization?.layout_id === activeLayout.id ? initialCustomization?.mode : 'standard',
|
||||
elements: initialCustomization?.layout_id === activeLayout.id ? initialCustomization?.elements : undefined,
|
||||
};
|
||||
setForm(newForm);
|
||||
setError(null);
|
||||
}, [invite?.id, activeLayout?.id, defaultInstructions, initialCustomization, eventName, inviteUrl, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeLayout) {
|
||||
const cleared: LayoutElement[] = [];
|
||||
commitElements(() => cleared, { silent: true });
|
||||
resetHistory(cleared);
|
||||
return;
|
||||
}
|
||||
const isCustomizedAdvanced = newForm.mode === 'advanced' && Array.isArray(newForm.elements) && newForm.elements.length > 0;
|
||||
|
||||
const layoutKey = activeLayout.id ?? '__default';
|
||||
const inviteKey = invite?.id ?? null;
|
||||
if (prevInviteRef.current !== inviteKey) {
|
||||
initializedLayoutsRef.current = {};
|
||||
prevInviteRef.current = inviteKey;
|
||||
}
|
||||
|
||||
if (!initializedLayoutsRef.current[layoutKey]) {
|
||||
if (initialCustomization?.mode === 'advanced' && Array.isArray(initialCustomization.elements) && initialCustomization.elements.length) {
|
||||
const initialElements = normalizeElements(payloadToElements(initialCustomization.elements));
|
||||
if (isCustomizedAdvanced) {
|
||||
const initialElements = normalizeElements(payloadToElements(newForm.elements));
|
||||
commitElements(() => initialElements, { silent: true });
|
||||
resetHistory(initialElements);
|
||||
} else {
|
||||
const defaults = buildDefaultElements(activeLayout, formStateRef.current, eventName, activeLayoutQrSize);
|
||||
const defaults = buildDefaultElements(activeLayout, newForm, eventName, activeLayoutQrSize);
|
||||
commitElements(() => defaults, { silent: true });
|
||||
resetHistory(defaults);
|
||||
}
|
||||
initializedLayoutsRef.current[layoutKey] = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (historyIndexRef.current === -1 && elements.length > 0) {
|
||||
resetHistory(cloneElements(elements));
|
||||
}
|
||||
setActiveElementId(null);
|
||||
}, [
|
||||
activeLayout,
|
||||
invite?.id,
|
||||
initialCustomization,
|
||||
defaultInstructions,
|
||||
eventName,
|
||||
inviteUrl,
|
||||
t,
|
||||
activeLayoutQrSize,
|
||||
initialCustomization?.mode,
|
||||
initialCustomization?.elements,
|
||||
commitElements,
|
||||
resetHistory,
|
||||
elements,
|
||||
cloneElements,
|
||||
eventName,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -756,6 +740,17 @@ export function InviteLayoutCustomizerPanel({
|
||||
hasCustomization: Boolean(initialCustomization?.elements?.length),
|
||||
});
|
||||
|
||||
// Erweiterter Log für Duplikate-Check
|
||||
const idCounts = base.reduce((acc, e) => {
|
||||
acc[e.id] = (acc[e.id] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
const duplicates = Object.entries(idCounts).filter(([_, count]) => count > 1);
|
||||
if (duplicates.length > 0) {
|
||||
console.warn('[Invites][CanvasElements] Duplicates detected in base', { duplicates, baseIds: base.map(e => ({ id: e.id, type: e.type, y: e.y })) });
|
||||
}
|
||||
console.debug('[Invites][CanvasElements] Base IDs overview', base.map(e => ({ id: e.id, type: e.type, y: e.y })));
|
||||
|
||||
const boundContent: Record<string, string | null> = {
|
||||
headline: form.headline ?? eventName,
|
||||
subtitle: form.subtitle ?? '',
|
||||
@@ -783,7 +778,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
};
|
||||
});
|
||||
}, [
|
||||
activeLayout,
|
||||
activeLayout?.id,
|
||||
elements,
|
||||
form.headline,
|
||||
form.subtitle,
|
||||
@@ -794,7 +789,6 @@ export function InviteLayoutCustomizerPanel({
|
||||
eventName,
|
||||
inviteUrl,
|
||||
t,
|
||||
activeLayout,
|
||||
activeLayoutQrSize,
|
||||
]);
|
||||
|
||||
@@ -1254,21 +1248,6 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
function handleLayoutSelect(layout: EventQrInviteLayout) {
|
||||
setSelectedLayoutId(layout.id);
|
||||
updateForm('layout_id', layout.id);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
accent_color: sanitizeColor(layout.preview?.accent ?? prev.accent_color ?? null) ?? '#6366F1',
|
||||
text_color: sanitizeColor(layout.preview?.text ?? prev.text_color ?? null) ?? '#111827',
|
||||
background_color: sanitizeColor(layout.preview?.background ?? prev.background_color ?? null) ?? '#FFFFFF',
|
||||
secondary_color: '#1F2937',
|
||||
badge_color: '#2563EB',
|
||||
background_gradient: layout.preview?.background_gradient ?? null,
|
||||
}));
|
||||
setInstructions((layout.instructions ?? []).length ? [...(layout.instructions as string[])] : [...defaultInstructions]);
|
||||
const defaults = buildDefaultElements(layout, formStateRef.current, eventName, layout.preview?.qr_size_px ?? activeLayoutQrSize);
|
||||
commitElements(() => defaults, { silent: true });
|
||||
resetHistory(defaults);
|
||||
setActiveElementId(null);
|
||||
}
|
||||
|
||||
function handleInstructionChange(index: number, value: string) {
|
||||
|
||||
@@ -450,7 +450,7 @@ export async function renderFabricLayout(
|
||||
} = options;
|
||||
|
||||
canvas.discardActiveObject();
|
||||
canvas.getObjects().forEach((object) => canvas.remove(object));
|
||||
canvas.clear();
|
||||
|
||||
applyBackground(canvas, backgroundColor, backgroundGradient);
|
||||
|
||||
@@ -461,6 +461,7 @@ export async function renderFabricLayout(
|
||||
readOnly,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
const objectPromises = elements.map((element) =>
|
||||
createFabricObject({
|
||||
element,
|
||||
@@ -471,10 +472,11 @@ export async function renderFabricLayout(
|
||||
qrCodeDataUrl,
|
||||
logoDataUrl,
|
||||
readOnly,
|
||||
}),
|
||||
}, abortController.signal),
|
||||
);
|
||||
|
||||
const fabricObjects = await Promise.all(objectPromises);
|
||||
abortController.abort(); // Abort any pending loads
|
||||
console.debug('[Invites][Fabric] resolved objects', {
|
||||
count: fabricObjects.length,
|
||||
nulls: fabricObjects.filter((obj) => !obj).length,
|
||||
@@ -765,8 +767,9 @@ export async function loadImageObject(
|
||||
element: LayoutElement,
|
||||
baseConfig: FabricObjectWithId,
|
||||
options?: { objectFit?: 'contain' | 'cover'; shadow?: string; padding?: number },
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<fabric.Object | null> {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
const resolveSafely = (value: fabric.Object | null) => {
|
||||
if (resolved) {
|
||||
@@ -779,7 +782,7 @@ export async function loadImageObject(
|
||||
const isDataUrl = source.startsWith('data:');
|
||||
|
||||
const onImageLoaded = (img?: HTMLImageElement | HTMLCanvasElement | null) => {
|
||||
if (!img) {
|
||||
if (!img || resolved) {
|
||||
console.warn('[Invites][Fabric] image load returned empty', { source });
|
||||
resolveSafely(null);
|
||||
return;
|
||||
@@ -819,14 +822,26 @@ export async function loadImageObject(
|
||||
};
|
||||
|
||||
const onError = (error?: unknown) => {
|
||||
if (resolved) return;
|
||||
console.warn('[Invites][Fabric] failed to load image', source, error);
|
||||
resolveSafely(null);
|
||||
};
|
||||
|
||||
const abortHandler = () => {
|
||||
if (resolved) return;
|
||||
console.debug('[Invites][Fabric] Image load aborted', { source });
|
||||
resolveSafely(null);
|
||||
};
|
||||
|
||||
if (abortSignal) {
|
||||
abortSignal.addEventListener('abort', abortHandler);
|
||||
}
|
||||
|
||||
try {
|
||||
if (isDataUrl) {
|
||||
const imageElement = new Image();
|
||||
imageElement.onload = () => {
|
||||
if (resolved) return;
|
||||
console.debug('[Invites][Fabric] image loaded (data-url)', {
|
||||
source: source.slice(0, 48),
|
||||
width: imageElement.naturalWidth,
|
||||
@@ -840,6 +855,7 @@ export async function loadImageObject(
|
||||
// Use direct Image constructor approach for better compatibility
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (resolved) return;
|
||||
console.debug('[Invites][Fabric] image loaded', {
|
||||
source: source.slice(0, 48),
|
||||
width: img.width,
|
||||
@@ -854,7 +870,17 @@ export async function loadImageObject(
|
||||
onError(error);
|
||||
}
|
||||
|
||||
window.setTimeout(() => resolveSafely(null), 3000);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (resolved) return;
|
||||
resolveSafely(null);
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\Tenant\EventController;
|
||||
use App\Http\Controllers\Api\Tenant\EventJoinTokenController;
|
||||
use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController;
|
||||
use App\Http\Controllers\Api\Tenant\EventTypeController;
|
||||
use App\Http\Controllers\Api\Tenant\NotificationLogController;
|
||||
use App\Http\Controllers\Api\Tenant\PhotoController;
|
||||
use App\Http\Controllers\Api\Tenant\SettingsController;
|
||||
use App\Http\Controllers\Api\Tenant\TaskCollectionController;
|
||||
@@ -145,6 +146,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
->name('tenant.settings.notifications.update');
|
||||
});
|
||||
|
||||
Route::get('notifications/logs', [NotificationLogController::class, 'index'])
|
||||
->name('tenant.notifications.logs.index');
|
||||
|
||||
Route::prefix('credits')->group(function () {
|
||||
Route::get('balance', [CreditController::class, 'balance'])->name('tenant.credits.balance');
|
||||
Route::get('ledger', [CreditController::class, 'ledger'])->name('tenant.credits.ledger');
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Packages;
|
||||
|
||||
use App\Jobs\Packages\SendTenantCreditsLowNotification;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\Packages\TenantCreditsLowNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SendTenantCreditsLowNotificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_logs_successful_notification(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'contact_email' => 'admin@example.com',
|
||||
]);
|
||||
|
||||
$job = new SendTenantCreditsLowNotification($tenant->id, balance: 5, threshold: 10);
|
||||
$job->handle();
|
||||
|
||||
Notification::assertSentOnDemand(TenantCreditsLowNotification::class, function ($notification, $channels, $notifiable) {
|
||||
return in_array('mail', $channels, true) && ($notifiable->routes['mail'] ?? null) === 'admin@example.com';
|
||||
});
|
||||
|
||||
$tenant->refresh();
|
||||
|
||||
$this->assertCount(1, $tenant->notificationLogs);
|
||||
$log = $tenant->notificationLogs()->first();
|
||||
$this->assertSame('credits_low', $log->type);
|
||||
$this->assertSame('sent', $log->status);
|
||||
$this->assertSame('admin@example.com', $log->recipient);
|
||||
$this->assertNotNull($log->sent_at);
|
||||
}
|
||||
}
|
||||
209
tests/e2e/guest-limit-experience.test.ts
Normal file
209
tests/e2e/guest-limit-experience.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const EVENT_TOKEN = 'limit-event';
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
test.describe('Guest PWA limit experiences', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(
|
||||
({ token }) => {
|
||||
try {
|
||||
window.localStorage.setItem(`guestName_${token}`, 'Playwright Gast');
|
||||
window.localStorage.setItem(`guestCameraPrimerDismissed_${token}`, '1');
|
||||
} catch (error) {
|
||||
console.warn('Failed to seed guest storage', error);
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices) {
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
}
|
||||
|
||||
navigator.mediaDevices.getUserMedia = () => Promise.resolve(new MediaStream());
|
||||
},
|
||||
{ token: EVENT_TOKEN }
|
||||
);
|
||||
|
||||
const timestamp = nowIso();
|
||||
|
||||
await page.route(`**/api/v1/events/${EVENT_TOKEN}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 1,
|
||||
slug: EVENT_TOKEN,
|
||||
name: 'Limit Experience Event',
|
||||
default_locale: 'de',
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/v1/events/${EVENT_TOKEN}/tasks`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
title: 'Playwright Mission',
|
||||
description: 'Test mission for upload limits',
|
||||
instructions: 'Mach ein Testfoto',
|
||||
duration: 2,
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/v1/events/${EVENT_TOKEN}/stats`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
online_guests: 5,
|
||||
tasks_solved: 12,
|
||||
latest_photo_at: timestamp,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/v1/events/${EVENT_TOKEN}/photos**`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 101,
|
||||
file_path: '/photos/101.jpg',
|
||||
thumbnail_path: '/photos/101-thumb.jpg',
|
||||
created_at: timestamp,
|
||||
likes_count: 3,
|
||||
},
|
||||
],
|
||||
latest_photo_at: timestamp,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('shows limit warnings and countdown before limits are reached', async ({ page }) => {
|
||||
const expiresAt = new Date(Date.now() + 2 * 86_400_000).toISOString();
|
||||
|
||||
await page.route(`**/api/v1/events/${EVENT_TOKEN}/package`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 42,
|
||||
event_id: 1,
|
||||
used_photos: 95,
|
||||
expires_at: expiresAt,
|
||||
package: {
|
||||
id: 77,
|
||||
name: 'Starter',
|
||||
max_photos: 100,
|
||||
max_guests: 150,
|
||||
gallery_days: 30,
|
||||
},
|
||||
limits: {
|
||||
photos: {
|
||||
limit: 100,
|
||||
used: 95,
|
||||
remaining: 5,
|
||||
percentage: 95,
|
||||
state: 'warning',
|
||||
threshold_reached: 95,
|
||||
next_threshold: 100,
|
||||
thresholds: [80, 95, 100],
|
||||
},
|
||||
guests: null,
|
||||
gallery: {
|
||||
state: 'warning',
|
||||
expires_at: expiresAt,
|
||||
days_remaining: 2,
|
||||
warning_thresholds: [7, 1],
|
||||
warning_triggered: 2,
|
||||
warning_sent_at: null,
|
||||
expired_notified_at: null,
|
||||
},
|
||||
can_upload_photos: true,
|
||||
can_add_guests: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/e/${EVENT_TOKEN}/upload?task=1`);
|
||||
await expect(page.getByText(/Nur noch 5 von 100 Fotos möglich/i)).toBeVisible();
|
||||
await expect(page.getByText(/Galerie läuft in 2 Tagen ab/i)).toBeVisible();
|
||||
|
||||
await page.goto(`/e/${EVENT_TOKEN}/gallery`);
|
||||
await expect(page.getByText(/Noch 2 Tage online/i)).toBeVisible();
|
||||
await expect(page.getByText(/Nur noch 5 von 100 Fotos möglich/i)).toBeVisible();
|
||||
await expect(page.getByText(/Galerie läuft in 2 Tagen ab/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Letzte Fotos hochladen/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('marks uploads as blocked and highlights expired gallery state', async ({ page }) => {
|
||||
const expiredAt = new Date(Date.now() - 86_400_000).toISOString();
|
||||
|
||||
await page.route(`**/api/v1/events/${EVENT_TOKEN}/package`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 43,
|
||||
event_id: 1,
|
||||
used_photos: 100,
|
||||
expires_at: expiredAt,
|
||||
package: {
|
||||
id: 77,
|
||||
name: 'Starter',
|
||||
max_photos: 100,
|
||||
max_guests: 150,
|
||||
gallery_days: 30,
|
||||
},
|
||||
limits: {
|
||||
photos: {
|
||||
limit: 100,
|
||||
used: 100,
|
||||
remaining: 0,
|
||||
percentage: 100,
|
||||
state: 'limit_reached',
|
||||
threshold_reached: 100,
|
||||
next_threshold: null,
|
||||
thresholds: [80, 95, 100],
|
||||
},
|
||||
guests: null,
|
||||
gallery: {
|
||||
state: 'expired',
|
||||
expires_at: expiredAt,
|
||||
days_remaining: 0,
|
||||
warning_thresholds: [7, 1],
|
||||
warning_triggered: 0,
|
||||
warning_sent_at: null,
|
||||
expired_notified_at: expiredAt,
|
||||
},
|
||||
can_upload_photos: false,
|
||||
can_add_guests: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/e/${EVENT_TOKEN}/upload?task=1`);
|
||||
await expect(page.getByText(/Upload-Limit erreicht/i)).toBeVisible();
|
||||
|
||||
await page.goto(`/e/${EVENT_TOKEN}/gallery`);
|
||||
await expect(page.getByText(/Galerie abgelaufen/i)).toBeVisible();
|
||||
await expect(page.getByText(/Die Galerie ist abgelaufen\. Uploads sind nicht mehr möglich\./i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user