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

View File

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

View File

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

View File

@@ -2,14 +2,17 @@
namespace App\Http\Middleware;
use App\Models\Event;
use App\Models\Tenant;
use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class PackageMiddleware
{
public function __construct(private readonly PackageLimitEvaluator $limitEvaluator) {}
public function handle(Request $request, Closure $next): Response
{
$tenant = $request->attributes->get('tenant');
@@ -23,10 +26,18 @@ class PackageMiddleware
]);
}
if ($this->requiresPackageCheck($request) && ! $this->canPerformAction($request, $tenant)) {
return response()->json([
'error' => 'Package limits exceeded. Please purchase or upgrade a package.',
], 402);
if ($this->requiresPackageCheck($request)) {
$violation = $this->detectViolation($request, $tenant);
if ($violation !== null) {
return ApiError::response(
$violation['code'],
$violation['title'],
$violation['message'],
$violation['status'],
$violation['meta']
);
}
}
return $next($request);
@@ -40,29 +51,30 @@ class PackageMiddleware
);
}
private function canPerformAction(Request $request, Tenant $tenant): bool
private function detectViolation(Request $request, Tenant $tenant): ?array
{
if ($request->routeIs('api.v1.tenant.events.store')) {
return $tenant->hasEventAllowance();
return $this->limitEvaluator->assessEventCreation($tenant);
}
if ($request->routeIs('api.v1.tenant.events.photos.store')) {
$eventId = $request->input('event_id');
$eventId = (int) $request->input('event_id');
if (! $eventId) {
return false;
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 false;
}
$eventPackage = $event->eventPackage;
if (! $eventPackage) {
return false;
}
return $eventPackage->used_photos < ($eventPackage->package->max_photos ?? PHP_INT_MAX);
return $this->limitEvaluator->assessPhotoUpload($tenant, $eventId);
}
return true;
return null;
}
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\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Carbon\Carbon;
class EventPackage extends Model
{
@@ -27,6 +26,8 @@ class EventPackage extends Model
'purchased_price' => 'decimal:2',
'purchased_at' => 'datetime',
'gallery_expires_at' => 'datetime',
'gallery_warning_sent_at' => 'datetime',
'gallery_expired_notified_at' => 'datetime',
'used_photos' => 'integer',
'used_guests' => 'integer',
];
@@ -48,33 +49,37 @@ class EventPackage extends Model
public function canUploadPhoto(): bool
{
if (!$this->isActive()) {
if (! $this->isActive()) {
return false;
}
$maxPhotos = $this->package->max_photos ?? 0;
return $this->used_photos < $maxPhotos;
}
public function canAddGuest(): bool
{
if (!$this->isActive()) {
if (! $this->isActive()) {
return false;
}
$maxGuests = $this->package->max_guests ?? 0;
return $this->used_guests < $maxGuests;
}
public function getRemainingPhotosAttribute(): int
{
$max = $this->package->max_photos ?? 0;
return max(0, $max - $this->used_photos);
}
public function getRemainingGuestsAttribute(): int
{
$max = $this->package->max_guests ?? 0;
return max(0, $max - $this->used_guests);
}
@@ -83,13 +88,13 @@ class EventPackage extends Model
parent::boot();
static::creating(function ($eventPackage) {
if (!$eventPackage->purchased_at) {
if (! $eventPackage->purchased_at) {
$eventPackage->purchased_at = now();
}
if (!$eventPackage->gallery_expires_at && $eventPackage->package) {
if (! $eventPackage->gallery_expires_at && $eventPackage->package) {
$days = $eventPackage->package->gallery_days ?? 30;
$eventPackage->gallery_expires_at = now()->addDays($days);
}
});
}
}
}

View File

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

View File

@@ -30,6 +30,11 @@ class TenantPackage extends Model
'expires_at' => 'datetime',
'used_events' => 'integer',
'active' => 'boolean',
'event_warning_sent_at' => 'datetime',
'event_warning_threshold' => 'float',
'event_limit_notified_at' => 'datetime',
'expiry_warning_sent_at' => 'datetime',
'expired_notified_at' => 'datetime',
];
public function tenant(): BelongsTo

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;
use App\Events\Packages\EventPackageGalleryExpired;
use App\Events\Packages\EventPackageGalleryExpiring;
use App\Events\Packages\EventPackageGuestLimitReached;
use App\Events\Packages\EventPackageGuestThresholdReached;
use App\Events\Packages\EventPackagePhotoLimitReached;
use App\Events\Packages\EventPackagePhotoThresholdReached;
use App\Events\Packages\TenantCreditsLow;
use App\Events\Packages\TenantPackageEventLimitReached;
use App\Events\Packages\TenantPackageEventThresholdReached;
use App\Events\Packages\TenantPackageExpired;
use App\Events\Packages\TenantPackageExpiring;
use App\Listeners\Packages\QueueGalleryExpiredNotification;
use App\Listeners\Packages\QueueGalleryWarningNotification;
use App\Listeners\Packages\QueueGuestLimitNotification;
use App\Listeners\Packages\QueueGuestThresholdNotification;
use App\Listeners\Packages\QueuePhotoLimitNotification;
use App\Listeners\Packages\QueuePhotoThresholdNotification;
use App\Listeners\Packages\QueueTenantCreditsLowNotification;
use App\Listeners\Packages\QueueTenantEventLimitNotification;
use App\Listeners\Packages\QueueTenantEventThresholdNotification;
use App\Listeners\Packages\QueueTenantPackageExpiredNotification;
use App\Listeners\Packages\QueueTenantPackageExpiringNotification;
use App\Notifications\UploadPipelineFailed;
use App\Services\Checkout\CheckoutAssignmentService;
use App\Services\Checkout\CheckoutPaymentService;
use App\Services\Checkout\CheckoutSessionService;
use App\Notifications\UploadPipelineFailed;
use App\Services\Security\PhotoSecurityScanner;
use App\Services\Storage\EventStorageManager;
use App\Services\Storage\StorageHealthService;
use App\Services\Security\PhotoSecurityScanner;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Event as EventFacade;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\RateLimiter;
@@ -40,25 +63,80 @@ class AppServiceProvider extends ServiceProvider
{
$this->app->make(EventStorageManager::class)->registerDynamicDisks();
EventFacade::listen(
EventPackagePhotoThresholdReached::class,
[QueuePhotoThresholdNotification::class, 'handle']
);
EventFacade::listen(
EventPackagePhotoLimitReached::class,
[QueuePhotoLimitNotification::class, 'handle']
);
EventFacade::listen(
EventPackageGuestThresholdReached::class,
[QueueGuestThresholdNotification::class, 'handle']
);
EventFacade::listen(
EventPackageGuestLimitReached::class,
[QueueGuestLimitNotification::class, 'handle']
);
EventFacade::listen(
EventPackageGalleryExpiring::class,
[QueueGalleryWarningNotification::class, 'handle']
);
EventFacade::listen(
EventPackageGalleryExpired::class,
[QueueGalleryExpiredNotification::class, 'handle']
);
EventFacade::listen(
TenantPackageEventThresholdReached::class,
[QueueTenantEventThresholdNotification::class, 'handle']
);
EventFacade::listen(
TenantPackageEventLimitReached::class,
[QueueTenantEventLimitNotification::class, 'handle']
);
EventFacade::listen(
TenantPackageExpiring::class,
[QueueTenantPackageExpiringNotification::class, 'handle']
);
EventFacade::listen(
TenantPackageExpired::class,
[QueueTenantPackageExpiredNotification::class, 'handle']
);
EventFacade::listen(
TenantCreditsLow::class,
[QueueTenantCreditsLowNotification::class, 'handle']
);
RateLimiter::for('tenant-api', function (Request $request) {
$tenantId = $request->attributes->get('tenant_id')
?? $request->user()?->tenant_id
?? $request->user()?->tenant?->id;
$key = $tenantId ? 'tenant:' . $tenantId : ('ip:' . ($request->ip() ?? 'unknown'));
$key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown'));
return Limit::perMinute(100)->by($key);
});
RateLimiter::for('oauth', function (Request $request) {
return Limit::perMinute(10)->by('oauth:' . ($request->ip() ?? 'unknown'));
return Limit::perMinute(10)->by('oauth:'.($request->ip() ?? 'unknown'));
});
Inertia::share('locale', fn () => app()->getLocale());
Inertia::share('analytics', static function () {
$config = config('services.matomo');
if (!($config['enabled'] ?? false)) {
if (! ($config['enabled'] ?? false)) {
return [
'matomo' => [
'enabled' => false,

View File

@@ -58,6 +58,25 @@ class EventJoinTokenService
public function incrementUsage(EventJoinToken $joinToken): void
{
$joinToken->increment('usage_count');
$event = $joinToken->event()
->with(['eventPackage.package', 'eventPackages.package', 'tenant'])
->first();
if ($event && $event->tenant) {
$usageTracker = app(\App\Services\Packages\PackageUsageTracker::class);
$limitEvaluator = app(\App\Services\Packages\PackageLimitEvaluator::class);
$eventPackage = $limitEvaluator->resolveEventPackageForPhotoUpload($event->tenant, $event->id, $event);
if ($eventPackage && $eventPackage->package?->max_guests !== null) {
$previous = (int) $eventPackage->used_guests;
$eventPackage->increment('used_guests');
$eventPackage->refresh();
$usageTracker->recordGuestUsage($eventPackage, $previous, 1);
}
}
}
public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken

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