verbesserung von benachrichtungen und warnungen an nutzer abgeschlossen. layout editor nun auf gutem stand.

This commit is contained in:
Codex Agent
2025-11-02 11:11:13 +01:00
parent 8e6c66f0db
commit 792b5dfe8b
32 changed files with 1292 additions and 149 deletions

View 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;
}
}

View File

@@ -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(),
],
]);
}
}

View 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(),
]);
}
}
}
}

View File

@@ -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) {
Notification::route('mail', $email)->notify(new EventPackageGalleryExpiredNotification($eventPackage));
}
$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,
];
}
}

View File

@@ -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) {
Notification::route('mail', $email)->notify(new EventPackageGalleryExpiringNotification(
$eventPackage,
$this->daysRemaining,
));
}
$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,
];
}
}

View File

@@ -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) {
Notification::route('mail', $email)->notify(new EventPackageGuestLimitNotification(
$eventPackage,
$this->limit,
));
}
$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,
];
}
}

View File

@@ -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) {
Notification::route('mail', $email)->notify(new EventPackageGuestThresholdNotification(
$eventPackage,
$this->threshold,
$this->limit,
$this->used,
));
}
$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,
];
}
}

View File

@@ -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) {
Notification::route('mail', $email)->notify(
new EventPackagePhotoLimitNotification(
$eventPackage,
$this->limit,
)
);
}
$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,
];
}
}

View File

@@ -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,18 +68,43 @@ 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) {
Notification::route('mail', $email)->notify(
new EventPackagePhotoThresholdNotification(
$eventPackage,
$this->threshold,
$this->limit,
$this->used,
)
);
}
$context = $this->context($eventPackage);
$this->dispatchToRecipients(
$tenant,
$emails,
'photo_threshold',
function (string $email) use ($eventPackage) {
Notification::route('mail', $email)->notify(
new EventPackagePhotoThresholdNotification(
$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,
];
}
}

View File

@@ -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) {
Notification::route('mail', $email)->notify(new TenantCreditsLowNotification(
$tenant,
$this->balance,
$this->threshold,
));
}
$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
);
}
}

View File

@@ -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) {
Notification::route('mail', $email)->notify(new TenantPackageEventLimitNotification(
$tenantPackage,
$this->limit,
));
}
$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,
];
}
}

View File

@@ -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) {
Notification::route('mail', $email)->notify(new TenantPackageEventThresholdNotification(
$tenantPackage,
$this->threshold,
$this->limit,
$this->used,
));
}
$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,
];
}
}

View File

@@ -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) {
Notification::route('mail', $email)->notify(new TenantPackageExpiredNotification($tenantPackage));
}
$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
);
}
}

View File

@@ -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) {
Notification::route('mail', $email)->notify(new TenantPackageExpiringNotification(
$tenantPackage,
$this->daysRemaining,
));
}
$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
);
}
}

View File

@@ -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();

View 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);
}
}

View 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;
}
}

View File

@@ -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');
}
};

View File

@@ -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.

View File

@@ -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.

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

View File

@@ -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'),

View File

@@ -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 && (

View File

@@ -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'),

View File

@@ -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 (isCustomizedAdvanced) {
const initialElements = normalizeElements(payloadToElements(newForm.elements));
commitElements(() => initialElements, { silent: true });
resetHistory(initialElements);
} else {
const defaults = buildDefaultElements(activeLayout, newForm, eventName, activeLayoutQrSize);
commitElements(() => defaults, { silent: true });
resetHistory(defaults);
}
if (!initializedLayoutsRef.current[layoutKey]) {
if (initialCustomization?.mode === 'advanced' && Array.isArray(initialCustomization.elements) && initialCustomization.elements.length) {
const initialElements = normalizeElements(payloadToElements(initialCustomization.elements));
commitElements(() => initialElements, { silent: true });
resetHistory(initialElements);
} else {
const defaults = buildDefaultElements(activeLayout, formStateRef.current, 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) {

View File

@@ -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);
}
};
});
}

View File

@@ -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');

View File

@@ -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);
}
}

View 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();
});
});