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;
|
namespace App\Jobs\Packages;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||||
use App\Models\EventPackage;
|
use App\Models\EventPackage;
|
||||||
use App\Notifications\Packages\EventPackageGalleryExpiredNotification;
|
use App\Notifications\Packages\EventPackageGalleryExpiredNotification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -16,6 +17,7 @@ class SendEventPackageGalleryExpired implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
use LogsTenantNotifications;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
@@ -40,6 +42,12 @@ class SendEventPackageGalleryExpired implements ShouldQueue
|
|||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'gallery_expired')) {
|
if (! $preferences->shouldNotify($tenant, 'gallery_expired')) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'gallery_expired',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => $this->context($eventPackage),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,11 +62,33 @@ class SendEventPackageGalleryExpired implements ShouldQueue
|
|||||||
'event_package_id' => $eventPackage->id,
|
'event_package_id' => $eventPackage->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'gallery_expired',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($emails as $email) {
|
$context = $this->context($eventPackage);
|
||||||
Notification::route('mail', $email)->notify(new EventPackageGalleryExpiredNotification($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;
|
namespace App\Jobs\Packages;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||||
use App\Models\EventPackage;
|
use App\Models\EventPackage;
|
||||||
use App\Notifications\Packages\EventPackageGalleryExpiringNotification;
|
use App\Notifications\Packages\EventPackageGalleryExpiringNotification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -16,6 +17,7 @@ class SendEventPackageGalleryWarning implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
use LogsTenantNotifications;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
@@ -43,6 +45,12 @@ class SendEventPackageGalleryWarning implements ShouldQueue
|
|||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'gallery_warnings')) {
|
if (! $preferences->shouldNotify($tenant, 'gallery_warnings')) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'gallery_warning',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => $this->context($eventPackage),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,14 +66,37 @@ class SendEventPackageGalleryWarning implements ShouldQueue
|
|||||||
'days_remaining' => $this->daysRemaining,
|
'days_remaining' => $this->daysRemaining,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'gallery_warning',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($emails as $email) {
|
$context = $this->context($eventPackage);
|
||||||
Notification::route('mail', $email)->notify(new EventPackageGalleryExpiringNotification(
|
|
||||||
$eventPackage,
|
$this->dispatchToRecipients(
|
||||||
$this->daysRemaining,
|
$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;
|
namespace App\Jobs\Packages;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||||
use App\Models\EventPackage;
|
use App\Models\EventPackage;
|
||||||
use App\Notifications\Packages\EventPackageGuestLimitNotification;
|
use App\Notifications\Packages\EventPackageGuestLimitNotification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -16,6 +17,7 @@ class SendEventPackageGuestLimitNotification implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
use LogsTenantNotifications;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
@@ -43,6 +45,12 @@ class SendEventPackageGuestLimitNotification implements ShouldQueue
|
|||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'guest_limits')) {
|
if (! $preferences->shouldNotify($tenant, 'guest_limits')) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'guest_limit',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => $this->context($eventPackage),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,14 +65,37 @@ class SendEventPackageGuestLimitNotification implements ShouldQueue
|
|||||||
'event_package_id' => $eventPackage->id,
|
'event_package_id' => $eventPackage->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'guest_limit',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($emails as $email) {
|
$context = $this->context($eventPackage);
|
||||||
Notification::route('mail', $email)->notify(new EventPackageGuestLimitNotification(
|
|
||||||
$eventPackage,
|
$this->dispatchToRecipients(
|
||||||
$this->limit,
|
$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;
|
namespace App\Jobs\Packages;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||||
use App\Models\EventPackage;
|
use App\Models\EventPackage;
|
||||||
use App\Notifications\Packages\EventPackageGuestThresholdNotification;
|
use App\Notifications\Packages\EventPackageGuestThresholdNotification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -16,6 +17,7 @@ class SendEventPackageGuestThresholdWarning implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
use LogsTenantNotifications;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
@@ -45,6 +47,12 @@ class SendEventPackageGuestThresholdWarning implements ShouldQueue
|
|||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'guest_thresholds')) {
|
if (! $preferences->shouldNotify($tenant, 'guest_thresholds')) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'guest_threshold',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => $this->context($eventPackage),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,16 +68,41 @@ class SendEventPackageGuestThresholdWarning implements ShouldQueue
|
|||||||
'threshold' => $this->threshold,
|
'threshold' => $this->threshold,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'guest_threshold',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($emails as $email) {
|
$context = $this->context($eventPackage);
|
||||||
Notification::route('mail', $email)->notify(new EventPackageGuestThresholdNotification(
|
|
||||||
$eventPackage,
|
$this->dispatchToRecipients(
|
||||||
$this->threshold,
|
$tenant,
|
||||||
$this->limit,
|
$emails,
|
||||||
$this->used,
|
'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;
|
namespace App\Jobs\Packages;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||||
use App\Models\EventPackage;
|
use App\Models\EventPackage;
|
||||||
use App\Notifications\Packages\EventPackagePhotoLimitNotification;
|
use App\Notifications\Packages\EventPackagePhotoLimitNotification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -16,6 +17,7 @@ class SendEventPackagePhotoLimitNotification implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
use LogsTenantNotifications;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
@@ -43,6 +45,12 @@ class SendEventPackagePhotoLimitNotification implements ShouldQueue
|
|||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'photo_limits')) {
|
if (! $preferences->shouldNotify($tenant, 'photo_limits')) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'photo_limit',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => $this->context($eventPackage),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,16 +66,39 @@ class SendEventPackagePhotoLimitNotification implements ShouldQueue
|
|||||||
'limit' => $this->limit,
|
'limit' => $this->limit,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'photo_limit',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($emails as $email) {
|
$context = $this->context($eventPackage);
|
||||||
Notification::route('mail', $email)->notify(
|
|
||||||
new EventPackagePhotoLimitNotification(
|
$this->dispatchToRecipients(
|
||||||
$eventPackage,
|
$tenant,
|
||||||
$this->limit,
|
$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;
|
namespace App\Jobs\Packages;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||||
use App\Models\EventPackage;
|
use App\Models\EventPackage;
|
||||||
use App\Notifications\Packages\EventPackagePhotoThresholdNotification;
|
use App\Notifications\Packages\EventPackagePhotoThresholdNotification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -16,6 +17,7 @@ class SendEventPackagePhotoThresholdWarning implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
use LogsTenantNotifications;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
@@ -45,6 +47,12 @@ class SendEventPackagePhotoThresholdWarning implements ShouldQueue
|
|||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'photo_thresholds')) {
|
if (! $preferences->shouldNotify($tenant, 'photo_thresholds')) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'photo_threshold',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => $this->context($eventPackage),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,18 +68,43 @@ class SendEventPackagePhotoThresholdWarning implements ShouldQueue
|
|||||||
'threshold' => $this->threshold,
|
'threshold' => $this->threshold,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'photo_threshold',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($emails as $email) {
|
$context = $this->context($eventPackage);
|
||||||
Notification::route('mail', $email)->notify(
|
|
||||||
new EventPackagePhotoThresholdNotification(
|
$this->dispatchToRecipients(
|
||||||
$eventPackage,
|
$tenant,
|
||||||
$this->threshold,
|
$emails,
|
||||||
$this->limit,
|
'photo_threshold',
|
||||||
$this->used,
|
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,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Jobs\Packages;
|
namespace App\Jobs\Packages;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Notifications\Packages\TenantCreditsLowNotification;
|
use App\Notifications\Packages\TenantCreditsLowNotification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -16,6 +17,7 @@ class SendTenantCreditsLowNotification implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
use LogsTenantNotifications;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
@@ -39,6 +41,16 @@ class SendTenantCreditsLowNotification implements ShouldQueue
|
|||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'credits_low')) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,15 +65,36 @@ class SendTenantCreditsLowNotification implements ShouldQueue
|
|||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'credits_low',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => [
|
||||||
|
'balance' => $this->balance,
|
||||||
|
'threshold' => $this->threshold,
|
||||||
|
'reason' => 'no_recipient',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($emails as $email) {
|
$context = [
|
||||||
Notification::route('mail', $email)->notify(new TenantCreditsLowNotification(
|
'balance' => $this->balance,
|
||||||
$tenant,
|
'threshold' => $this->threshold,
|
||||||
$this->balance,
|
];
|
||||||
$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;
|
namespace App\Jobs\Packages;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
use App\Notifications\Packages\TenantPackageEventLimitNotification;
|
use App\Notifications\Packages\TenantPackageEventLimitNotification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -16,6 +17,7 @@ class SendTenantPackageEventLimitNotification implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
use LogsTenantNotifications;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
@@ -36,14 +38,22 @@ class SendTenantPackageEventLimitNotification implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenant = $tenantPackage->tenant;
|
||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$emails = collect([
|
$emails = collect([
|
||||||
$tenantPackage->tenant->contact_email,
|
$tenant->contact_email,
|
||||||
$tenantPackage->tenant->user?->email,
|
$tenant->user?->email,
|
||||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||||
->unique();
|
->unique();
|
||||||
|
|
||||||
@@ -52,14 +62,36 @@ class SendTenantPackageEventLimitNotification implements ShouldQueue
|
|||||||
'tenant_package_id' => $tenantPackage->id,
|
'tenant_package_id' => $tenantPackage->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'event_limit',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($this->context($tenantPackage), ['reason' => 'no_recipient']),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($emails as $email) {
|
$context = $this->context($tenantPackage);
|
||||||
Notification::route('mail', $email)->notify(new TenantPackageEventLimitNotification(
|
|
||||||
$tenantPackage,
|
$this->dispatchToRecipients(
|
||||||
$this->limit,
|
$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;
|
namespace App\Jobs\Packages;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
use App\Notifications\Packages\TenantPackageEventThresholdNotification;
|
use App\Notifications\Packages\TenantPackageEventThresholdNotification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -16,6 +17,7 @@ class SendTenantPackageEventThresholdWarning implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
use LogsTenantNotifications;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
@@ -38,14 +40,22 @@ class SendTenantPackageEventThresholdWarning implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenant = $tenantPackage->tenant;
|
||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$emails = collect([
|
$emails = collect([
|
||||||
$tenantPackage->tenant->contact_email,
|
$tenant->contact_email,
|
||||||
$tenantPackage->tenant->user?->email,
|
$tenant->user?->email,
|
||||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||||
->unique();
|
->unique();
|
||||||
|
|
||||||
@@ -55,16 +65,40 @@ class SendTenantPackageEventThresholdWarning implements ShouldQueue
|
|||||||
'threshold' => $this->threshold,
|
'threshold' => $this->threshold,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'event_threshold',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($this->context($tenantPackage), ['reason' => 'no_recipient']),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($emails as $email) {
|
$context = $this->context($tenantPackage);
|
||||||
Notification::route('mail', $email)->notify(new TenantPackageEventThresholdNotification(
|
|
||||||
$tenantPackage,
|
$this->dispatchToRecipients(
|
||||||
$this->threshold,
|
$tenant,
|
||||||
$this->limit,
|
$emails,
|
||||||
$this->used,
|
'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;
|
namespace App\Jobs\Packages;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
use App\Notifications\Packages\TenantPackageExpiredNotification;
|
use App\Notifications\Packages\TenantPackageExpiredNotification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -16,6 +17,7 @@ class SendTenantPackageExpiredNotification implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
use LogsTenantNotifications;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
@@ -33,14 +35,25 @@ class SendTenantPackageExpiredNotification implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenant = $tenantPackage->tenant;
|
||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$emails = collect([
|
$emails = collect([
|
||||||
$tenantPackage->tenant->contact_email,
|
$tenant->contact_email,
|
||||||
$tenantPackage->tenant->user?->email,
|
$tenant->user?->email,
|
||||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||||
->unique();
|
->unique();
|
||||||
|
|
||||||
@@ -49,11 +62,30 @@ class SendTenantPackageExpiredNotification implements ShouldQueue
|
|||||||
'tenant_package_id' => $tenantPackage->id,
|
'tenant_package_id' => $tenantPackage->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'package_expired',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => [
|
||||||
|
'tenant_package_id' => $tenantPackage->id,
|
||||||
|
'reason' => 'no_recipient',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($emails as $email) {
|
$context = [
|
||||||
Notification::route('mail', $email)->notify(new TenantPackageExpiredNotification($tenantPackage));
|
'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;
|
namespace App\Jobs\Packages;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\LogsTenantNotifications;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
use App\Notifications\Packages\TenantPackageExpiringNotification;
|
use App\Notifications\Packages\TenantPackageExpiringNotification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -16,6 +17,7 @@ class SendTenantPackageExpiringNotification implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
use LogsTenantNotifications;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
@@ -36,14 +38,26 @@ class SendTenantPackageExpiringNotification implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenant = $tenantPackage->tenant;
|
||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$emails = collect([
|
$emails = collect([
|
||||||
$tenantPackage->tenant->contact_email,
|
$tenant->contact_email,
|
||||||
$tenantPackage->tenant->user?->email,
|
$tenant->user?->email,
|
||||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||||
->unique();
|
->unique();
|
||||||
|
|
||||||
@@ -53,14 +67,35 @@ class SendTenantPackageExpiringNotification implements ShouldQueue
|
|||||||
'days_remaining' => $this->daysRemaining,
|
'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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($emails as $email) {
|
$context = [
|
||||||
Notification::route('mail', $email)->notify(new TenantPackageExpiringNotification(
|
'tenant_package_id' => $tenantPackage->id,
|
||||||
$tenantPackage,
|
'days_remaining' => $this->daysRemaining,
|
||||||
$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);
|
return $this->hasOne(TenantPackage::class)->where('active', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function notificationLogs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TenantNotificationLog::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function canCreateEvent(): bool
|
public function canCreateEvent(): bool
|
||||||
{
|
{
|
||||||
return $this->hasEventAllowance();
|
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.
|
- Emotions & Tasks: list, tenant overrides; task library scoping.
|
||||||
- Purchases & Ledger: create purchase intent, webhook ingest, ledger list.
|
- Purchases & Ledger: create purchase intent, webhook ingest, ledger list.
|
||||||
- Settings: read/update tenant theme, limits, legal page links.
|
- 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)
|
Guest Polling (no WebSockets in v1)
|
||||||
- GET `/events/{token}/stats` — lightweight counters for Home info bar.
|
- 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] Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).
|
||||||
- [x] Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
|
- [x] Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
|
||||||
- [x] Galerie-Countdown/Badge für Ablaufdatum + Call-to-Action.
|
- [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
|
### 4. Tenant Admin PWA Improvements
|
||||||
- [x] Dashboard-Karten & Event-Header mit Ampelsystem für Limitfortschritt.
|
- [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] E-Mail-Schablonen & Notifications für Foto- und Gäste-Schwellen/Limits.
|
||||||
- [x] Galerie-Warnungen (D-7/D-1) & Ablauf-Mails + Cron Task.
|
- [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.
|
- [x] Opt-in/Opt-out-Konfiguration pro Tenant implementieren.
|
||||||
- [ ] In-App/Toast-Benachrichtigungen für Admin UI (und optional Push/Slack intern).
|
- [x] In-App/Toast-Benachrichtigungen für Admin UI (und optional Push/Slack intern).
|
||||||
- [ ] Audit-Log & Retry-Logik für gesendete Mails.
|
- [x] Audit-Log & Retry-Logik für gesendete Mails.
|
||||||
|
|
||||||
### 6. Monitoring, Docs & Support
|
### 6. Monitoring, Docs & Support
|
||||||
- [x] Prometheus/Grafana-Metriken für Paketnutzung & Warns triggern. *(`PackageLimitMetrics` + `php artisan metrics:package-limits` Snapshot)*
|
- [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,
|
Package as PackageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -205,6 +206,23 @@ export default function DashboardPage() {
|
|||||||
[primaryEventLimits, limitTranslate],
|
[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(
|
const limitScopeLabels = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
photos: tc('limits.photosTitle'),
|
photos: tc('limits.photosTitle'),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -216,6 +217,23 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
|||||||
[event?.limits, tCommon],
|
[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 (
|
return (
|
||||||
<AdminLayout title={eventName} subtitle={subtitle} actions={actions}>
|
<AdminLayout title={eventName} subtitle={subtitle} actions={actions}>
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
|
import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
@@ -203,6 +204,23 @@ export default function EventFormPage() {
|
|||||||
return buildLimitWarnings(loadedEvent?.limits, tLimits);
|
return buildLimitWarnings(loadedEvent?.limits, tLimits);
|
||||||
}, [isEdit, 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(() => ({
|
const limitScopeLabels = React.useMemo(() => ({
|
||||||
photos: tLimits('photosTitle'),
|
photos: tLimits('photosTitle'),
|
||||||
guests: tLimits('guestsTitle'),
|
guests: tLimits('guestsTitle'),
|
||||||
|
|||||||
@@ -622,6 +622,8 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
if (!invite || !activeLayout) {
|
if (!invite || !activeLayout) {
|
||||||
setForm({});
|
setForm({});
|
||||||
setInstructions([]);
|
setInstructions([]);
|
||||||
|
commitElements(() => [], { silent: true });
|
||||||
|
resetHistory([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +637,7 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
|
|
||||||
setInstructions(baseInstructions);
|
setInstructions(baseInstructions);
|
||||||
|
|
||||||
setForm({
|
const newForm: QrLayoutCustomization = {
|
||||||
layout_id: activeLayout.id,
|
layout_id: activeLayout.id,
|
||||||
headline: reuseCustomization ? initialCustomization?.headline ?? eventName : eventName,
|
headline: reuseCustomization ? initialCustomization?.headline ?? eventName : eventName,
|
||||||
subtitle: reuseCustomization ? initialCustomization?.subtitle ?? activeLayout.subtitle ?? '' : activeLayout.subtitle ?? '',
|
subtitle: reuseCustomization ? initialCustomization?.subtitle ?? activeLayout.subtitle ?? '' : activeLayout.subtitle ?? '',
|
||||||
@@ -652,53 +654,35 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
badge_color: reuseCustomization ? initialCustomization?.badge_color ?? '#2563EB' : '#2563EB',
|
badge_color: reuseCustomization ? initialCustomization?.badge_color ?? '#2563EB' : '#2563EB',
|
||||||
background_gradient: reuseCustomization ? initialCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null : activeLayout.preview?.background_gradient ?? null,
|
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,
|
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);
|
setError(null);
|
||||||
}, [invite?.id, activeLayout?.id, defaultInstructions, initialCustomization, eventName, inviteUrl, t]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
const isCustomizedAdvanced = newForm.mode === 'advanced' && Array.isArray(newForm.elements) && newForm.elements.length > 0;
|
||||||
if (!activeLayout) {
|
|
||||||
const cleared: LayoutElement[] = [];
|
|
||||||
commitElements(() => cleared, { silent: true });
|
|
||||||
resetHistory(cleared);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const layoutKey = activeLayout.id ?? '__default';
|
if (isCustomizedAdvanced) {
|
||||||
const inviteKey = invite?.id ?? null;
|
const initialElements = normalizeElements(payloadToElements(newForm.elements));
|
||||||
if (prevInviteRef.current !== inviteKey) {
|
commitElements(() => initialElements, { silent: true });
|
||||||
initializedLayoutsRef.current = {};
|
resetHistory(initialElements);
|
||||||
prevInviteRef.current = inviteKey;
|
} else {
|
||||||
}
|
const defaults = buildDefaultElements(activeLayout, newForm, eventName, activeLayoutQrSize);
|
||||||
|
commitElements(() => defaults, { silent: true });
|
||||||
if (!initializedLayoutsRef.current[layoutKey]) {
|
resetHistory(defaults);
|
||||||
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,
|
activeLayout,
|
||||||
invite?.id,
|
invite?.id,
|
||||||
|
initialCustomization,
|
||||||
|
defaultInstructions,
|
||||||
|
eventName,
|
||||||
|
inviteUrl,
|
||||||
|
t,
|
||||||
activeLayoutQrSize,
|
activeLayoutQrSize,
|
||||||
initialCustomization?.mode,
|
|
||||||
initialCustomization?.elements,
|
|
||||||
commitElements,
|
commitElements,
|
||||||
resetHistory,
|
resetHistory,
|
||||||
elements,
|
|
||||||
cloneElements,
|
|
||||||
eventName,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -756,6 +740,17 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
hasCustomization: Boolean(initialCustomization?.elements?.length),
|
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> = {
|
const boundContent: Record<string, string | null> = {
|
||||||
headline: form.headline ?? eventName,
|
headline: form.headline ?? eventName,
|
||||||
subtitle: form.subtitle ?? '',
|
subtitle: form.subtitle ?? '',
|
||||||
@@ -783,7 +778,7 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
activeLayout,
|
activeLayout?.id,
|
||||||
elements,
|
elements,
|
||||||
form.headline,
|
form.headline,
|
||||||
form.subtitle,
|
form.subtitle,
|
||||||
@@ -794,7 +789,6 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
eventName,
|
eventName,
|
||||||
inviteUrl,
|
inviteUrl,
|
||||||
t,
|
t,
|
||||||
activeLayout,
|
|
||||||
activeLayoutQrSize,
|
activeLayoutQrSize,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1254,21 +1248,6 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
|
|
||||||
function handleLayoutSelect(layout: EventQrInviteLayout) {
|
function handleLayoutSelect(layout: EventQrInviteLayout) {
|
||||||
setSelectedLayoutId(layout.id);
|
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) {
|
function handleInstructionChange(index: number, value: string) {
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ export async function renderFabricLayout(
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
canvas.discardActiveObject();
|
canvas.discardActiveObject();
|
||||||
canvas.getObjects().forEach((object) => canvas.remove(object));
|
canvas.clear();
|
||||||
|
|
||||||
applyBackground(canvas, backgroundColor, backgroundGradient);
|
applyBackground(canvas, backgroundColor, backgroundGradient);
|
||||||
|
|
||||||
@@ -461,6 +461,7 @@ export async function renderFabricLayout(
|
|||||||
readOnly,
|
readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
const objectPromises = elements.map((element) =>
|
const objectPromises = elements.map((element) =>
|
||||||
createFabricObject({
|
createFabricObject({
|
||||||
element,
|
element,
|
||||||
@@ -471,10 +472,11 @@ export async function renderFabricLayout(
|
|||||||
qrCodeDataUrl,
|
qrCodeDataUrl,
|
||||||
logoDataUrl,
|
logoDataUrl,
|
||||||
readOnly,
|
readOnly,
|
||||||
}),
|
}, abortController.signal),
|
||||||
);
|
);
|
||||||
|
|
||||||
const fabricObjects = await Promise.all(objectPromises);
|
const fabricObjects = await Promise.all(objectPromises);
|
||||||
|
abortController.abort(); // Abort any pending loads
|
||||||
console.debug('[Invites][Fabric] resolved objects', {
|
console.debug('[Invites][Fabric] resolved objects', {
|
||||||
count: fabricObjects.length,
|
count: fabricObjects.length,
|
||||||
nulls: fabricObjects.filter((obj) => !obj).length,
|
nulls: fabricObjects.filter((obj) => !obj).length,
|
||||||
@@ -765,8 +767,9 @@ export async function loadImageObject(
|
|||||||
element: LayoutElement,
|
element: LayoutElement,
|
||||||
baseConfig: FabricObjectWithId,
|
baseConfig: FabricObjectWithId,
|
||||||
options?: { objectFit?: 'contain' | 'cover'; shadow?: string; padding?: number },
|
options?: { objectFit?: 'contain' | 'cover'; shadow?: string; padding?: number },
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
): Promise<fabric.Object | null> {
|
): Promise<fabric.Object | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
const resolveSafely = (value: fabric.Object | null) => {
|
const resolveSafely = (value: fabric.Object | null) => {
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
@@ -779,7 +782,7 @@ export async function loadImageObject(
|
|||||||
const isDataUrl = source.startsWith('data:');
|
const isDataUrl = source.startsWith('data:');
|
||||||
|
|
||||||
const onImageLoaded = (img?: HTMLImageElement | HTMLCanvasElement | null) => {
|
const onImageLoaded = (img?: HTMLImageElement | HTMLCanvasElement | null) => {
|
||||||
if (!img) {
|
if (!img || resolved) {
|
||||||
console.warn('[Invites][Fabric] image load returned empty', { source });
|
console.warn('[Invites][Fabric] image load returned empty', { source });
|
||||||
resolveSafely(null);
|
resolveSafely(null);
|
||||||
return;
|
return;
|
||||||
@@ -819,14 +822,26 @@ export async function loadImageObject(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onError = (error?: unknown) => {
|
const onError = (error?: unknown) => {
|
||||||
|
if (resolved) return;
|
||||||
console.warn('[Invites][Fabric] failed to load image', source, error);
|
console.warn('[Invites][Fabric] failed to load image', source, error);
|
||||||
resolveSafely(null);
|
resolveSafely(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const abortHandler = () => {
|
||||||
|
if (resolved) return;
|
||||||
|
console.debug('[Invites][Fabric] Image load aborted', { source });
|
||||||
|
resolveSafely(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (abortSignal) {
|
||||||
|
abortSignal.addEventListener('abort', abortHandler);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isDataUrl) {
|
if (isDataUrl) {
|
||||||
const imageElement = new Image();
|
const imageElement = new Image();
|
||||||
imageElement.onload = () => {
|
imageElement.onload = () => {
|
||||||
|
if (resolved) return;
|
||||||
console.debug('[Invites][Fabric] image loaded (data-url)', {
|
console.debug('[Invites][Fabric] image loaded (data-url)', {
|
||||||
source: source.slice(0, 48),
|
source: source.slice(0, 48),
|
||||||
width: imageElement.naturalWidth,
|
width: imageElement.naturalWidth,
|
||||||
@@ -840,6 +855,7 @@ export async function loadImageObject(
|
|||||||
// Use direct Image constructor approach for better compatibility
|
// Use direct Image constructor approach for better compatibility
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
|
if (resolved) return;
|
||||||
console.debug('[Invites][Fabric] image loaded', {
|
console.debug('[Invites][Fabric] image loaded', {
|
||||||
source: source.slice(0, 48),
|
source: source.slice(0, 48),
|
||||||
width: img.width,
|
width: img.width,
|
||||||
@@ -854,7 +870,17 @@ export async function loadImageObject(
|
|||||||
onError(error);
|
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\EventJoinTokenController;
|
||||||
use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController;
|
use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController;
|
||||||
use App\Http\Controllers\Api\Tenant\EventTypeController;
|
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\PhotoController;
|
||||||
use App\Http\Controllers\Api\Tenant\SettingsController;
|
use App\Http\Controllers\Api\Tenant\SettingsController;
|
||||||
use App\Http\Controllers\Api\Tenant\TaskCollectionController;
|
use App\Http\Controllers\Api\Tenant\TaskCollectionController;
|
||||||
@@ -145,6 +146,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
->name('tenant.settings.notifications.update');
|
->name('tenant.settings.notifications.update');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::get('notifications/logs', [NotificationLogController::class, 'index'])
|
||||||
|
->name('tenant.notifications.logs.index');
|
||||||
|
|
||||||
Route::prefix('credits')->group(function () {
|
Route::prefix('credits')->group(function () {
|
||||||
Route::get('balance', [CreditController::class, 'balance'])->name('tenant.credits.balance');
|
Route::get('balance', [CreditController::class, 'balance'])->name('tenant.credits.balance');
|
||||||
Route::get('ledger', [CreditController::class, 'ledger'])->name('tenant.credits.ledger');
|
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