Implement package limit notification system

This commit is contained in:
Codex Agent
2025-11-01 13:19:07 +01:00
parent 81cdee428e
commit 2c14493604
87 changed files with 4557 additions and 290 deletions

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

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

View 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,
) {}
}

View 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,
) {}
}

View 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,
) {}
}

View 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,
) {}
}

View 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,
) {}
}

View 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,
) {}
}

View 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,
) {}
}

View 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,
) {}
}

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

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

View File

@@ -2,10 +2,23 @@
namespace App\Http\Controllers\Api; 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\Carbon;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController; use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
@@ -13,16 +26,6 @@ use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response; 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 class EventPublicController extends BaseController
{ {
@@ -32,8 +35,9 @@ class EventPublicController extends BaseController
private readonly EventJoinTokenService $joinTokenService, private readonly EventJoinTokenService $joinTokenService,
private readonly EventStorageManager $eventStorageManager, private readonly EventStorageManager $eventStorageManager,
private readonly JoinTokenAnalyticsRecorder $analyticsRecorder, private readonly JoinTokenAnalyticsRecorder $analyticsRecorder,
) { private readonly PackageLimitEvaluator $packageLimitEvaluator,
} private readonly PackageUsageTracker $packageUsageTracker,
) {}
/** /**
* @return JsonResponse|array{0: object, 1: EventJoinToken} * @return JsonResponse|array{0: object, 1: EventJoinToken}
@@ -249,8 +253,7 @@ class EventPublicController extends BaseController
array $context = [], array $context = [],
?string $rawToken = null, ?string $rawToken = null,
?EventJoinToken $joinToken = null ?EventJoinToken $joinToken = null
): JsonResponse ): JsonResponse {
{
$failureLimit = max(1, (int) config('join_tokens.failure_limit', 10)); $failureLimit = max(1, (int) config('join_tokens.failure_limit', 10));
$failureDecay = max(1, (int) config('join_tokens.failure_decay_minutes', 5)); $failureDecay = max(1, (int) config('join_tokens.failure_decay_minutes', 5));
@@ -393,22 +396,33 @@ class EventPublicController extends BaseController
return null; return null;
} }
private function getLocalized($value, $locale, $default = '') { private function getLocalized($value, $locale, $default = '')
{
if (is_string($value) && json_decode($value) !== null) { if (is_string($value) && json_decode($value) !== null) {
$data = json_decode($value, true); $data = json_decode($value, true);
return $data[$locale] ?? $data['de'] ?? $default; return $data[$locale] ?? $data['de'] ?? $default;
} }
return $value ?: $default; return $value ?: $default;
} }
private function toPublicUrl(?string $path): ?string private function toPublicUrl(?string $path): ?string
{ {
if (! $path) return null; if (! $path) {
return null;
}
// Already absolute URL // 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 // Already a public storage URL
if (str_starts_with($path, '/storage/')) return $path; if (str_starts_with($path, '/storage/')) {
if (str_starts_with($path, 'storage/')) return '/' . $path; return $path;
}
if (str_starts_with($path, 'storage/')) {
return '/'.$path;
}
// Common relative paths stored in DB (e.g. 'photos/...', 'thumbnails/...', 'events/...') // 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/')) { 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/'; $needle = '/storage/app/public/';
if (str_contains($normalized, $needle)) { if (str_contains($normalized, $needle)) {
$rel = substr($normalized, strpos($normalized, $needle) + strlen($needle)); $rel = substr($normalized, strpos($normalized, $needle) + strlen($needle));
return '/storage/' . ltrim($rel, '/');
return '/storage/'.ltrim($rel, '/');
} }
return $path; // fallback as-is return $path; // fallback as-is
@@ -768,6 +783,7 @@ class EventPublicController extends BaseController
'disk' => $diskName, 'disk' => $diskName,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
continue; continue;
} }
@@ -952,7 +968,7 @@ class EventPublicController extends BaseController
return $result; return $result;
} }
[$event] = $result; [$event, $joinToken] = $result;
$eventId = $event->id; $eventId = $event->id;
$locale = $request->query('locale', 'de'); $locale = $request->query('locale', 'de');
@@ -965,7 +981,7 @@ class EventPublicController extends BaseController
'emotions.id', 'emotions.id',
'emotions.name', 'emotions.name',
'emotions.icon as emoji', 'emotions.icon as emoji',
'emotions.description' 'emotions.description',
]) ])
->orderBy('emotions.sort_order') ->orderBy('emotions.sort_order')
->get(); ->get();
@@ -973,13 +989,13 @@ class EventPublicController extends BaseController
$payload = $rows->map(function ($r) use ($locale) { $payload = $rows->map(function ($r) use ($locale) {
$nameData = json_decode($r->name, true); $nameData = json_decode($r->name, true);
$name = $nameData[$locale] ?? $nameData['de'] ?? $r->name; $name = $nameData[$locale] ?? $nameData['de'] ?? $r->name;
$descriptionData = json_decode($r->description, true); $descriptionData = json_decode($r->description, true);
$description = $descriptionData ? ($descriptionData[$locale] ?? $descriptionData['de'] ?? '') : ''; $description = $descriptionData ? ($descriptionData[$locale] ?? $descriptionData['de'] ?? '') : '';
return [ return [
'id' => (int) $r->id, 'id' => (int) $r->id,
'slug' => 'emotion-' . $r->id, // Generate slug from ID 'slug' => 'emotion-'.$r->id, // Generate slug from ID
'name' => $name, 'name' => $name,
'emoji' => $r->emoji, 'emoji' => $r->emoji,
'description' => $description, 'description' => $description,
@@ -1005,7 +1021,7 @@ class EventPublicController extends BaseController
return $result; return $result;
} }
[$event] = $result; [$event, $joinToken] = $result;
$eventId = $event->id; $eventId = $event->id;
$query = DB::table('tasks') $query = DB::table('tasks')
@@ -1018,7 +1034,7 @@ class EventPublicController extends BaseController
'tasks.description', 'tasks.description',
'tasks.example_text as instructions', 'tasks.example_text as instructions',
'tasks.emotion_id', 'tasks.emotion_id',
'tasks.sort_order' 'tasks.sort_order',
]) ])
->orderBy('event_task_collection.sort_order') ->orderBy('event_task_collection.sort_order')
->orderBy('tasks.sort_order') ->orderBy('tasks.sort_order')
@@ -1033,8 +1049,10 @@ class EventPublicController extends BaseController
$value = $r->$field; $value = $r->$field;
if (is_string($value) && json_decode($value) !== null) { if (is_string($value) && json_decode($value) !== null) {
$data = json_decode($value, true); $data = json_decode($value, true);
return $data[$locale] ?? $data['de'] ?? $default; return $data[$locale] ?? $data['de'] ?? $default;
} }
return $value ?: $default; return $value ?: $default;
}; };
@@ -1051,7 +1069,7 @@ class EventPublicController extends BaseController
$emotionName = $value ?: 'Unbekannte Emotion'; $emotionName = $value ?: 'Unbekannte Emotion';
} }
$emotion = [ $emotion = [
'slug' => 'emotion-' . $r->emotion_id, // Generate slug from ID 'slug' => 'emotion-'.$r->emotion_id, // Generate slug from ID
'name' => $emotionName, 'name' => $emotionName,
]; ];
} }
@@ -1092,7 +1110,7 @@ class EventPublicController extends BaseController
return $result; return $result;
} }
[$event] = $result; [$event, $joinToken] = $result;
$eventId = $event->id; $eventId = $event->id;
$deviceId = (string) $request->header('X-Device-Id', 'anon'); $deviceId = (string) $request->header('X-Device-Id', 'anon');
@@ -1109,7 +1127,7 @@ class EventPublicController extends BaseController
'photos.emotion_id', 'photos.emotion_id',
'photos.task_id', 'photos.task_id',
'photos.guest_name', 'photos.guest_name',
'tasks.title as task_title' 'tasks.title as task_title',
]) ])
->where('photos.event_id', $eventId) ->where('photos.event_id', $eventId)
->orderByDesc('photos.created_at') ->orderByDesc('photos.created_at')
@@ -1126,14 +1144,14 @@ class EventPublicController extends BaseController
$locale = $request->query('locale', 'de'); $locale = $request->query('locale', 'de');
$rows = $query->get()->map(function ($r) use ($locale) { $rows = $query->get()->map(function ($r) use ($locale) {
$r->file_path = $this->toPublicUrl((string)($r->file_path ?? '')); $r->file_path = $this->toPublicUrl((string) ($r->file_path ?? ''));
$r->thumbnail_path = $this->toPublicUrl((string)($r->thumbnail_path ?? '')); $r->thumbnail_path = $this->toPublicUrl((string) ($r->thumbnail_path ?? ''));
// Localize task title if present // Localize task title if present
if ($r->task_title) { if ($r->task_title) {
$r->task_title = $this->getLocalized($r->task_title, $locale, 'Unbenannte Aufgabe'); $r->task_title = $this->getLocalized($r->task_title, $locale, 'Unbenannte Aufgabe');
} }
return $r; return $r;
}); });
$latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at'); $latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
@@ -1146,10 +1164,11 @@ class EventPublicController extends BaseController
if ($reqEtag && $reqEtag === $etag) { if ($reqEtag && $reqEtag === $etag) {
return response('', 304); return response('', 304);
} }
return response()->json($payload) return response()->json($payload)
->header('Cache-Control', 'no-store') ->header('Cache-Control', 'no-store')
->header('ETag', $etag) ->header('ETag', $etag)
->header('Last-Modified', (string)$latestPhotoAt); ->header('Last-Modified', (string) $latestPhotoAt);
} }
public function photo(int $id) public function photo(int $id)
@@ -1166,7 +1185,7 @@ class EventPublicController extends BaseController
'photos.emotion_id', 'photos.emotion_id',
'photos.task_id', 'photos.task_id',
'photos.created_at', 'photos.created_at',
'tasks.title as task_title' 'tasks.title as task_title',
]) ])
->where('photos.id', $id) ->where('photos.id', $id)
->where('events.status', 'published') ->where('events.status', 'published')
@@ -1174,15 +1193,15 @@ class EventPublicController extends BaseController
if (! $row) { if (! $row) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found or event not public']], 404); 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->file_path = $this->toPublicUrl((string) ($row->file_path ?? ''));
$row->thumbnail_path = $this->toPublicUrl((string)($row->thumbnail_path ?? '')); $row->thumbnail_path = $this->toPublicUrl((string) ($row->thumbnail_path ?? ''));
// Localize task title if present // Localize task title if present
$locale = request()->query('locale', 'de'); $locale = request()->query('locale', 'de');
if ($row->task_title) { if ($row->task_title) {
$row->task_title = $this->getLocalized($row->task_title, $locale, 'Unbenannte Aufgabe'); $row->task_title = $this->getLocalized($row->task_title, $locale, 'Unbenannte Aufgabe');
} }
return response()->json($row)->header('Cache-Control', 'no-store'); return response()->json($row)->header('Cache-Control', 'no-store');
} }
@@ -1207,6 +1226,7 @@ class EventPublicController extends BaseController
$exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists(); $exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists();
if ($exists) { if ($exists) {
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count'); $count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
return response()->json(['liked' => true, 'likes_count' => $count]); return response()->json(['liked' => true, 'likes_count' => $count]);
} }
@@ -1241,9 +1261,42 @@ class EventPublicController extends BaseController
return $result; return $result;
} }
[$event] = $result; [$event, $joinToken] = $result;
$eventId = $event->id; $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 = (string) $request->header('X-Device-Id', 'anon');
$deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64) ?: '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 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([ $validated = $request->validate([
@@ -1294,7 +1359,7 @@ class EventPublicController extends BaseController
'file_path' => $url, 'file_path' => $url,
'thumbnail_path' => $thumbUrl, 'thumbnail_path' => $thumbUrl,
'likes_count' => 0, 'likes_count' => 0,
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default // Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
'emotion_id' => $this->resolveEmotionId($validated, $eventId), 'emotion_id' => $this->resolveEmotionId($validated, $eventId),
'is_featured' => 0, 'is_featured' => 0,
@@ -1333,6 +1398,13 @@ class EventPublicController extends BaseController
->where('id', $photoId) ->where('id', $photoId)
->update(['media_asset_id' => $asset->id]); ->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([ $response = response()->json([
'id' => $photoId, 'id' => $photoId,
'file_path' => $url, 'file_path' => $url,
@@ -1378,7 +1450,7 @@ class EventPublicController extends BaseController
->where('emotions.id', $id) ->where('emotions.id', $id)
->where('events.id', $eventId) ->where('events.id', $eventId)
->exists(); ->exists();
if ($exists) { if ($exists) {
return $id; return $id;
} }
@@ -1396,6 +1468,7 @@ class EventPublicController extends BaseController
return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists) return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists)
} }
public function achievements(Request $request, string $identifier) public function achievements(Request $request, string $identifier)
{ {
$result = $this->resolvePublishedEvent($request, $identifier, ['id']); $result = $this->resolvePublishedEvent($request, $identifier, ['id']);
@@ -1628,5 +1701,4 @@ class EventPublicController extends BaseController
return $path; return $path;
} }
} }
} }

View File

@@ -5,37 +5,69 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\PhotoStoreRequest; use App\Http\Requests\Tenant\PhotoStoreRequest;
use App\Http\Resources\Tenant\PhotoResource; 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 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\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB; 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 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 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. * Display a listing of the event's photos.
*/ */
public function index(Request $request, string $eventSlug): AnonymousResourceCollection public function index(Request $request, string $eventSlug): AnonymousResourceCollection
{ {
$tenantId = $request->attributes->get('tenant_id'); $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) ->where('tenant_id', $tenantId)
->firstOrFail(); ->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) $query = Photo::where('event_id', $event->id)
->with('event')->withCount('likes') ->with('event')->withCount('likes')
->orderBy('created_at', 'desc'); ->orderBy('created_at', 'desc');
@@ -68,7 +100,7 @@ class PhotoController extends Controller
$validated = $request->validated(); $validated = $request->validated();
$file = $request->file('photo'); $file = $request->file('photo');
if (!$file) { if (! $file) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'photo' => 'No photo file uploaded.', 'photo' => 'No photo file uploaded.',
]); ]);
@@ -76,7 +108,7 @@ class PhotoController extends Controller
// Validate file type and size // Validate file type and size
$allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; $allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($file->getMimeType(), $allowedTypes)) { if (! in_array($file->getMimeType(), $allowedTypes)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'photo' => 'Only JPEG, PNG, and WebP images are allowed.', 'photo' => 'Only JPEG, PNG, and WebP images are allowed.',
]); ]);
@@ -89,12 +121,11 @@ class PhotoController extends Controller
} }
// Determine storage target // Determine storage target
$event->load('storageAssignments.storageTarget');
$disk = $this->eventStorageManager->getHotDiskForEvent($event); $disk = $this->eventStorageManager->getHotDiskForEvent($event);
// Generate unique filename // Generate unique filename
$extension = $file->getClientOriginalExtension(); $extension = $file->getClientOriginalExtension();
$filename = Str::uuid() . '.' . $extension; $filename = Str::uuid().'.'.$extension;
$path = "events/{$eventSlug}/photos/{$filename}"; $path = "events/{$eventSlug}/photos/{$filename}";
// Store original file // Store original file
@@ -160,6 +191,12 @@ class PhotoController extends Controller
ProcessPhotoSecurityScan::dispatch($photo->id); ProcessPhotoSecurityScan::dispatch($photo->id);
if ($eventPackage) {
$eventPackage->increment('used_photos');
$eventPackage->refresh();
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsedPhotos, 1);
}
$photo->load('event')->loadCount('likes'); $photo->load('event')->loadCount('likes');
return response()->json([ return response()->json([
@@ -283,7 +320,6 @@ class PhotoController extends Controller
/** /**
* Bulk approve photos (admin only) * Bulk approve photos (admin only)
*/ */
public function feature(Request $request, string $eventSlug, Photo $photo): JsonResponse public function feature(Request $request, string $eventSlug, Photo $photo): JsonResponse
{ {
$tenantId = $request->attributes->get('tenant_id'); $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)]); return response()->json(['message' => 'Photo removed from featured', 'data' => new PhotoResource($photo)]);
} }
public function bulkApprove(Request $request, string $eventSlug): JsonResponse public function bulkApprove(Request $request, string $eventSlug): JsonResponse
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');
@@ -436,7 +473,7 @@ class PhotoController extends Controller
$totalPhotos = Photo::where('event_id', $event->id)->count(); $totalPhotos = Photo::where('event_id', $event->id)->count();
$pendingPhotos = Photo::where('event_id', $event->id)->where('status', 'pending')->count(); $pendingPhotos = Photo::where('event_id', $event->id)->where('status', 'pending')->count();
$approvedPhotos = Photo::where('event_id', $event->id)->where('status', 'approved')->count(); $approvedPhotos = Photo::where('event_id', $event->id)->where('status', 'approved')->count();
$totalLikes = DB::table('photo_likes')->whereIn('photo_id', $totalLikes = DB::table('photo_likes')->whereIn('photo_id',
Photo::where('event_id', $event->id)->pluck('id') Photo::where('event_id', $event->id)->pluck('id')
)->count(); )->count();
$totalStorage = Photo::where('event_id', $event->id)->sum('size'); $totalStorage = Photo::where('event_id', $event->id)->sum('size');
@@ -492,13 +529,13 @@ class PhotoController extends Controller
// Generate unique filename // Generate unique filename
$extension = pathinfo($request->filename, PATHINFO_EXTENSION); $extension = pathinfo($request->filename, PATHINFO_EXTENSION);
$filename = Str::uuid() . '.' . $extension; $filename = Str::uuid().'.'.$extension;
$path = "events/{$eventSlug}/pending/{$filename}"; $path = "events/{$eventSlug}/pending/{$filename}";
// For local storage, return direct upload endpoint // For local storage, return direct upload endpoint
// For S3, use Storage::disk('s3')->temporaryUrl() or presigned URL // For S3, use Storage::disk('s3')->temporaryUrl() or presigned URL
$uploadUrl = url("/api/v1/tenant/events/{$eventSlug}/upload-direct"); $uploadUrl = url("/api/v1/tenant/events/{$eventSlug}/upload-direct");
$fields = [ $fields = [
'event_id' => $event->id, 'event_id' => $event->id,
'filename' => $filename, 'filename' => $filename,
@@ -605,9 +642,3 @@ class PhotoController extends Controller
], 201); ], 201);
} }
} }

