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