View File

@@ -3,12 +3,16 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class CreditCheckMiddleware class CreditCheckMiddleware
{ {
public function __construct(private readonly PackageLimitEvaluator $limitEvaluator) {}
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$tenant = $request->attributes->get('tenant'); $tenant = $request->attributes->get('tenant');
@@ -22,10 +26,18 @@ class CreditCheckMiddleware
]); ]);
} }
if ($this->requiresCredits($request) && !$tenant->canCreateEvent()) { if ($this->requiresCredits($request)) {
return response()->json([ $violation = $this->limitEvaluator->assessEventCreation($tenant);
'error' => 'No available package for creating events. Please purchase a package.',
], 402); if ($violation !== null) {
return ApiError::response(
$violation['code'],
$violation['title'],
$violation['message'],
$violation['status'],
$violation['meta']
);
}
} }
return $next($request); return $next($request);

View File

@@ -2,14 +2,17 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Event;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class PackageMiddleware class PackageMiddleware
{ {
public function __construct(private readonly PackageLimitEvaluator $limitEvaluator) {}
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$tenant = $request->attributes->get('tenant'); $tenant = $request->attributes->get('tenant');
@@ -23,10 +26,18 @@ class PackageMiddleware
]); ]);
} }
if ($this->requiresPackageCheck($request) && ! $this->canPerformAction($request, $tenant)) { if ($this->requiresPackageCheck($request)) {
return response()->json([ $violation = $this->detectViolation($request, $tenant);
'error' => 'Package limits exceeded. Please purchase or upgrade a package.',
], 402); if ($violation !== null) {
return ApiError::response(
$violation['code'],
$violation['title'],
$violation['message'],
$violation['status'],
$violation['meta']
);
}
} }
return $next($request); 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')) { 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')) { if ($request->routeIs('api.v1.tenant.events.photos.store')) {
$eventId = $request->input('event_id'); $eventId = (int) $request->input('event_id');
if (! $eventId) { if (! $eventId) {
return false; return [
'code' => 'event_id_missing',
'title' => 'Event required',
'message' => 'An event must be specified to upload photos.',
'status' => 422,
'meta' => [
'scope' => 'photos',
],
];
} }
$event = Event::query()->find($eventId);
if (! $event || $event->tenant_id !== $tenant->id) { return $this->limitEvaluator->assessPhotoUpload($tenant, $eventId);
return false;
}
$eventPackage = $event->eventPackage;
if (! $eventPackage) {
return false;
}
return $eventPackage->used_photos < ($eventPackage->package->max_photos ?? PHP_INT_MAX);
} }
return true; return null;
} }
private function resolveTenant(Request $request): Tenant private function resolveTenant(Request $request): Tenant

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Carbon\Carbon;
class EventPackage extends Model class EventPackage extends Model
{ {
@@ -27,6 +26,8 @@ class EventPackage extends Model
'purchased_price' => 'decimal:2', 'purchased_price' => 'decimal:2',
'purchased_at' => 'datetime', 'purchased_at' => 'datetime',
'gallery_expires_at' => 'datetime', 'gallery_expires_at' => 'datetime',
'gallery_warning_sent_at' => 'datetime',
'gallery_expired_notified_at' => 'datetime',
'used_photos' => 'integer', 'used_photos' => 'integer',
'used_guests' => 'integer', 'used_guests' => 'integer',
]; ];
@@ -48,33 +49,37 @@ class EventPackage extends Model
public function canUploadPhoto(): bool public function canUploadPhoto(): bool
{ {
if (!$this->isActive()) { if (! $this->isActive()) {
return false; return false;
} }
$maxPhotos = $this->package->max_photos ?? 0; $maxPhotos = $this->package->max_photos ?? 0;
return $this->used_photos < $maxPhotos; return $this->used_photos < $maxPhotos;
} }
public function canAddGuest(): bool public function canAddGuest(): bool
{ {
if (!$this->isActive()) { if (! $this->isActive()) {
return false; return false;
} }
$maxGuests = $this->package->max_guests ?? 0; $maxGuests = $this->package->max_guests ?? 0;
return $this->used_guests < $maxGuests; return $this->used_guests < $maxGuests;
} }
public function getRemainingPhotosAttribute(): int public function getRemainingPhotosAttribute(): int
{ {
$max = $this->package->max_photos ?? 0; $max = $this->package->max_photos ?? 0;
return max(0, $max - $this->used_photos); return max(0, $max - $this->used_photos);
} }
public function getRemainingGuestsAttribute(): int public function getRemainingGuestsAttribute(): int
{ {
$max = $this->package->max_guests ?? 0; $max = $this->package->max_guests ?? 0;
return max(0, $max - $this->used_guests); return max(0, $max - $this->used_guests);
} }
@@ -83,13 +88,13 @@ class EventPackage extends Model
parent::boot(); parent::boot();
static::creating(function ($eventPackage) { static::creating(function ($eventPackage) {
if (!$eventPackage->purchased_at) { if (! $eventPackage->purchased_at) {
$eventPackage->purchased_at = now(); $eventPackage->purchased_at = now();
} }
if (!$eventPackage->gallery_expires_at && $eventPackage->package) { if (! $eventPackage->gallery_expires_at && $eventPackage->package) {
$days = $eventPackage->package->gallery_days ?? 30; $days = $eventPackage->package->gallery_days ?? 30;
$eventPackage->gallery_expires_at = now()->addDays($days); $eventPackage->gallery_expires_at = now()->addDays($days);
} }
}); });
} }
} }

View File

@@ -5,30 +5,31 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; 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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; 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\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Models\TenantPackage;
use App\Models\EventCreditsLedger;
class Tenant extends Model class Tenant extends Model
{ {
use HasFactory; use HasFactory;
protected $table = 'tenants'; protected $table = 'tenants';
protected $guarded = []; protected $guarded = [];
protected $casts = [ protected $casts = [
'features' => 'array', 'features' => 'array',
'settings' => 'array', 'settings' => 'array',
'notification_preferences' => 'array',
'last_activity_at' => 'datetime', 'last_activity_at' => 'datetime',
'total_revenue' => 'decimal:2', 'total_revenue' => 'decimal:2',
'settings_updated_at' => 'datetime', 'settings_updated_at' => 'datetime',
'subscription_expires_at' => 'datetime', 'subscription_expires_at' => 'datetime',
'credit_warning_sent_at' => 'datetime',
'credit_warning_threshold' => 'integer',
]; ];
public function events(): HasMany public function events(): HasMany
@@ -83,6 +84,7 @@ class Tenant extends Model
} }
$package->increment('used_events', $amount); $package->increment('used_events', $amount);
return true; return true;
} }
@@ -90,6 +92,7 @@ class Tenant extends Model
{ {
if (is_string($value)) { if (is_string($value)) {
$this->attributes['settings'] = $value; $this->attributes['settings'] = $value;
return; return;
} }
@@ -105,6 +108,22 @@ class Tenant extends Model
$balance = (int) ($this->event_credits_balance ?? 0) + $amount; $balance = (int) ($this->event_credits_balance ?? 0) + $amount;
$this->forceFill(['event_credits_balance' => $balance])->save(); $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([ EventCreditsLedger::create([
'tenant_id' => $this->id, 'tenant_id' => $this->id,
'delta' => $amount, 'delta' => $amount,
@@ -134,6 +153,12 @@ class Tenant extends Model
$balance = $current - $amount; $balance = $current - $amount;
$this->forceFill(['event_credits_balance' => $balance])->save(); $this->forceFill(['event_credits_balance' => $balance])->save();
app(\App\Services\Packages\TenantUsageTracker::class)->recordCreditBalance(
$this,
$current,
$balance
);
EventCreditsLedger::create([ EventCreditsLedger::create([
'tenant_id' => $this->id, 'tenant_id' => $this->id,
'delta' => -$amount, 'delta' => -$amount,
@@ -166,7 +191,15 @@ class Tenant extends Model
{ {
$package = $this->getActiveResellerPackage(); $package = $this->getActiveResellerPackage();
if ($package && $package->canCreateEvent()) { if ($package && $package->canCreateEvent()) {
$previousUsed = (int) $package->used_events;
$package->increment('used_events', $amount); $package->increment('used_events', $amount);
$package->refresh();
app(\App\Services\Packages\TenantUsageTracker::class)->recordEventUsage(
$package,
$previousUsed,
$amount
);
Log::info('Tenant package usage recorded', [ Log::info('Tenant package usage recorded', [
'tenant_id' => $this->id, 'tenant_id' => $this->id,

View File

@@ -30,6 +30,11 @@ class TenantPackage extends Model
'expires_at' => 'datetime', 'expires_at' => 'datetime',
'used_events' => 'integer', 'used_events' => 'integer',
'active' => 'boolean', '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 public function tenant(): BelongsTo

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,16 +2,39 @@
namespace App\Providers; 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\CheckoutAssignmentService;
use App\Services\Checkout\CheckoutPaymentService; use App\Services\Checkout\CheckoutPaymentService;
use App\Services\Checkout\CheckoutSessionService; use App\Services\Checkout\CheckoutSessionService;
use App\Notifications\UploadPipelineFailed; use App\Services\Security\PhotoSecurityScanner;
use App\Services\Storage\EventStorageManager; use App\Services\Storage\EventStorageManager;
use App\Services\Storage\StorageHealthService; use App\Services\Storage\StorageHealthService;
use App\Services\Security\PhotoSecurityScanner;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Queue\Events\JobFailed; use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Event as EventFacade;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
@@ -40,25 +63,80 @@ class AppServiceProvider extends ServiceProvider
{ {
$this->app->make(EventStorageManager::class)->registerDynamicDisks(); $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) { RateLimiter::for('tenant-api', function (Request $request) {
$tenantId = $request->attributes->get('tenant_id') $tenantId = $request->attributes->get('tenant_id')
?? $request->user()?->tenant_id ?? $request->user()?->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); return Limit::perMinute(100)->by($key);
}); });
RateLimiter::for('oauth', function (Request $request) { 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('locale', fn () => app()->getLocale());
Inertia::share('analytics', static function () { Inertia::share('analytics', static function () {
$config = config('services.matomo'); $config = config('services.matomo');
if (!($config['enabled'] ?? false)) { if (! ($config['enabled'] ?? false)) {
return [ return [
'matomo' => [ 'matomo' => [
'enabled' => false, 'enabled' => false,

View File

@@ -58,6 +58,25 @@ class EventJoinTokenService
public function incrementUsage(EventJoinToken $joinToken): void public function incrementUsage(EventJoinToken $joinToken): void
{ {
$joinToken->increment('usage_count'); $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 public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken

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

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

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

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

View File

@@ -33,7 +33,7 @@ class JoinTokenLayoutRegistry
'link_heading' => 'Falls der Scan nicht klappt', 'link_heading' => 'Falls der Scan nicht klappt',
'cta_label' => 'Gästegalerie öffnen', 'cta_label' => 'Gästegalerie öffnen',
'cta_caption' => 'Jetzt Erinnerungen sammeln', 'cta_caption' => 'Jetzt Erinnerungen sammeln',
'qr' => ['size_px' => 520], 'qr' => ['size_px' => 640],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => [
'QR-Code scannen und mit eurem Lieblingsnamen anmelden.', 'QR-Code scannen und mit eurem Lieblingsnamen anmelden.',
@@ -62,7 +62,7 @@ class JoinTokenLayoutRegistry
'link_heading' => 'Link teilen statt scannen', 'link_heading' => 'Link teilen statt scannen',
'cta_label' => 'Jetzt Event-Hub öffnen', 'cta_label' => 'Jetzt Event-Hub öffnen',
'cta_caption' => 'Programm, Uploads & Highlights', 'cta_caption' => 'Programm, Uploads & Highlights',
'qr' => ['size_px' => 560], 'qr' => ['size_px' => 640],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => [
'QR-Code scannen oder Kurzlink eingeben.', 'QR-Code scannen oder Kurzlink eingeben.',
@@ -91,7 +91,7 @@ class JoinTokenLayoutRegistry
'link_heading' => 'Alternativ zum Scannen', 'link_heading' => 'Alternativ zum Scannen',
'cta_label' => 'Gästebuch öffnen', 'cta_label' => 'Gästebuch öffnen',
'cta_caption' => 'Eure Grüße festhalten', 'cta_caption' => 'Eure Grüße festhalten',
'qr' => ['size_px' => 520], 'qr' => ['size_px' => 660],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => [
'QR-Code scannen und Namen eintragen.', 'QR-Code scannen und Namen eintragen.',
@@ -120,7 +120,7 @@ class JoinTokenLayoutRegistry
'link_heading' => 'QR funktioniert nicht?', 'link_heading' => 'QR funktioniert nicht?',
'cta_label' => 'Partyfeed starten', 'cta_label' => 'Partyfeed starten',
'cta_caption' => 'Momente live teilen', 'cta_caption' => 'Momente live teilen',
'qr' => ['size_px' => 560], 'qr' => ['size_px' => 680],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => [
'Code scannen und kurz registrieren.', 'Code scannen und kurz registrieren.',
@@ -149,7 +149,7 @@ class JoinTokenLayoutRegistry
'link_heading' => 'Kurzlink für Gäste', 'link_heading' => 'Kurzlink für Gäste',
'cta_label' => 'Zur Geburtstagswand', 'cta_label' => 'Zur Geburtstagswand',
'cta_caption' => 'Fotos & Grüße posten', 'cta_caption' => 'Fotos & Grüße posten',
'qr' => ['size_px' => 520], 'qr' => ['size_px' => 680],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => [
'QR-Code scannen und Wunschname auswählen.', 'QR-Code scannen und Wunschname auswählen.',
@@ -223,7 +223,7 @@ class JoinTokenLayoutRegistry
'link_label' => null, 'link_label' => null,
'logo_url' => null, 'logo_url' => null,
'qr' => [ 'qr' => [
'size_px' => 360, 'size_px' => 640,
], ],
'svg' => [ 'svg' => [
'width' => 1240, 'width' => 1240,

View File

@@ -22,7 +22,11 @@ return Application::configure(basePath: dirname(__DIR__))
\App\Console\Commands\OAuthRotateKeysCommand::class, \App\Console\Commands\OAuthRotateKeysCommand::class,
\App\Console\Commands\OAuthListKeysCommand::class, \App\Console\Commands\OAuthListKeysCommand::class,
\App\Console\Commands\OAuthPruneKeysCommand::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) { ->withMiddleware(function (Middleware $middleware) {
$middleware->alias([ $middleware->alias([
'tenant.token' => TenantTokenGuard::class, 'tenant.token' => TenantTokenGuard::class,

29
config/package-limits.php Normal file
View 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,
],
];

View File

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

View File

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

View File

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

View File

@@ -14,8 +14,28 @@ class InviteLayoutSeeder extends Seeder
$reflection = new ReflectionClass(JoinTokenLayoutRegistry::class); $reflection = new ReflectionClass(JoinTokenLayoutRegistry::class);
$layoutsConst = $reflection->getReflectionConstant('LAYOUTS'); $layoutsConst = $reflection->getReflectionConstant('LAYOUTS');
$fallbackLayouts = $layoutsConst ? $layoutsConst->getValue() : []; $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) { 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 = [ $preview = [
'background' => $layout['background'] ?? null, 'background' => $layout['background'] ?? null,
'background_gradient' => $layout['background_gradient'] ?? null, 'background_gradient' => $layout['background_gradient'] ?? null,
@@ -23,8 +43,8 @@ class InviteLayoutSeeder extends Seeder
'secondary' => $layout['secondary'] ?? null, 'secondary' => $layout['secondary'] ?? null,
'text' => $layout['text'] ?? null, 'text' => $layout['text'] ?? null,
'badge' => $layout['badge'] ?? null, 'badge' => $layout['badge'] ?? null,
'qr' => $layout['qr'] ?? ['size_px' => 500], 'qr' => ['size_px' => $qrSize],
'svg' => $layout['svg'] ?? ['width' => 1240, 'height' => 1754], 'svg' => ['width' => $svgWidth, 'height' => $svgHeight],
]; ];
$options = [ $options = [

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

View File

@@ -1,4 +1,5 @@
import { authorizedFetch } from './auth/tokens'; import { authorizedFetch } from './auth/tokens';
import { ApiError } from './lib/apiError';
import i18n from './i18n'; import i18n from './i18n';
type JsonValue = Record<string, unknown>; type JsonValue = Record<string, unknown>;
@@ -331,8 +332,20 @@ type EventSavePayload = {
async function jsonOrThrow<T>(response: Response, message: string): Promise<T> { async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
if (!response.ok) { if (!response.ok) {
const body = await safeJson(response); const body = await safeJson(response);
console.error('[API]', message, response.status, body); const status = response.status;
throw new Error(message); 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; return (await response.json()) as T;

View File

@@ -20,5 +20,13 @@
"actions": { "actions": {
"open": "Öffnen", "open": "Öffnen",
"viewAll": "Alle anzeigen" "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"
} }
} }

View File

@@ -20,5 +20,13 @@
"actions": { "actions": {
"open": "Open", "open": "Open",
"viewAll": "View all" "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"
} }
} }

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

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react'; import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -23,6 +24,7 @@ import {
TenantEvent, TenantEvent,
} from '../api'; } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { isApiError } from '../lib/apiError';
import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants'; import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
interface EventFormState { interface EventFormState {
@@ -63,6 +65,8 @@ export default function EventFormPage() {
const isEdit = Boolean(slugParam); const isEdit = Boolean(slugParam);
const navigate = useNavigate(); const navigate = useNavigate();
const { t: tCommon } = useTranslation('common', { keyPrefix: 'errors' });
const [form, setForm] = React.useState<EventFormState>({ const [form, setForm] = React.useState<EventFormState>({
name: '', name: '',
slug: '', slug: '',
@@ -76,6 +80,7 @@ export default function EventFormPage() {
const slugSuffixRef = React.useRef<string | null>(null); const slugSuffixRef = React.useRef<string | null>(null);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [showUpgradeHint, setShowUpgradeHint] = React.useState(false);
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null); const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
const [eventPackageMeta, setEventPackageMeta] = React.useState<EventPackageMeta | null>(null); const [eventPackageMeta, setEventPackageMeta] = React.useState<EventPackageMeta | null>(null);
@@ -232,6 +237,7 @@ export default function EventFormPage() {
setSaving(true); setSaving(true);
setError(null); setError(null);
setShowUpgradeHint(false);
const status: 'draft' | 'published' | 'archived' = form.isPublished ? 'published' : 'draft'; const status: 'draft' | 'published' | 'archived' = form.isPublished ? 'published' : 'draft';
const packageIdForSubmit = form.package_id || activePackage?.package_id || null; const packageIdForSubmit = form.package_id || activePackage?.package_id || null;
@@ -256,14 +262,44 @@ export default function EventFormPage() {
const targetSlug = originalSlug ?? slugParam!; const targetSlug = originalSlug ?? slugParam!;
const updated = await updateEvent(targetSlug, payload); const updated = await updateEvent(targetSlug, payload);
setOriginalSlug(updated.slug); setOriginalSlug(updated.slug);
setShowUpgradeHint(false);
setError(null);
navigate(ADMIN_EVENT_VIEW_PATH(updated.slug)); navigate(ADMIN_EVENT_VIEW_PATH(updated.slug));
} else { } else {
const { event: created } = await createEvent(payload); const { event: created } = await createEvent(payload);
setShowUpgradeHint(false);
setError(null);
navigate(ADMIN_EVENT_VIEW_PATH(created.slug)); navigate(ADMIN_EVENT_VIEW_PATH(created.slug));
} }
} catch (err) { } catch (err) {
if (!isAuthError(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 { } finally {
setSaving(false); setSaving(false);
@@ -360,7 +396,18 @@ export default function EventFormPage() {
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>Hinweis</AlertTitle> <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> </Alert>
)} )}

View File

@@ -31,7 +31,10 @@ import {
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel'; import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas'; import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
import { import {
CANVAS_HEIGHT,
CANVAS_WIDTH,
buildDefaultElements, buildDefaultElements,
clamp,
normalizeElements, normalizeElements,
payloadToElements, payloadToElements,
LayoutElement, LayoutElement,
@@ -171,6 +174,8 @@ export default function EventInvitesPage(): JSX.Element {
const [exportDownloadBusy, setExportDownloadBusy] = React.useState<string | null>(null); const [exportDownloadBusy, setExportDownloadBusy] = React.useState<string | null>(null);
const [exportPrintBusy, setExportPrintBusy] = React.useState<string | null>(null); const [exportPrintBusy, setExportPrintBusy] = React.useState<string | null>(null);
const [exportError, setExportError] = 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 () => { const load = React.useCallback(async () => {
if (!slug) { if (!slug) {
@@ -190,10 +195,35 @@ export default function EventInvitesPage(): JSX.Element {
} }
}, [slug]); }, [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(() => { React.useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
React.useEffect(() => {
recomputeExportScale();
}, [recomputeExportScale]);
React.useEffect(() => {
const handleResize = () => recomputeExportScale();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [recomputeExportScale]);
React.useEffect(() => { React.useEffect(() => {
const param = searchParams.get('tab'); const param = searchParams.get('tab');
const nextTab = param === 'export' || param === 'links' ? (param as TabKey) : 'layout'; 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]); }, [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( const exportCanvasKey = React.useMemo(
() => `export:${selectedInvite?.id ?? 'none'}:${exportLayout?.id ?? 'layout'}:${exportPreview?.mode ?? 'standard'}`, () => `export:${selectedInvite?.id ?? 'none'}:${exportLayout?.id ?? 'layout'}:${exportPreview?.mode ?? 'standard'}`,
[selectedInvite?.id, exportLayout?.id, exportPreview?.mode] [selectedInvite?.id, exportLayout?.id, exportPreview?.mode]
@@ -789,7 +841,10 @@ export default function EventInvitesPage(): JSX.Element {
</div> </div>
<div className="mt-6 flex justify-center"> <div className="mt-6 flex justify-center">
{exportElements.length ? ( {exportElements.length ? (
<div className="pointer-events-none"> <div
ref={exportPreviewContainerRef}
className="pointer-events-none w-full max-w-full"
>
<DesignerCanvas <DesignerCanvas
elements={exportElements} elements={exportElements}
selectedId={null} selectedId={null}
@@ -803,8 +858,9 @@ export default function EventInvitesPage(): JSX.Element {
badge={exportPreview.badgeColor} badge={exportPreview.badgeColor}
qrCodeDataUrl={exportQr} qrCodeDataUrl={exportQr}
logoDataUrl={exportLogo} logoDataUrl={exportLogo}
scale={0.34} scale={exportScale}
layoutKey={exportCanvasKey} layoutKey={exportCanvasKey}
readOnly
/> />
</div> </div>
) : ( ) : (

View File

@@ -34,19 +34,21 @@ import type { EventQrInvite, EventQrInviteLayout } from '../../api';
import { authorizedFetch } from '../../auth/tokens'; import { authorizedFetch } from '../../auth/tokens';
import { import {
CANVAS_HEIGHT,
CANVAS_WIDTH,
QrLayoutCustomization, QrLayoutCustomization,
LayoutElement, LayoutElement,
LayoutElementPayload, LayoutElementPayload,
LayoutElementType, LayoutElementType,
LayoutSerializationContext, LayoutSerializationContext,
buildDefaultElements, buildDefaultElements,
clamp,
clampElement, clampElement,
elementsToPayload, elementsToPayload,
normalizeElements, normalizeElements,
payloadToElements, payloadToElements,
} from './invite-layout/schema'; } from './invite-layout/schema';
import { DesignerCanvas } from './invite-layout/DesignerCanvas'; import { DesignerCanvas } from './invite-layout/DesignerCanvas';
import { CANVAS_HEIGHT, CANVAS_WIDTH } from './invite-layout/schema';
import { import {
generatePdfBytes, generatePdfBytes,
generatePngDataUrl, generatePngDataUrl,
@@ -181,6 +183,9 @@ type InviteLayoutCustomizerPanelProps = {
}; };
const MAX_INSTRUCTIONS = 5; const MAX_INSTRUCTIONS = 5;
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 2;
const ZOOM_STEP = 0.05;
export function InviteLayoutCustomizerPanel({ export function InviteLayoutCustomizerPanel({
invite, invite,
@@ -213,6 +218,10 @@ export function InviteLayoutCustomizerPanel({
const [elements, setElements] = React.useState<LayoutElement[]>([]); const [elements, setElements] = React.useState<LayoutElement[]>([]);
const [activeElementId, setActiveElementId] = React.useState<string | null>(null); const [activeElementId, setActiveElementId] = React.useState<string | null>(null);
const [showFloatingActions, setShowFloatingActions] = React.useState(false); 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 actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
const historyRef = React.useRef<LayoutElement[][]>([]); const historyRef = React.useRef<LayoutElement[][]>([]);
const historyIndexRef = React.useRef(-1); const historyIndexRef = React.useRef(-1);
@@ -223,6 +232,83 @@ export function InviteLayoutCustomizerPanel({
const canvasContainerRef = React.useRef<HTMLDivElement | null>(null); const canvasContainerRef = React.useRef<HTMLDivElement | null>(null);
const isAdvanced = true; 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( const cloneElements = React.useCallback(
(items: LayoutElement[]): LayoutElement[] => items.map((item) => ({ ...item })), (items: LayoutElement[]): LayoutElement[] => items.map((item) => ({ ...item })),
[] []
@@ -355,6 +441,11 @@ export function InviteLayoutCustomizerPanel({
return availableLayouts[0]; return availableLayouts[0];
}, [availableLayouts, selectedLayoutId]); }, [availableLayouts, selectedLayoutId]);
React.useEffect(() => {
manualZoomRef.current = false;
recomputeFitScale();
}, [recomputeFitScale, activeLayout?.id, invite?.id]);
const activeLayoutQrSize = React.useMemo(() => { const activeLayoutQrSize = React.useMemo(() => {
const qrElement = elements.find((element) => element.type === 'qr'); const qrElement = elements.find((element) => element.type === 'qr');
if (qrElement && typeof qrElement.width === 'number' && qrElement.width > 0) { if (qrElement && typeof qrElement.width === 'number' && qrElement.width > 0) {
@@ -371,6 +462,12 @@ export function InviteLayoutCustomizerPanel({
return activeLayout?.preview?.qr_size_px ?? 500; return activeLayout?.preview?.qr_size_px ?? 500;
}, [elements, initialCustomization?.mode, initialCustomization?.elements, activeLayout?.preview?.qr_size_px]); }, [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( const updateElement = React.useCallback(
(id: string, updater: Partial<LayoutElement> | ((element: LayoutElement) => Partial<LayoutElement>), options?: { silent?: boolean }) => { (id: string, updater: Partial<LayoutElement> | ((element: LayoutElement) => Partial<LayoutElement>), options?: { silent?: boolean }) => {
commitElements( commitElements(
@@ -1702,27 +1799,62 @@ export function InviteLayoutCustomizerPanel({
<div ref={actionsSentinelRef} className="h-1 w-full" /> <div ref={actionsSentinelRef} className="h-1 w-full" />
</form> </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-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">
<Button <div className="flex flex-wrap items-center gap-3">
type="button" <span className="text-sm font-medium text-muted-foreground">
variant="outline" {t('invites.customizer.controls.zoom', 'Zoom')}
size="sm" </span>
onClick={handleUndo} <input
disabled={!canUndo} type="range"
> min={ZOOM_MIN}
<Undo2 className="mr-1 h-4 w-4" /> max={ZOOM_MAX}
{t('invites.customizer.actions.undo', 'Rückgängig')} step={ZOOM_STEP}
</Button> value={effectiveScale}
<Button onChange={(event) => {
type="button" manualZoomRef.current = true;
variant="outline" setZoomScale(clampZoom(Number(event.target.value)));
size="sm" }}
onClick={handleRedo} className="h-1 w-36 overflow-hidden rounded-full"
disabled={!canRedo} disabled={false}
> aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
<Redo2 className="mr-1 h-4 w-4" /> />
{t('invites.customizer.actions.redo', 'Wiederholen')} <span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
</Button> <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"
size="sm"
onClick={handleUndo}
disabled={!canUndo}
>
<Undo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.undo', 'Rückgängig')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRedo}
disabled={!canRedo}
>
<Redo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.redo', 'Wiederholen')}
</Button>
</div>
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center">
@@ -1744,6 +1876,7 @@ export function InviteLayoutCustomizerPanel({
badge={form.badge_color ?? form.accent_color ?? '#2563EB'} badge={form.badge_color ?? form.accent_color ?? '#2563EB'}
qrCodeDataUrl={qrCodeDataUrl} qrCodeDataUrl={qrCodeDataUrl}
logoDataUrl={form.logo_data_url ?? form.logo_url ?? null} logoDataUrl={form.logo_data_url ?? form.logo_url ?? null}
scale={effectiveScale}
layoutKey={`designer:${invite?.id ?? 'unknown'}:${activeLayout?.id ?? 'default'}`} layoutKey={`designer:${invite?.id ?? 'unknown'}:${activeLayout?.id ?? 'default'}`}
/> />
</div> </div>

View File

@@ -22,6 +22,7 @@ type DesignerCanvasProps = {
badge: string; badge: string;
qrCodeDataUrl: string | null; qrCodeDataUrl: string | null;
logoDataUrl: string | null; logoDataUrl: string | null;
scale?: number;
layoutKey?: string; layoutKey?: string;
readOnly?: boolean; readOnly?: boolean;
}; };
@@ -41,6 +42,7 @@ export function DesignerCanvas({
badge, badge,
qrCodeDataUrl, qrCodeDataUrl,
logoDataUrl, logoDataUrl,
scale = 1,
layoutKey, layoutKey,
readOnly = false, readOnly = false,
}: DesignerCanvasProps): React.JSX.Element { }: DesignerCanvasProps): React.JSX.Element {
@@ -343,16 +345,43 @@ export function DesignerCanvas({
if (!canvas) { if (!canvas) {
return; return;
} }
canvas.setZoom(1);
canvas.setDimensions( const normalizedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
{
width: CANVAS_WIDTH, canvas.setZoom(normalizedScale);
height: CANVAS_HEIGHT,
}, const cssWidth = CANVAS_WIDTH * normalizedScale;
{ cssOnly: true }, 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(); canvas.requestRenderAll();
}, []); }, [scale]);
return ( return (
<div ref={containerRef} className="relative inline-block max-w-full"> <div ref={containerRef} className="relative inline-block max-w-full">

View File

@@ -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 }> = { 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' }, headline: { width: 900, height: 240, fontSize: 82, align: 'left' },
subtitle: { width: 580, height: 140, fontSize: 34, align: 'left' }, subtitle: { width: 760, height: 170, fontSize: 40, align: 'left' },
description: { width: 620, height: 280, fontSize: 28, align: 'left' }, description: { width: 920, height: 340, fontSize: 32, align: 'left' },
link: { width: 400, height: 110, fontSize: 28, align: 'center' }, link: { width: 520, height: 130, fontSize: 30, align: 'center' },
badge: { width: 280, height: 80, fontSize: 24, align: 'center' }, badge: { width: 420, height: 100, fontSize: 26, align: 'center' },
logo: { width: 240, height: 180, align: 'center' }, logo: { width: 320, height: 220, align: 'center' },
cta: { width: 400, height: 110, fontSize: 26, align: 'center' }, cta: { width: 520, height: 130, fontSize: 28, align: 'center' },
qr: { width: 520, height: 520 }, qr: { width: 640, height: 640 },
text: { width: 560, height: 200, fontSize: 26, align: 'left' }, text: { width: 720, height: 260, fontSize: 28, align: 'left' },
}; };
const DEFAULT_PRESET: LayoutPreset = [ const DEFAULT_PRESET: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 120, y: 140, width: 320, height: 80, align: 'center', fontSize: 24 }, { id: 'badge', type: 'badge', x: 140, y: 160, width: 440, height: 100, align: 'center', fontSize: 28 },
{ 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: 'headline',
{ id: 'description', type: 'description', x: 120, y: 600, width: 620, height: 280, fontSize: 28, align: 'left' }, 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', id: 'qr',
type: 'qr', type: 'qr',
x: (context) => context.canvasWidth - context.qrSize - 140, x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 180,
y: 360, y: 360,
width: (context) => context.qrSize, width: (context) => Math.min(context.qrSize, 680),
height: (context) => context.qrSize, height: (context) => Math.min(context.qrSize, 680),
}, },
{ {
id: 'link', id: 'link',
type: 'link', type: 'link',
x: (context) => context.canvasWidth - 420, x: (context) => context.canvasWidth - 540,
y: (context) => 400 + context.qrSize, y: (context) => 420 + Math.min(context.qrSize, 680),
width: 400, width: 520,
height: 110, height: 130,
fontSize: 28, fontSize: 28,
align: 'center', align: 'center',
}, },
{ {
id: 'cta', id: 'cta',
type: 'cta', type: 'cta',
x: (context) => context.canvasWidth - 420, x: (context) => context.canvasWidth - 540,
y: (context) => 420 + context.qrSize + 140, y: (context) => 460 + Math.min(context.qrSize, 680) + 160,
width: 400, width: 520,
height: 110, height: 130,
fontSize: 26, fontSize: 30,
align: 'center', align: 'center',
}, },
]; ];
const evergreenVowsPreset: LayoutPreset = [ const evergreenVowsPreset: LayoutPreset = [
{ id: 'logo', type: 'logo', x: 120, y: 140, width: 240, height: 180 }, { id: 'logo', type: 'logo', x: 160, y: 140, width: 340, height: 240 },
{ id: 'badge', type: 'badge', x: 400, y: 160, width: 320, height: 80, align: 'center', fontSize: 24 }, { id: 'badge', type: 'badge', x: 540, y: 160, width: 420, height: 100, align: 'center', fontSize: 28 },
{ 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: 'headline',
{ id: 'description', type: 'description', x: 120, y: 720, width: 620, height: 280, fontSize: 28, align: 'left' }, 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', id: 'qr',
type: 'qr', type: 'qr',
x: (context) => context.canvasWidth - context.qrSize - 160, x: (context) => context.canvasWidth - Math.min(context.qrSize, 640) - 200,
y: 460, y: 420,
width: (context) => context.qrSize, width: (context) => Math.min(context.qrSize, 640),
height: (context) => context.qrSize, height: (context) => Math.min(context.qrSize, 640),
}, },
{ {
id: 'link', id: 'link',
type: 'link', type: 'link',
x: (context) => context.canvasWidth - 420, x: (context) => context.canvasWidth - 560,
y: (context) => 500 + context.qrSize, y: (context) => 480 + Math.min(context.qrSize, 640),
width: 400, width: 520,
height: 110, height: 130,
align: 'center', align: 'center',
}, },
{ {
id: 'cta', id: 'cta',
type: 'cta', type: 'cta',
x: (context) => context.canvasWidth - 420, x: (context) => context.canvasWidth - 560,
y: (context) => 520 + context.qrSize + 150, y: (context) => 520 + Math.min(context.qrSize, 640) + 180,
width: 400, width: 520,
height: 110, height: 130,
align: 'center', align: 'center',
}, },
]; ];
const midnightGalaPreset: LayoutPreset = [ const midnightGalaPreset: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 360, y: 160, width: 520, height: 90, align: 'center', fontSize: 26 }, { 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: 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: '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', id: 'qr',
type: 'qr', type: 'qr',
x: (context) => (context.canvasWidth - context.qrSize) / 2, x: (context) => (context.canvasWidth - Math.min(context.qrSize, 640)) / 2,
y: 700, y: 880,
width: (context) => context.qrSize, width: (context) => Math.min(context.qrSize, 640),
height: (context) => context.qrSize, height: (context) => Math.min(context.qrSize, 640),
}, },
{ {
id: 'link', id: 'link',
type: 'link', type: 'link',
x: (context) => (context.canvasWidth - 420) / 2, x: (context) => (context.canvasWidth - 560) / 2,
y: (context) => 740 + context.qrSize, y: (context) => 940 + Math.min(context.qrSize, 640),
width: 420, width: 560,
height: 120, height: 140,
align: 'center', align: 'center',
}, },
{ {
id: 'cta', id: 'cta',
type: 'cta', type: 'cta',
x: (context) => (context.canvasWidth - 420) / 2, x: (context) => (context.canvasWidth - 560) / 2,
y: (context) => 770 + context.qrSize + 150, y: (context) => 980 + Math.min(context.qrSize, 640) + 200,
width: 420, width: 560,
height: 120, 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', align: 'center',
}, },
{ id: 'description', type: 'description', x: 200, y: 1040, width: 840, height: 260, fontSize: 28, align: 'center' },
]; ];
const gardenBrunchPreset: LayoutPreset = [ const gardenBrunchPreset: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 160, y: 160, width: 360, height: 80, align: 'center', fontSize: 24 }, { id: 'badge', type: 'badge', x: 180, y: 180, width: 500, height: 110, align: 'center', fontSize: 30 },
{ id: 'headline', type: 'headline', x: 160, y: 300, width: 560, height: 200, fontSize: 66, align: 'left' }, { id: 'headline', type: 'headline', x: 180, y: 340, width: (context) => context.canvasWidth - 360, height: 260, fontSize: 86, align: 'left' },
{ id: 'description', type: 'description', x: 160, y: 520, width: 560, height: 260, fontSize: 28, align: 'left' }, { id: 'description', type: 'description', x: 180, y: 630, width: (context) => context.canvasWidth - 360, height: 360, fontSize: 34, align: 'left' },
{ {
id: 'qr', id: 'qr',
type: 'qr', type: 'qr',
x: 160, x: 180,
y: 840, y: 1000,
width: (context) => Math.min(context.qrSize, 520), width: (context) => Math.min(context.qrSize, 660),
height: (context) => Math.min(context.qrSize, 520), height: (context) => Math.min(context.qrSize, 660),
}, },
{ {
id: 'link', id: 'link',
type: 'link', type: 'link',
x: 160, x: 180,
y: (context) => 880 + Math.min(context.qrSize, 520), y: (context) => 1060 + Math.min(context.qrSize, 660),
width: 420, width: 520,
height: 110, height: 140,
align: 'center', align: 'center',
}, },
{ {
id: 'cta', id: 'cta',
type: 'cta', type: 'cta',
x: 160, x: 180,
y: (context) => 910 + Math.min(context.qrSize, 520) + 140, y: (context) => 1100 + Math.min(context.qrSize, 660) + 190,
width: 420, width: 520,
height: 110, height: 140,
align: 'center', align: 'center',
}, },
{ id: 'subtitle', type: 'subtitle', x: 780, y: 320, width: 320, height: 140, fontSize: 32, 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: 780, y: 480, width: 320, height: 320, fontSize: 24, 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 = [ const sparklerSoireePreset: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 360, y: 150, width: 520, height: 90, align: 'center', fontSize: 26 }, { 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: 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: 'headline',
{ id: 'description', type: 'description', x: 220, y: 680, width: 800, height: 240, fontSize: 28, align: 'center' }, 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', id: 'qr',
type: 'qr', type: 'qr',
x: (context) => (context.canvasWidth - context.qrSize) / 2, x: (context) => (context.canvasWidth - Math.min(context.qrSize, 680)) / 2,
y: 960, y: 1200,
width: (context) => context.qrSize, width: (context) => Math.min(context.qrSize, 680),
height: (context) => context.qrSize, height: (context) => Math.min(context.qrSize, 680),
}, },
{ {
id: 'link', id: 'link',
type: 'link', type: 'link',
x: (context) => (context.canvasWidth - 420) / 2, x: (context) => (context.canvasWidth - 580) / 2,
y: (context) => 1000 + context.qrSize, y: (context) => 1260 + Math.min(context.qrSize, 680),
width: 420, width: 580,
height: 110, height: 150,
align: 'center', align: 'center',
}, },
{ {
id: 'cta', id: 'cta',
type: 'cta', type: 'cta',
x: (context) => (context.canvasWidth - 420) / 2, x: (context) => (context.canvasWidth - 580) / 2,
y: (context) => 1030 + context.qrSize + 140, y: (context) => 1300 + Math.min(context.qrSize, 680) + 200,
width: 420, width: 580,
height: 110, height: 150,
align: 'center', align: 'center',
}, },
]; ];
const confettiBashPreset: LayoutPreset = [ const confettiBashPreset: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 140, y: 180, width: 360, height: 90, align: 'center', fontSize: 24 }, { id: 'badge', type: 'badge', x: 180, y: 220, width: 520, height: 120, align: 'center', fontSize: 32 },
{ 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: 'headline',
{ id: 'description', type: 'description', x: 140, y: 680, width: 520, height: 240, fontSize: 26, align: 'left' }, 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', id: 'qr',
type: 'qr', type: 'qr',
x: (context) => context.canvasWidth - context.qrSize - 200, x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 200,
y: 360, y: 460,
width: (context) => context.qrSize, width: (context) => Math.min(context.qrSize, 680),
height: (context) => context.qrSize, height: (context) => Math.min(context.qrSize, 680),
}, },
{ {
id: 'link', id: 'link',
type: 'link', type: 'link',
x: (context) => context.canvasWidth - 420, x: (context) => context.canvasWidth - 560,
y: (context) => 400 + context.qrSize, y: (context) => 520 + Math.min(context.qrSize, 680),
width: 400, width: 520,
height: 110, height: 140,
align: 'center', align: 'center',
}, },
{ {
id: 'cta', id: 'cta',
type: 'cta', type: 'cta',
x: (context) => context.canvasWidth - 420, x: (context) => context.canvasWidth - 560,
y: (context) => 430 + context.qrSize + 140, y: (context) => 560 + Math.min(context.qrSize, 680) + 200,
width: 400, width: 520,
height: 110, height: 140,
align: 'center', 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> = { const LAYOUT_PRESETS: Record<string, LayoutPreset> = {
@@ -513,6 +657,7 @@ export function elementsToPayload(elements: LayoutElement[]): LayoutElementPaylo
})); }));
} }
export function normalizeElements(elements: LayoutElement[]): LayoutElement[] { export function normalizeElements(elements: LayoutElement[]): LayoutElement[] {
const seen = new Set<string>(); const seen = new Set<string>();
return elements return elements

View File

@@ -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.', limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter für ein Upgrade.',
limitUnlimited: 'unbegrenzt', 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}', cameraInactive: 'Kamera ist nicht aktiv. {hint}',
cameraInactiveHint: 'Tippe auf "{label}", um loszulegen.', cameraInactiveHint: 'Tippe auf "{label}", um loszulegen.',
captureError: 'Foto konnte nicht erstellt werden.', 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.', limitReached: 'Upload limit reached ({used} / {max} photos). Contact the organizers for an upgrade.',
limitUnlimited: 'unlimited', 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}', cameraInactive: 'Camera is not active. {hint}',
cameraInactiveHint: 'Tap "{label}" to get started.', cameraInactiveHint: 'Tap "{label}" to get started.',
captureError: 'Photo could not be created.', captureError: 'Photo could not be created.',

View File

@@ -5,7 +5,7 @@ import BottomNav from '../components/BottomNav';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert'; 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 { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
@@ -117,6 +117,7 @@ export default function UploadPage() {
const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null); const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null); const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
const [canUpload, setCanUpload] = useState(true); const [canUpload, setCanUpload] = useState(true);
@@ -262,10 +263,29 @@ export default function UploadPage() {
setCanUpload(true); setCanUpload(true);
setUploadError(null); 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) { } catch (err) {
console.error('Failed to check package limits', err); console.error('Failed to check package limits', err);
setCanUpload(false); setCanUpload(false);
setUploadError(t('upload.limitCheckError')); setUploadError(t('upload.limitCheckError'));
setUploadWarning(null);
} }
}; };
@@ -520,7 +540,42 @@ export default function UploadPage() {
navigateAfterUpload(photoId); navigateAfterUpload(photoId);
} catch (error: unknown) { } catch (error: unknown) {
console.error('Upload failed', error); 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'); setMode('review');
} finally { } finally {
if (uploadProgressTimerRef.current) { if (uploadProgressTimerRef.current) {
@@ -773,6 +828,13 @@ export default function UploadPage() {
</div> </div>
<div className="relative z-30 flex flex-col gap-3 bg-gradient-to-t from-black via-black/80 to-transparent p-4"> <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 && ( {uploadError && (
<Alert variant="destructive" className="bg-red-500/10 text-white"> <Alert variant="destructive" className="bg-red-500/10 text-white">
<AlertDescription className="flex items-center gap-2 text-xs"> <AlertDescription className="flex items-center gap-2 text-xs">

View File

@@ -1,5 +1,11 @@
import { getDeviceId } from '../lib/device'; import { getDeviceId } from '../lib/device';
export type UploadError = Error & {
code?: string;
status?: number;
meta?: Record<string, unknown>;
};
function getCsrfToken(): string | null { function getCsrfToken(): string | null {
// Method 1: Meta tag (preferred for SPA) // Method 1: Meta tag (preferred for SPA)
const metaToken = document.querySelector('meta[name="csrf-token"]'); const metaToken = document.querySelector('meta[name="csrf-token"]');
@@ -56,16 +62,30 @@ export async function likePhoto(id: number): Promise<number> {
}); });
if (!res.ok) { if (!res.ok) {
const errorText = await res.text(); let payload: any = null;
try {
payload = await res.clone().json();
} catch {}
if (res.status === 419) { if (res.status === 419) {
throw new Error('CSRF Token mismatch. This usually means:\n\n' + const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.');
'1. The page needs to be refreshed\n' + error.code = 'csrf_mismatch';
'2. Check if <meta name="csrf-token"> is present in HTML source\n' + error.status = res.status;
'3. API routes might need CSRF exemption in VerifyCsrfToken middleware'); 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(); const json = await res.json();
return json.likes_count ?? json.data?.likes_count ?? 0; return json.likes_count ?? json.data?.likes_count ?? 0;
} }
@@ -85,15 +105,30 @@ export async function uploadPhoto(eventToken: string, file: File, taskId?: numbe
}); });
if (!res.ok) { if (!res.ok) {
const errorText = await res.text(); let payload: any = null;
try {
payload = await res.clone().json();
} catch {}
if (res.status === 419) { if (res.status === 419) {
throw new Error('CSRF Token mismatch during upload.\n\n' + const csrfError: UploadError = new Error(
'This usually means:\n' + 'CSRF token mismatch during upload. Please refresh the page and try again.'
'1. API routes need CSRF exemption in VerifyCsrfToken middleware\n' + );
'2. Check if <meta name="csrf-token"> is present in page source\n' + csrfError.code = 'csrf_mismatch';
'3. The page might need to be refreshed'); 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(); const json = await res.json();

View File

@@ -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.', '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', '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',
],
]; ];

View File

@@ -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.', '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', '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',
],
]; ];

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

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

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

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

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

View File

@@ -2,6 +2,8 @@
namespace Tests\Unit; namespace Tests\Unit;
use App\Events\Packages\TenantPackageEventLimitReached;
use App\Events\Packages\TenantPackageEventThresholdReached;
use App\Models\Event; use App\Models\Event;
use App\Models\Package; use App\Models\Package;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
@@ -9,13 +11,15 @@ use App\Models\Photo;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event as EventFacade;
use Tests\TestCase; use Tests\TestCase;
class TenantModelTest extends TestCase class TenantModelTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
public function testTenantHasManyEvents(): void public function test_tenant_has_many_events(): void
{ {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
Event::factory()->count(3)->create(['tenant_id' => $tenant->id]); Event::factory()->count(3)->create(['tenant_id' => $tenant->id]);
@@ -23,7 +27,7 @@ class TenantModelTest extends TestCase
$this->assertCount(3, $tenant->events()->get()); $this->assertCount(3, $tenant->events()->get());
} }
public function testTenantHasPhotosThroughEvents(): void public function test_tenant_has_photos_through_events(): void
{ {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$event = Event::factory()->create(['tenant_id' => $tenant->id]); $event = Event::factory()->create(['tenant_id' => $tenant->id]);
@@ -32,7 +36,7 @@ class TenantModelTest extends TestCase
$this->assertCount(2, $tenant->photos()->get()); $this->assertCount(2, $tenant->photos()->get());
} }
public function testTenantHasManyPackagePurchases(): void public function test_tenant_has_many_package_purchases(): void
{ {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$package = Package::factory()->create(); $package = Package::factory()->create();
@@ -44,7 +48,7 @@ class TenantModelTest extends TestCase
$this->assertCount(2, $tenant->purchases()->get()); $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(); $tenant = Tenant::factory()->create();
$package = Package::factory()->create(['type' => 'reseller']); $package = Package::factory()->create(['type' => 'reseller']);
@@ -58,21 +62,21 @@ class TenantModelTest extends TestCase
$this->assertTrue($tenant->fresh()->active_subscription); $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(); $tenant = Tenant::factory()->create();
$this->assertFalse($tenant->fresh()->active_subscription); $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(); $tenant = Tenant::factory()->create();
$this->assertFalse($tenant->incrementUsedEvents()); $this->assertFalse($tenant->incrementUsedEvents());
} }
public function testIncrementUsedEventsUpdatesActivePackage(): void public function test_increment_used_events_updates_active_package(): void
{ {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$package = Package::factory()->create(['type' => 'reseller']); $package = Package::factory()->create(['type' => 'reseller']);
@@ -87,7 +91,41 @@ class TenantModelTest extends TestCase
$this->assertEquals(3, $tenantPackage->fresh()->used_events); $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([ $tenant = Tenant::factory()->create([
'settings' => ['theme' => 'dark', 'logo' => 'logo.png'], 'settings' => ['theme' => 'dark', 'logo' => 'logo.png'],
@@ -97,7 +135,7 @@ class TenantModelTest extends TestCase
$this->assertSame('dark', $tenant->settings['theme']); $this->assertSame('dark', $tenant->settings['theme']);
} }
public function testFeaturesCastToArray(): void public function test_features_cast_to_array(): void
{ {
$tenant = Tenant::factory()->create([ $tenant = Tenant::factory()->create([
'features' => ['photo_likes' => true, 'analytics' => false], 'features' => ['photo_likes' => true, 'analytics' => false],
@@ -107,4 +145,23 @@ class TenantModelTest extends TestCase
$this->assertTrue($tenant->features['photo_likes']); $this->assertTrue($tenant->features['photo_likes']);
$this->assertFalse($tenant->features['analytics']); $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);
}
} }