Implement package limit notification system
This commit is contained in:
158
app/Console/Commands/CheckEventPackages.php
Normal file
158
app/Console/Commands/CheckEventPackages.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\Packages\EventPackageGalleryExpired;
|
||||
use App\Events\Packages\EventPackageGalleryExpiring;
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CheckEventPackages extends Command
|
||||
{
|
||||
protected $signature = 'package:check-status';
|
||||
|
||||
protected $description = 'Check event packages for upcoming gallery expiry and emit notifications.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$warningDays = collect(config('package-limits.gallery_warning_days', []))
|
||||
->filter(fn ($day) => is_numeric($day) && $day > 0)
|
||||
->map(fn ($day) => (int) $day)
|
||||
->unique()
|
||||
->sortDesc() // handle larger days first
|
||||
->values();
|
||||
|
||||
$eventPackageExpiryDays = collect(config('package-limits.package_expiry_days', []))
|
||||
->filter(fn ($day) => is_numeric($day) && $day > 0)
|
||||
->map(fn ($day) => (int) $day)
|
||||
->unique()
|
||||
->sortDesc()
|
||||
->values();
|
||||
|
||||
$creditThresholds = collect(config('package-limits.credit_thresholds', []))
|
||||
->filter(fn ($value) => is_numeric($value) && $value >= 0)
|
||||
->map(fn ($value) => (int) $value)
|
||||
->unique()
|
||||
->sortDesc()
|
||||
->values();
|
||||
|
||||
$maxCreditThreshold = $creditThresholds->max();
|
||||
|
||||
$now = now();
|
||||
|
||||
if ($maxCreditThreshold !== null) {
|
||||
\App\Models\Tenant::query()
|
||||
->select(['id', 'event_credits_balance', 'credit_warning_sent_at', 'credit_warning_threshold', 'contact_email'])
|
||||
->chunkById(100, function ($tenants) use ($creditThresholds, $maxCreditThreshold, $now) {
|
||||
foreach ($tenants as $tenant) {
|
||||
$balance = (int) ($tenant->event_credits_balance ?? 0);
|
||||
|
||||
if ($balance > $maxCreditThreshold && $tenant->credit_warning_sent_at) {
|
||||
$tenant->forceFill([
|
||||
'credit_warning_sent_at' => null,
|
||||
'credit_warning_threshold' => null,
|
||||
])->save();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($creditThresholds as $threshold) {
|
||||
if (
|
||||
$balance <= $threshold
|
||||
&& (
|
||||
$tenant->credit_warning_sent_at === null
|
||||
|| $threshold < ($tenant->credit_warning_threshold ?? PHP_INT_MAX)
|
||||
)
|
||||
) {
|
||||
event(new \App\Events\Packages\TenantCreditsLow($tenant, $balance, $threshold));
|
||||
$tenant->forceFill([
|
||||
'credit_warning_sent_at' => $now,
|
||||
'credit_warning_threshold' => $threshold,
|
||||
])->save();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
EventPackage::query()
|
||||
->whereNotNull('gallery_expires_at')
|
||||
->chunkById(100, function ($packages) use ($warningDays, $now) {
|
||||
foreach ($packages as $package) {
|
||||
$expiresAt = $package->gallery_expires_at;
|
||||
if (! $expiresAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$daysDiff = $now->diffInDays($expiresAt, false);
|
||||
|
||||
if ($daysDiff < 0) {
|
||||
if (! $package->gallery_expired_notified_at) {
|
||||
event(new EventPackageGalleryExpired($package));
|
||||
$package->forceFill([
|
||||
'gallery_expired_notified_at' => $now,
|
||||
])->save();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($warningDays->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($package->gallery_warning_sent_at) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($warningDays as $day) {
|
||||
if ($daysDiff <= $day && $daysDiff >= 0) {
|
||||
event(new EventPackageGalleryExpiring($package, $day));
|
||||
$package->forceFill([
|
||||
'gallery_warning_sent_at' => $now,
|
||||
])->save();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
\App\Models\TenantPackage::query()
|
||||
->with(['tenant', 'package'])
|
||||
->whereNotNull('expires_at')
|
||||
->chunkById(100, function ($tenantPackages) use ($eventPackageExpiryDays, $now) {
|
||||
foreach ($tenantPackages as $tenantPackage) {
|
||||
$expiresAt = $tenantPackage->expires_at;
|
||||
if (! $expiresAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$daysDiff = $now->diffInDays($expiresAt, false);
|
||||
|
||||
if ($daysDiff < 0) {
|
||||
if (! $tenantPackage->expired_notified_at) {
|
||||
event(new \App\Events\Packages\TenantPackageExpired($tenantPackage));
|
||||
$tenantPackage->forceFill(['expired_notified_at' => $now])->save();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($tenantPackage->expiry_warning_sent_at) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($eventPackageExpiryDays as $day) {
|
||||
if ($daysDiff <= $day && $daysDiff >= 0) {
|
||||
event(new \App\Events\Packages\TenantPackageExpiring($tenantPackage, $day));
|
||||
$tenantPackage->forceFill(['expiry_warning_sent_at' => $now])->save();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
15
app/Events/Packages/EventPackageGalleryExpired.php
Normal file
15
app/Events/Packages/EventPackageGalleryExpired.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class EventPackageGalleryExpired
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(public EventPackage $eventPackage) {}
|
||||
}
|
||||
18
app/Events/Packages/EventPackageGalleryExpiring.php
Normal file
18
app/Events/Packages/EventPackageGalleryExpiring.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class EventPackageGalleryExpiring
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public EventPackage $eventPackage,
|
||||
public int $daysRemaining,
|
||||
) {}
|
||||
}
|
||||
18
app/Events/Packages/EventPackageGuestLimitReached.php
Normal file
18
app/Events/Packages/EventPackageGuestLimitReached.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class EventPackageGuestLimitReached
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public EventPackage $eventPackage,
|
||||
public int $limit,
|
||||
) {}
|
||||
}
|
||||
20
app/Events/Packages/EventPackageGuestThresholdReached.php
Normal file
20
app/Events/Packages/EventPackageGuestThresholdReached.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class EventPackageGuestThresholdReached
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public EventPackage $eventPackage,
|
||||
public float $threshold,
|
||||
public int $limit,
|
||||
public int $used,
|
||||
) {}
|
||||
}
|
||||
18
app/Events/Packages/EventPackagePhotoLimitReached.php
Normal file
18
app/Events/Packages/EventPackagePhotoLimitReached.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class EventPackagePhotoLimitReached
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public EventPackage $eventPackage,
|
||||
public int $limit,
|
||||
) {}
|
||||
}
|
||||
20
app/Events/Packages/EventPackagePhotoThresholdReached.php
Normal file
20
app/Events/Packages/EventPackagePhotoThresholdReached.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class EventPackagePhotoThresholdReached
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public EventPackage $eventPackage,
|
||||
public float $threshold,
|
||||
public int $limit,
|
||||
public int $used,
|
||||
) {}
|
||||
}
|
||||
19
app/Events/Packages/TenantCreditsLow.php
Normal file
19
app/Events/Packages/TenantCreditsLow.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Packages;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TenantCreditsLow
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Tenant $tenant,
|
||||
public int $balance,
|
||||
public int $threshold,
|
||||
) {}
|
||||
}
|
||||
18
app/Events/Packages/TenantPackageEventLimitReached.php
Normal file
18
app/Events/Packages/TenantPackageEventLimitReached.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Packages;
|
||||
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TenantPackageEventLimitReached
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public TenantPackage $tenantPackage,
|
||||
public int $limit,
|
||||
) {}
|
||||
}
|
||||
20
app/Events/Packages/TenantPackageEventThresholdReached.php
Normal file
20
app/Events/Packages/TenantPackageEventThresholdReached.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Packages;
|
||||
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TenantPackageEventThresholdReached
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public TenantPackage $tenantPackage,
|
||||
public float $threshold,
|
||||
public int $limit,
|
||||
public int $used,
|
||||
) {}
|
||||
}
|
||||
15
app/Events/Packages/TenantPackageExpired.php
Normal file
15
app/Events/Packages/TenantPackageExpired.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Packages;
|
||||
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TenantPackageExpired
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(public TenantPackage $tenantPackage) {}
|
||||
}
|
||||
15
app/Events/Packages/TenantPackageExpiring.php
Normal file
15
app/Events/Packages/TenantPackageExpiring.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Packages;
|
||||
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TenantPackageExpiring
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(public TenantPackage $tenantPackage, public int $daysRemaining) {}
|
||||
}
|
||||
@@ -2,10 +2,23 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Models\EventMediaAsset;
|
||||
use App\Models\Photo;
|
||||
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Services\Packages\PackageLimitEvaluator;
|
||||
use App\Services\Packages\PackageUsageTracker;
|
||||
use App\Services\Storage\EventStorageManager;
|
||||
use App\Support\ApiError;
|
||||
use App\Support\ImageHelper;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
@@ -13,16 +26,6 @@ use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use App\Support\ImageHelper;
|
||||
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Services\Storage\EventStorageManager;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Arr;
|
||||
use App\Models\Photo;
|
||||
use App\Models\EventMediaAsset;
|
||||
|
||||
class EventPublicController extends BaseController
|
||||
{
|
||||
@@ -32,8 +35,9 @@ class EventPublicController extends BaseController
|
||||
private readonly EventJoinTokenService $joinTokenService,
|
||||
private readonly EventStorageManager $eventStorageManager,
|
||||
private readonly JoinTokenAnalyticsRecorder $analyticsRecorder,
|
||||
) {
|
||||
}
|
||||
private readonly PackageLimitEvaluator $packageLimitEvaluator,
|
||||
private readonly PackageUsageTracker $packageUsageTracker,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return JsonResponse|array{0: object, 1: EventJoinToken}
|
||||
@@ -249,8 +253,7 @@ class EventPublicController extends BaseController
|
||||
array $context = [],
|
||||
?string $rawToken = null,
|
||||
?EventJoinToken $joinToken = null
|
||||
): JsonResponse
|
||||
{
|
||||
): JsonResponse {
|
||||
$failureLimit = max(1, (int) config('join_tokens.failure_limit', 10));
|
||||
$failureDecay = max(1, (int) config('join_tokens.failure_decay_minutes', 5));
|
||||
|
||||
@@ -393,22 +396,33 @@ class EventPublicController extends BaseController
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getLocalized($value, $locale, $default = '') {
|
||||
private function getLocalized($value, $locale, $default = '')
|
||||
{
|
||||
if (is_string($value) && json_decode($value) !== null) {
|
||||
$data = json_decode($value, true);
|
||||
|
||||
return $data[$locale] ?? $data['de'] ?? $default;
|
||||
}
|
||||
|
||||
return $value ?: $default;
|
||||
}
|
||||
|
||||
private function toPublicUrl(?string $path): ?string
|
||||
{
|
||||
if (! $path) return null;
|
||||
if (! $path) {
|
||||
return null;
|
||||
}
|
||||
// Already absolute URL
|
||||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) return $path;
|
||||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
||||
return $path;
|
||||
}
|
||||
// Already a public storage URL
|
||||
if (str_starts_with($path, '/storage/')) return $path;
|
||||
if (str_starts_with($path, 'storage/')) return '/' . $path;
|
||||
if (str_starts_with($path, '/storage/')) {
|
||||
return $path;
|
||||
}
|
||||
if (str_starts_with($path, 'storage/')) {
|
||||
return '/'.$path;
|
||||
}
|
||||
|
||||
// Common relative paths stored in DB (e.g. 'photos/...', 'thumbnails/...', 'events/...')
|
||||
if (str_starts_with($path, 'photos/') || str_starts_with($path, 'thumbnails/') || str_starts_with($path, 'events/')) {
|
||||
@@ -420,7 +434,8 @@ class EventPublicController extends BaseController
|
||||
$needle = '/storage/app/public/';
|
||||
if (str_contains($normalized, $needle)) {
|
||||
$rel = substr($normalized, strpos($normalized, $needle) + strlen($needle));
|
||||
return '/storage/' . ltrim($rel, '/');
|
||||
|
||||
return '/storage/'.ltrim($rel, '/');
|
||||
}
|
||||
|
||||
return $path; // fallback as-is
|
||||
@@ -768,6 +783,7 @@ class EventPublicController extends BaseController
|
||||
'disk' => $diskName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -952,7 +968,7 @@ class EventPublicController extends BaseController
|
||||
return $result;
|
||||
}
|
||||
|
||||
[$event] = $result;
|
||||
[$event, $joinToken] = $result;
|
||||
$eventId = $event->id;
|
||||
$locale = $request->query('locale', 'de');
|
||||
|
||||
@@ -965,7 +981,7 @@ class EventPublicController extends BaseController
|
||||
'emotions.id',
|
||||
'emotions.name',
|
||||
'emotions.icon as emoji',
|
||||
'emotions.description'
|
||||
'emotions.description',
|
||||
])
|
||||
->orderBy('emotions.sort_order')
|
||||
->get();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Packages\PackageLimitEvaluator;
|
||||
use App\Support\ApiError;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CreditCheckMiddleware
|
||||
{
|
||||
public function __construct(private readonly PackageLimitEvaluator $limitEvaluator) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
@@ -22,10 +26,18 @@ class CreditCheckMiddleware
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->requiresCredits($request) && !$tenant->canCreateEvent()) {
|
||||
return response()->json([
|
||||
'error' => 'No available package for creating events. Please purchase a package.',
|
||||
], 402);
|
||||
if ($this->requiresCredits($request)) {
|
||||
$violation = $this->limitEvaluator->assessEventCreation($tenant);
|
||||
|
||||
if ($violation !== null) {
|
||||
return ApiError::response(
|
||||
$violation['code'],
|
||||
$violation['title'],
|
||||
$violation['message'],
|
||||
$violation['status'],
|
||||
$violation['meta']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Packages\PackageLimitEvaluator;
|
||||
use App\Support\ApiError;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PackageMiddleware
|
||||
{
|
||||
public function __construct(private readonly PackageLimitEvaluator $limitEvaluator) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
@@ -23,10 +26,18 @@ class PackageMiddleware
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->requiresPackageCheck($request) && ! $this->canPerformAction($request, $tenant)) {
|
||||
return response()->json([
|
||||
'error' => 'Package limits exceeded. Please purchase or upgrade a package.',
|
||||
], 402);
|
||||
if ($this->requiresPackageCheck($request)) {
|
||||
$violation = $this->detectViolation($request, $tenant);
|
||||
|
||||
if ($violation !== null) {
|
||||
return ApiError::response(
|
||||
$violation['code'],
|
||||
$violation['title'],
|
||||
$violation['message'],
|
||||
$violation['status'],
|
||||
$violation['meta']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
@@ -40,29 +51,30 @@ class PackageMiddleware
|
||||
);
|
||||
}
|
||||
|
||||
private function canPerformAction(Request $request, Tenant $tenant): bool
|
||||
private function detectViolation(Request $request, Tenant $tenant): ?array
|
||||
{
|
||||
if ($request->routeIs('api.v1.tenant.events.store')) {
|
||||
return $tenant->hasEventAllowance();
|
||||
return $this->limitEvaluator->assessEventCreation($tenant);
|
||||
}
|
||||
|
||||
if ($request->routeIs('api.v1.tenant.events.photos.store')) {
|
||||
$eventId = $request->input('event_id');
|
||||
$eventId = (int) $request->input('event_id');
|
||||
if (! $eventId) {
|
||||
return false;
|
||||
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
|
||||
|
||||
64
app/Jobs/Packages/SendEventPackageGalleryExpired.php
Normal file
64
app/Jobs/Packages/SendEventPackageGalleryExpired.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use App\Notifications\Packages\EventPackageGalleryExpiredNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class SendEventPackageGalleryExpired implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(public int $eventPackageId) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$eventPackage = EventPackage::with(['event', 'package', 'event.tenant'])->find($this->eventPackageId);
|
||||
|
||||
if (! $eventPackage) {
|
||||
Log::warning('Gallery expired job skipped; event package missing', [
|
||||
'event_package_id' => $this->eventPackageId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $eventPackage->event?->tenant;
|
||||
if (! $tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenant, 'gallery_expired')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenant->contact_email,
|
||||
$tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
if ($emails->isEmpty()) {
|
||||
Log::info('Gallery expired notification skipped due to missing recipients', [
|
||||
'event_package_id' => $eventPackage->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
Notification::route('mail', $email)->notify(new EventPackageGalleryExpiredNotification($eventPackage));
|
||||
}
|
||||
}
|
||||
}
|
||||
71
app/Jobs/Packages/SendEventPackageGalleryWarning.php
Normal file
71
app/Jobs/Packages/SendEventPackageGalleryWarning.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use App\Notifications\Packages\EventPackageGalleryExpiringNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class SendEventPackageGalleryWarning implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $eventPackageId,
|
||||
public int $daysRemaining,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$eventPackage = EventPackage::with(['event', 'package', 'event.tenant'])->find($this->eventPackageId);
|
||||
|
||||
if (! $eventPackage) {
|
||||
Log::warning('Gallery warning job skipped; event package missing', [
|
||||
'event_package_id' => $this->eventPackageId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $eventPackage->event?->tenant;
|
||||
if (! $tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenant, 'gallery_warnings')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenant->contact_email,
|
||||
$tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
if ($emails->isEmpty()) {
|
||||
Log::info('Gallery warning skipped due to missing recipients', [
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
Notification::route('mail', $email)->notify(new EventPackageGalleryExpiringNotification(
|
||||
$eventPackage,
|
||||
$this->daysRemaining,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
70
app/Jobs/Packages/SendEventPackageGuestLimitNotification.php
Normal file
70
app/Jobs/Packages/SendEventPackageGuestLimitNotification.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use App\Notifications\Packages\EventPackageGuestLimitNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class SendEventPackageGuestLimitNotification implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $eventPackageId,
|
||||
public int $limit,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$eventPackage = EventPackage::with(['event', 'package', 'event.tenant'])->find($this->eventPackageId);
|
||||
|
||||
if (! $eventPackage) {
|
||||
Log::warning('Guest limit job skipped; event package missing', [
|
||||
'event_package_id' => $this->eventPackageId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $eventPackage->event?->tenant;
|
||||
if (! $tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenant, 'guest_limits')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenant->contact_email,
|
||||
$tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
if ($emails->isEmpty()) {
|
||||
Log::info('Guest limit notification skipped due to missing recipients', [
|
||||
'event_package_id' => $eventPackage->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
Notification::route('mail', $email)->notify(new EventPackageGuestLimitNotification(
|
||||
$eventPackage,
|
||||
$this->limit,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
75
app/Jobs/Packages/SendEventPackageGuestThresholdWarning.php
Normal file
75
app/Jobs/Packages/SendEventPackageGuestThresholdWarning.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use App\Notifications\Packages\EventPackageGuestThresholdNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class SendEventPackageGuestThresholdWarning implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $eventPackageId,
|
||||
public float $threshold,
|
||||
public int $limit,
|
||||
public int $used,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$eventPackage = EventPackage::with(['event', 'package', 'event.tenant'])->find($this->eventPackageId);
|
||||
|
||||
if (! $eventPackage) {
|
||||
Log::warning('Guest threshold job skipped; event package missing', [
|
||||
'event_package_id' => $this->eventPackageId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $eventPackage->event?->tenant;
|
||||
if (! $tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenant, 'guest_thresholds')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenant->contact_email,
|
||||
$tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
if ($emails->isEmpty()) {
|
||||
Log::info('Guest threshold notification skipped due to missing recipients', [
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'threshold' => $this->threshold,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
Notification::route('mail', $email)->notify(new EventPackageGuestThresholdNotification(
|
||||
$eventPackage,
|
||||
$this->threshold,
|
||||
$this->limit,
|
||||
$this->used,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
73
app/Jobs/Packages/SendEventPackagePhotoLimitNotification.php
Normal file
73
app/Jobs/Packages/SendEventPackagePhotoLimitNotification.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use App\Notifications\Packages\EventPackagePhotoLimitNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class SendEventPackagePhotoLimitNotification implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $eventPackageId,
|
||||
public int $limit,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$eventPackage = EventPackage::with(['event', 'package', 'event.tenant'])->find($this->eventPackageId);
|
||||
|
||||
if (! $eventPackage) {
|
||||
Log::warning('Package limit job skipped; event package missing', [
|
||||
'event_package_id' => $this->eventPackageId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $eventPackage->event?->tenant;
|
||||
if (! $tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenant, 'photo_limits')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenant->contact_email,
|
||||
$tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
if ($emails->isEmpty()) {
|
||||
Log::info('Package limit notification skipped due to missing recipients', [
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'limit' => $this->limit,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
Notification::route('mail', $email)->notify(
|
||||
new EventPackagePhotoLimitNotification(
|
||||
$eventPackage,
|
||||
$this->limit,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
77
app/Jobs/Packages/SendEventPackagePhotoThresholdWarning.php
Normal file
77
app/Jobs/Packages/SendEventPackagePhotoThresholdWarning.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use App\Notifications\Packages\EventPackagePhotoThresholdNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class SendEventPackagePhotoThresholdWarning implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $eventPackageId,
|
||||
public float $threshold,
|
||||
public int $limit,
|
||||
public int $used,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$eventPackage = EventPackage::with(['event', 'package', 'event.tenant'])->find($this->eventPackageId);
|
||||
|
||||
if (! $eventPackage) {
|
||||
Log::warning('Package threshold job skipped; event package missing', [
|
||||
'event_package_id' => $this->eventPackageId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $eventPackage->event?->tenant;
|
||||
if (! $tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenant, 'photo_thresholds')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenant->contact_email,
|
||||
$tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
if ($emails->isEmpty()) {
|
||||
Log::info('Package threshold notification skipped due to missing recipients', [
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'threshold' => $this->threshold,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
Notification::route('mail', $email)->notify(
|
||||
new EventPackagePhotoThresholdNotification(
|
||||
$eventPackage,
|
||||
$this->threshold,
|
||||
$this->limit,
|
||||
$this->used,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
app/Jobs/Packages/SendTenantCreditsLowNotification.php
Normal file
67
app/Jobs/Packages/SendTenantCreditsLowNotification.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\Packages\TenantCreditsLowNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class SendTenantCreditsLowNotification implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $balance,
|
||||
public int $threshold,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$tenant = Tenant::find($this->tenantId);
|
||||
|
||||
if (! $tenant) {
|
||||
Log::warning('Tenant credits low job skipped; tenant missing', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenant, 'credits_low')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenant->contact_email,
|
||||
$tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
if ($emails->isEmpty()) {
|
||||
Log::info('Tenant credits low notification skipped due to missing recipients', [
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
Notification::route('mail', $email)->notify(new TenantCreditsLowNotification(
|
||||
$tenant,
|
||||
$this->balance,
|
||||
$this->threshold,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Models\TenantPackage;
|
||||
use App\Notifications\Packages\TenantPackageEventLimitNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class SendTenantPackageEventLimitNotification implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantPackageId,
|
||||
public int $limit,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$tenantPackage = TenantPackage::with(['tenant', 'package'])->find($this->tenantPackageId);
|
||||
|
||||
if (! $tenantPackage || ! $tenantPackage->tenant) {
|
||||
Log::warning('Tenant package event limit job skipped', [
|
||||
'tenant_package_id' => $this->tenantPackageId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenantPackage->tenant, 'event_limits')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenantPackage->tenant->contact_email,
|
||||
$tenantPackage->tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
if ($emails->isEmpty()) {
|
||||
Log::info('Tenant package event limit notification skipped due to missing recipients', [
|
||||
'tenant_package_id' => $tenantPackage->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
Notification::route('mail', $email)->notify(new TenantPackageEventLimitNotification(
|
||||
$tenantPackage,
|
||||
$this->limit,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
70
app/Jobs/Packages/SendTenantPackageEventThresholdWarning.php
Normal file
70
app/Jobs/Packages/SendTenantPackageEventThresholdWarning.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Models\TenantPackage;
|
||||
use App\Notifications\Packages\TenantPackageEventThresholdNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class SendTenantPackageEventThresholdWarning implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantPackageId,
|
||||
public float $threshold,
|
||||
public int $limit,
|
||||
public int $used,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$tenantPackage = TenantPackage::with(['tenant', 'package'])->find($this->tenantPackageId);
|
||||
|
||||
if (! $tenantPackage || ! $tenantPackage->tenant) {
|
||||
Log::warning('Tenant package event threshold job skipped', [
|
||||
'tenant_package_id' => $this->tenantPackageId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenantPackage->tenant, 'event_thresholds')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenantPackage->tenant->contact_email,
|
||||
$tenantPackage->tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
if ($emails->isEmpty()) {
|
||||
Log::info('Tenant package event threshold notification skipped due to missing recipients', [
|
||||
'tenant_package_id' => $tenantPackage->id,
|
||||
'threshold' => $this->threshold,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
Notification::route('mail', $email)->notify(new TenantPackageEventThresholdNotification(
|
||||
$tenantPackage,
|
||||
$this->threshold,
|
||||
$this->limit,
|
||||
$this->used,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/Jobs/Packages/SendTenantPackageExpiredNotification.php
Normal file
59
app/Jobs/Packages/SendTenantPackageExpiredNotification.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Models\TenantPackage;
|
||||
use App\Notifications\Packages\TenantPackageExpiredNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class SendTenantPackageExpiredNotification implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(public int $tenantPackageId) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$tenantPackage = TenantPackage::with(['tenant', 'package'])->find($this->tenantPackageId);
|
||||
|
||||
if (! $tenantPackage || ! $tenantPackage->tenant) {
|
||||
Log::warning('Tenant package expired job skipped', [
|
||||
'tenant_package_id' => $this->tenantPackageId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenantPackage->tenant, 'package_expired')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenantPackage->tenant->contact_email,
|
||||
$tenantPackage->tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
if ($emails->isEmpty()) {
|
||||
Log::info('Tenant package expired notification skipped due to missing recipients', [
|
||||
'tenant_package_id' => $tenantPackage->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
Notification::route('mail', $email)->notify(new TenantPackageExpiredNotification($tenantPackage));
|
||||
}
|
||||
}
|
||||
}
|
||||
66
app/Jobs/Packages/SendTenantPackageExpiringNotification.php
Normal file
66
app/Jobs/Packages/SendTenantPackageExpiringNotification.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Packages;
|
||||
|
||||
use App\Models\TenantPackage;
|
||||
use App\Notifications\Packages\TenantPackageExpiringNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class SendTenantPackageExpiringNotification implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantPackageId,
|
||||
public int $daysRemaining,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$tenantPackage = TenantPackage::with(['tenant', 'package'])->find($this->tenantPackageId);
|
||||
|
||||
if (! $tenantPackage || ! $tenantPackage->tenant) {
|
||||
Log::warning('Tenant package expiry warning skipped', [
|
||||
'tenant_package_id' => $this->tenantPackageId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||
if (! $preferences->shouldNotify($tenantPackage->tenant, 'package_expiring')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emails = collect([
|
||||
$tenantPackage->tenant->contact_email,
|
||||
$tenantPackage->tenant->user?->email,
|
||||
])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique();
|
||||
|
||||
if ($emails->isEmpty()) {
|
||||
Log::info('Tenant package expiry warning skipped due to missing recipients', [
|
||||
'tenant_package_id' => $tenantPackage->id,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($emails as $email) {
|
||||
Notification::route('mail', $email)->notify(new TenantPackageExpiringNotification(
|
||||
$tenantPackage,
|
||||
$this->daysRemaining,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
14
app/Listeners/Packages/QueueGalleryExpiredNotification.php
Normal file
14
app/Listeners/Packages/QueueGalleryExpiredNotification.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Packages;
|
||||
|
||||
use App\Events\Packages\EventPackageGalleryExpired;
|
||||
use App\Jobs\Packages\SendEventPackageGalleryExpired;
|
||||
|
||||
class QueueGalleryExpiredNotification
|
||||
{
|
||||
public function handle(EventPackageGalleryExpired $event): void
|
||||
{
|
||||
SendEventPackageGalleryExpired::dispatch($event->eventPackage->id);
|
||||
}
|
||||
}
|
||||
17
app/Listeners/Packages/QueueGalleryWarningNotification.php
Normal file
17
app/Listeners/Packages/QueueGalleryWarningNotification.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Packages;
|
||||
|
||||
use App\Events\Packages\EventPackageGalleryExpiring;
|
||||
use App\Jobs\Packages\SendEventPackageGalleryWarning;
|
||||
|
||||
class QueueGalleryWarningNotification
|
||||
{
|
||||
public function handle(EventPackageGalleryExpiring $event): void
|
||||
{
|
||||
SendEventPackageGalleryWarning::dispatch(
|
||||
$event->eventPackage->id,
|
||||
$event->daysRemaining
|
||||
);
|
||||
}
|
||||
}
|
||||
17
app/Listeners/Packages/QueueGuestLimitNotification.php
Normal file
17
app/Listeners/Packages/QueueGuestLimitNotification.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Packages;
|
||||
|
||||
use App\Events\Packages\EventPackageGuestLimitReached;
|
||||
use App\Jobs\Packages\SendEventPackageGuestLimitNotification;
|
||||
|
||||
class QueueGuestLimitNotification
|
||||
{
|
||||
public function handle(EventPackageGuestLimitReached $event): void
|
||||
{
|
||||
SendEventPackageGuestLimitNotification::dispatch(
|
||||
$event->eventPackage->id,
|
||||
$event->limit
|
||||
);
|
||||
}
|
||||
}
|
||||
19
app/Listeners/Packages/QueueGuestThresholdNotification.php
Normal file
19
app/Listeners/Packages/QueueGuestThresholdNotification.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Packages;
|
||||
|
||||
use App\Events\Packages\EventPackageGuestThresholdReached;
|
||||
use App\Jobs\Packages\SendEventPackageGuestThresholdWarning;
|
||||
|
||||
class QueueGuestThresholdNotification
|
||||
{
|
||||
public function handle(EventPackageGuestThresholdReached $event): void
|
||||
{
|
||||
SendEventPackageGuestThresholdWarning::dispatch(
|
||||
$event->eventPackage->id,
|
||||
$event->threshold,
|
||||
$event->limit,
|
||||
$event->used
|
||||
);
|
||||
}
|
||||
}
|
||||
17
app/Listeners/Packages/QueuePhotoLimitNotification.php
Normal file
17
app/Listeners/Packages/QueuePhotoLimitNotification.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Packages;
|
||||
|
||||
use App\Events\Packages\EventPackagePhotoLimitReached;
|
||||
use App\Jobs\Packages\SendEventPackagePhotoLimitNotification;
|
||||
|
||||
class QueuePhotoLimitNotification
|
||||
{
|
||||
public function handle(EventPackagePhotoLimitReached $event): void
|
||||
{
|
||||
SendEventPackagePhotoLimitNotification::dispatch(
|
||||
$event->eventPackage->id,
|
||||
$event->limit
|
||||
);
|
||||
}
|
||||
}
|
||||
19
app/Listeners/Packages/QueuePhotoThresholdNotification.php
Normal file
19
app/Listeners/Packages/QueuePhotoThresholdNotification.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Packages;
|
||||
|
||||
use App\Events\Packages\EventPackagePhotoThresholdReached;
|
||||
use App\Jobs\Packages\SendEventPackagePhotoThresholdWarning;
|
||||
|
||||
class QueuePhotoThresholdNotification
|
||||
{
|
||||
public function handle(EventPackagePhotoThresholdReached $event): void
|
||||
{
|
||||
SendEventPackagePhotoThresholdWarning::dispatch(
|
||||
$event->eventPackage->id,
|
||||
$event->threshold,
|
||||
$event->limit,
|
||||
$event->used
|
||||
);
|
||||
}
|
||||
}
|
||||
18
app/Listeners/Packages/QueueTenantCreditsLowNotification.php
Normal file
18
app/Listeners/Packages/QueueTenantCreditsLowNotification.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Packages;
|
||||
|
||||
use App\Events\Packages\TenantCreditsLow;
|
||||
use App\Jobs\Packages\SendTenantCreditsLowNotification;
|
||||
|
||||
class QueueTenantCreditsLowNotification
|
||||
{
|
||||
public function handle(TenantCreditsLow $event): void
|
||||
{
|
||||
SendTenantCreditsLowNotification::dispatch(
|
||||
$event->tenant->id,
|
||||
$event->balance,
|
||||
$event->threshold
|
||||
);
|
||||
}
|
||||
}
|
||||
17
app/Listeners/Packages/QueueTenantEventLimitNotification.php
Normal file
17
app/Listeners/Packages/QueueTenantEventLimitNotification.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Packages;
|
||||
|
||||
use App\Events\Packages\TenantPackageEventLimitReached;
|
||||
use App\Jobs\Packages\SendTenantPackageEventLimitNotification;
|
||||
|
||||
class QueueTenantEventLimitNotification
|
||||
{
|
||||
public function handle(TenantPackageEventLimitReached $event): void
|
||||
{
|
||||
SendTenantPackageEventLimitNotification::dispatch(
|
||||
$event->tenantPackage->id,
|
||||
$event->limit
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Packages;
|
||||
|
||||
use App\Events\Packages\TenantPackageEventThresholdReached;
|
||||
use App\Jobs\Packages\SendTenantPackageEventThresholdWarning;
|
||||
|
||||
class QueueTenantEventThresholdNotification
|
||||
{
|
||||
public function handle(TenantPackageEventThresholdReached $event): void
|
||||
{
|
||||
SendTenantPackageEventThresholdWarning::dispatch(
|
||||
$event->tenantPackage->id,
|
||||
$event->threshold,
|
||||
$event->limit,
|
||||
$event->used
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Packages;
|
||||
|
||||
use App\Events\Packages\TenantPackageExpired;
|
||||
use App\Jobs\Packages\SendTenantPackageExpiredNotification;
|
||||
|
||||
class QueueTenantPackageExpiredNotification
|
||||
{
|
||||
public function handle(TenantPackageExpired $event): void
|
||||
{
|
||||
SendTenantPackageExpiredNotification::dispatch($event->tenantPackage->id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Packages;
|
||||
|
||||
use App\Events\Packages\TenantPackageExpiring;
|
||||
use App\Jobs\Packages\SendTenantPackageExpiringNotification;
|
||||
|
||||
class QueueTenantPackageExpiringNotification
|
||||
{
|
||||
public function handle(TenantPackageExpiring $event): void
|
||||
{
|
||||
SendTenantPackageExpiringNotification::dispatch(
|
||||
$event->tenantPackage->id,
|
||||
$event->daysRemaining
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class EventPackage extends Model
|
||||
{
|
||||
@@ -27,6 +26,8 @@ class EventPackage extends Model
|
||||
'purchased_price' => 'decimal:2',
|
||||
'purchased_at' => 'datetime',
|
||||
'gallery_expires_at' => 'datetime',
|
||||
'gallery_warning_sent_at' => 'datetime',
|
||||
'gallery_expired_notified_at' => 'datetime',
|
||||
'used_photos' => 'integer',
|
||||
'used_guests' => 'integer',
|
||||
];
|
||||
@@ -48,33 +49,37 @@ class EventPackage extends Model
|
||||
|
||||
public function canUploadPhoto(): bool
|
||||
{
|
||||
if (!$this->isActive()) {
|
||||
if (! $this->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxPhotos = $this->package->max_photos ?? 0;
|
||||
|
||||
return $this->used_photos < $maxPhotos;
|
||||
}
|
||||
|
||||
public function canAddGuest(): bool
|
||||
{
|
||||
if (!$this->isActive()) {
|
||||
if (! $this->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxGuests = $this->package->max_guests ?? 0;
|
||||
|
||||
return $this->used_guests < $maxGuests;
|
||||
}
|
||||
|
||||
public function getRemainingPhotosAttribute(): int
|
||||
{
|
||||
$max = $this->package->max_photos ?? 0;
|
||||
|
||||
return max(0, $max - $this->used_photos);
|
||||
}
|
||||
|
||||
public function getRemainingGuestsAttribute(): int
|
||||
{
|
||||
$max = $this->package->max_guests ?? 0;
|
||||
|
||||
return max(0, $max - $this->used_guests);
|
||||
}
|
||||
|
||||
@@ -83,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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,30 +5,31 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\EventCreditsLedger;
|
||||
|
||||
class Tenant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'tenants';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'features' => 'array',
|
||||
'settings' => 'array',
|
||||
'notification_preferences' => 'array',
|
||||
'last_activity_at' => 'datetime',
|
||||
'total_revenue' => 'decimal:2',
|
||||
'settings_updated_at' => 'datetime',
|
||||
'subscription_expires_at' => 'datetime',
|
||||
'credit_warning_sent_at' => 'datetime',
|
||||
'credit_warning_threshold' => 'integer',
|
||||
];
|
||||
|
||||
public function events(): HasMany
|
||||
@@ -83,6 +84,7 @@ class Tenant extends Model
|
||||
}
|
||||
|
||||
$package->increment('used_events', $amount);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -90,6 +92,7 @@ class Tenant extends Model
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$this->attributes['settings'] = $value;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,6 +108,22 @@ class Tenant extends Model
|
||||
$balance = (int) ($this->event_credits_balance ?? 0) + $amount;
|
||||
$this->forceFill(['event_credits_balance' => $balance])->save();
|
||||
|
||||
$maxThreshold = collect(config('package-limits.credit_thresholds', []))
|
||||
->filter(fn ($value) => is_numeric($value) && $value >= 0)
|
||||
->map(fn ($value) => (int) $value)
|
||||
->max();
|
||||
|
||||
if (
|
||||
$maxThreshold !== null
|
||||
&& $balance > $maxThreshold
|
||||
&& ($this->credit_warning_sent_at !== null || $this->credit_warning_threshold !== null)
|
||||
) {
|
||||
$this->forceFill([
|
||||
'credit_warning_sent_at' => null,
|
||||
'credit_warning_threshold' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
EventCreditsLedger::create([
|
||||
'tenant_id' => $this->id,
|
||||
'delta' => $amount,
|
||||
@@ -134,6 +153,12 @@ class Tenant extends Model
|
||||
$balance = $current - $amount;
|
||||
$this->forceFill(['event_credits_balance' => $balance])->save();
|
||||
|
||||
app(\App\Services\Packages\TenantUsageTracker::class)->recordCreditBalance(
|
||||
$this,
|
||||
$current,
|
||||
$balance
|
||||
);
|
||||
|
||||
EventCreditsLedger::create([
|
||||
'tenant_id' => $this->id,
|
||||
'delta' => -$amount,
|
||||
@@ -166,7 +191,15 @@ class Tenant extends Model
|
||||
{
|
||||
$package = $this->getActiveResellerPackage();
|
||||
if ($package && $package->canCreateEvent()) {
|
||||
$previousUsed = (int) $package->used_events;
|
||||
$package->increment('used_events', $amount);
|
||||
$package->refresh();
|
||||
|
||||
app(\App\Services\Packages\TenantUsageTracker::class)->recordEventUsage(
|
||||
$package,
|
||||
$previousUsed,
|
||||
$amount
|
||||
);
|
||||
|
||||
Log::info('Tenant package usage recorded', [
|
||||
'tenant_id' => $this->id,
|
||||
|
||||
@@ -30,6 +30,11 @@ class TenantPackage extends Model
|
||||
'expires_at' => 'datetime',
|
||||
'used_events' => 'integer',
|
||||
'active' => 'boolean',
|
||||
'event_warning_sent_at' => 'datetime',
|
||||
'event_warning_threshold' => 'float',
|
||||
'event_limit_notified_at' => 'datetime',
|
||||
'expiry_warning_sent_at' => 'datetime',
|
||||
'expired_notified_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class EventPackageGalleryExpiredNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(private readonly EventPackage $eventPackage) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$event = $this->eventPackage->event;
|
||||
$tenant = $event?->tenant;
|
||||
$package = $this->eventPackage->package;
|
||||
|
||||
$eventName = $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback');
|
||||
$url = url('/tenant/events/'.($event?->slug ?? ''));
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('emails.package_limits.gallery_expired.subject', [
|
||||
'event' => $eventName,
|
||||
]))
|
||||
->greeting(__('emails.package_limits.gallery_expired.greeting', [
|
||||
'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'),
|
||||
]))
|
||||
->line(__('emails.package_limits.gallery_expired.body', [
|
||||
'event' => $eventName,
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'date' => optional($this->eventPackage->gallery_expires_at)->toFormattedDateString(),
|
||||
]))
|
||||
->action(__('emails.package_limits.gallery_expired.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class EventPackageGalleryExpiringNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventPackage $eventPackage,
|
||||
private readonly int $daysRemaining,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$event = $this->eventPackage->event;
|
||||
$tenant = $event?->tenant;
|
||||
$package = $this->eventPackage->package;
|
||||
|
||||
$eventName = $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback');
|
||||
$url = url('/tenant/events/'.($event?->slug ?? ''));
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(trans_choice('emails.package_limits.gallery_warning.subject', $this->daysRemaining, [
|
||||
'event' => $eventName,
|
||||
'days' => $this->daysRemaining,
|
||||
]))
|
||||
->greeting(__('emails.package_limits.gallery_warning.greeting', [
|
||||
'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'),
|
||||
]))
|
||||
->line(trans_choice('emails.package_limits.gallery_warning.body', $this->daysRemaining, [
|
||||
'event' => $eventName,
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'days' => $this->daysRemaining,
|
||||
'date' => optional($this->eventPackage->gallery_expires_at)->toFormattedDateString(),
|
||||
]))
|
||||
->action(__('emails.package_limits.gallery_warning.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class EventPackageGuestLimitNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventPackage $eventPackage,
|
||||
private readonly int $limit,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$event = $this->eventPackage->event;
|
||||
$tenant = $event?->tenant;
|
||||
$package = $this->eventPackage->package;
|
||||
|
||||
$eventName = $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback');
|
||||
$url = url('/tenant/events/'.($event?->slug ?? ''));
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('emails.package_limits.guest_limit.subject', [
|
||||
'event' => $eventName,
|
||||
]))
|
||||
->greeting(__('emails.package_limits.guest_limit.greeting', [
|
||||
'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'),
|
||||
]))
|
||||
->line(__('emails.package_limits.guest_limit.body', [
|
||||
'event' => $eventName,
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'limit' => $this->limit,
|
||||
]))
|
||||
->action(__('emails.package_limits.guest_limit.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class EventPackageGuestThresholdNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventPackage $eventPackage,
|
||||
private readonly float $threshold,
|
||||
private readonly int $limit,
|
||||
private readonly int $used,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$event = $this->eventPackage->event;
|
||||
$tenant = $event?->tenant;
|
||||
$package = $this->eventPackage->package;
|
||||
|
||||
$percentage = (int) round($this->threshold * 100);
|
||||
$remaining = max(0, $this->limit - $this->used);
|
||||
$eventName = $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback');
|
||||
$url = url('/tenant/events/'.($event?->slug ?? ''));
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('emails.package_limits.guest_threshold.subject', [
|
||||
'event' => $eventName,
|
||||
'percentage' => $percentage,
|
||||
]))
|
||||
->greeting(__('emails.package_limits.guest_threshold.greeting', [
|
||||
'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'),
|
||||
]))
|
||||
->line(__('emails.package_limits.guest_threshold.body', [
|
||||
'event' => $eventName,
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'percentage' => $percentage,
|
||||
'used' => $this->used,
|
||||
'limit' => $this->limit,
|
||||
'remaining' => $remaining,
|
||||
]))
|
||||
->action(__('emails.package_limits.guest_threshold.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class EventPackagePhotoLimitNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventPackage $eventPackage,
|
||||
private readonly int $limit,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$event = $this->eventPackage->event;
|
||||
$tenant = $event?->tenant;
|
||||
$package = $this->eventPackage->package;
|
||||
|
||||
$eventName = $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback');
|
||||
$url = url('/tenant/events/'.($event?->slug ?? ''));
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('emails.package_limits.photo_limit.subject', [
|
||||
'event' => $eventName,
|
||||
]))
|
||||
->greeting(__('emails.package_limits.photo_limit.greeting', [
|
||||
'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'),
|
||||
]))
|
||||
->line(__('emails.package_limits.photo_limit.body', [
|
||||
'event' => $eventName,
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'limit' => $this->limit,
|
||||
]))
|
||||
->action(__('emails.package_limits.photo_limit.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Packages;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class EventPackagePhotoThresholdNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventPackage $eventPackage,
|
||||
private readonly float $threshold,
|
||||
private readonly int $limit,
|
||||
private readonly int $used,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$event = $this->eventPackage->event;
|
||||
$tenant = $event?->tenant;
|
||||
$package = $this->eventPackage->package;
|
||||
|
||||
$percentage = (int) round($this->threshold * 100);
|
||||
$remaining = max(0, $this->limit - $this->used);
|
||||
$eventName = $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback');
|
||||
|
||||
$url = url('/tenant/events/'.($event?->slug ?? ''));
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('emails.package_limits.photo_threshold.subject', [
|
||||
'event' => $eventName,
|
||||
'percentage' => $percentage,
|
||||
]))
|
||||
->greeting(__('emails.package_limits.photo_threshold.greeting', [
|
||||
'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'),
|
||||
]))
|
||||
->line(__('emails.package_limits.photo_threshold.body', [
|
||||
'event' => $eventName,
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'percentage' => $percentage,
|
||||
'used' => $this->used,
|
||||
'limit' => $this->limit,
|
||||
'remaining' => $remaining,
|
||||
]))
|
||||
->action(__('emails.package_limits.photo_threshold.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
}
|
||||
42
app/Notifications/Packages/TenantCreditsLowNotification.php
Normal file
42
app/Notifications/Packages/TenantCreditsLowNotification.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Packages;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class TenantCreditsLowNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly Tenant $tenant,
|
||||
private readonly int $balance,
|
||||
private readonly int $threshold,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$url = url('/tenant/billing');
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('emails.package_limits.credits_low.subject'))
|
||||
->greeting(__('emails.package_limits.credits_low.greeting', [
|
||||
'name' => $this->tenant->name ?? __('emails.package_limits.team_fallback'),
|
||||
]))
|
||||
->line(__('emails.package_limits.credits_low.body', [
|
||||
'balance' => $this->balance,
|
||||
'threshold' => $this->threshold,
|
||||
]))
|
||||
->action(__('emails.package_limits.credits_low.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Packages;
|
||||
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class TenantPackageEventLimitNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantPackage $tenantPackage,
|
||||
private readonly int $limit,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$tenant = $this->tenantPackage->tenant;
|
||||
$package = $this->tenantPackage->package;
|
||||
|
||||
$url = url('/tenant/billing');
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('emails.package_limits.event_limit.subject', [
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
]))
|
||||
->greeting(__('emails.package_limits.event_limit.greeting', [
|
||||
'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'),
|
||||
]))
|
||||
->line(__('emails.package_limits.event_limit.body', [
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'limit' => $this->limit,
|
||||
]))
|
||||
->action(__('emails.package_limits.event_limit.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Packages;
|
||||
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class TenantPackageEventThresholdNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantPackage $tenantPackage,
|
||||
private readonly float $threshold,
|
||||
private readonly int $limit,
|
||||
private readonly int $used,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$tenant = $this->tenantPackage->tenant;
|
||||
$package = $this->tenantPackage->package;
|
||||
|
||||
$percentage = (int) round($this->threshold * 100);
|
||||
$remaining = max(0, $this->limit - $this->used);
|
||||
|
||||
$url = url('/tenant/billing');
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('emails.package_limits.event_threshold.subject', [
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'percentage' => $percentage,
|
||||
]))
|
||||
->greeting(__('emails.package_limits.event_threshold.greeting', [
|
||||
'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'),
|
||||
]))
|
||||
->line(__('emails.package_limits.event_threshold.body', [
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'percentage' => $percentage,
|
||||
'used' => $this->used,
|
||||
'limit' => $this->limit,
|
||||
'remaining' => $remaining,
|
||||
]))
|
||||
->action(__('emails.package_limits.event_threshold.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Packages;
|
||||
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class TenantPackageExpiredNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(private readonly TenantPackage $tenantPackage) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$tenant = $this->tenantPackage->tenant;
|
||||
$package = $this->tenantPackage->package;
|
||||
|
||||
$url = url('/tenant/billing');
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('emails.package_limits.package_expired.subject', [
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
]))
|
||||
->greeting(__('emails.package_limits.package_expired.greeting', [
|
||||
'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'),
|
||||
]))
|
||||
->line(__('emails.package_limits.package_expired.body', [
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'date' => optional($this->tenantPackage->expires_at)->toFormattedDateString(),
|
||||
]))
|
||||
->action(__('emails.package_limits.package_expired.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Packages;
|
||||
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class TenantPackageExpiringNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantPackage $tenantPackage,
|
||||
private readonly int $daysRemaining,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$tenant = $this->tenantPackage->tenant;
|
||||
$package = $this->tenantPackage->package;
|
||||
|
||||
$url = url('/tenant/billing');
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(trans_choice('emails.package_limits.package_expiring.subject', $this->daysRemaining, [
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'days' => $this->daysRemaining,
|
||||
]))
|
||||
->greeting(__('emails.package_limits.package_expiring.greeting', [
|
||||
'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'),
|
||||
]))
|
||||
->line(trans_choice('emails.package_limits.package_expiring.body', $this->daysRemaining, [
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'days' => $this->daysRemaining,
|
||||
'date' => optional($this->tenantPackage->expires_at)->toFormattedDateString(),
|
||||
]))
|
||||
->action(__('emails.package_limits.package_expiring.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,39 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Events\Packages\EventPackageGalleryExpired;
|
||||
use App\Events\Packages\EventPackageGalleryExpiring;
|
||||
use App\Events\Packages\EventPackageGuestLimitReached;
|
||||
use App\Events\Packages\EventPackageGuestThresholdReached;
|
||||
use App\Events\Packages\EventPackagePhotoLimitReached;
|
||||
use App\Events\Packages\EventPackagePhotoThresholdReached;
|
||||
use App\Events\Packages\TenantCreditsLow;
|
||||
use App\Events\Packages\TenantPackageEventLimitReached;
|
||||
use App\Events\Packages\TenantPackageEventThresholdReached;
|
||||
use App\Events\Packages\TenantPackageExpired;
|
||||
use App\Events\Packages\TenantPackageExpiring;
|
||||
use App\Listeners\Packages\QueueGalleryExpiredNotification;
|
||||
use App\Listeners\Packages\QueueGalleryWarningNotification;
|
||||
use App\Listeners\Packages\QueueGuestLimitNotification;
|
||||
use App\Listeners\Packages\QueueGuestThresholdNotification;
|
||||
use App\Listeners\Packages\QueuePhotoLimitNotification;
|
||||
use App\Listeners\Packages\QueuePhotoThresholdNotification;
|
||||
use App\Listeners\Packages\QueueTenantCreditsLowNotification;
|
||||
use App\Listeners\Packages\QueueTenantEventLimitNotification;
|
||||
use App\Listeners\Packages\QueueTenantEventThresholdNotification;
|
||||
use App\Listeners\Packages\QueueTenantPackageExpiredNotification;
|
||||
use App\Listeners\Packages\QueueTenantPackageExpiringNotification;
|
||||
use App\Notifications\UploadPipelineFailed;
|
||||
use App\Services\Checkout\CheckoutAssignmentService;
|
||||
use App\Services\Checkout\CheckoutPaymentService;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Notifications\UploadPipelineFailed;
|
||||
use App\Services\Security\PhotoSecurityScanner;
|
||||
use App\Services\Storage\EventStorageManager;
|
||||
use App\Services\Storage\StorageHealthService;
|
||||
use App\Services\Security\PhotoSecurityScanner;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
use Illuminate\Support\Facades\Event as EventFacade;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
@@ -40,25 +63,80 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
$this->app->make(EventStorageManager::class)->registerDynamicDisks();
|
||||
|
||||
EventFacade::listen(
|
||||
EventPackagePhotoThresholdReached::class,
|
||||
[QueuePhotoThresholdNotification::class, 'handle']
|
||||
);
|
||||
|
||||
EventFacade::listen(
|
||||
EventPackagePhotoLimitReached::class,
|
||||
[QueuePhotoLimitNotification::class, 'handle']
|
||||
);
|
||||
|
||||
EventFacade::listen(
|
||||
EventPackageGuestThresholdReached::class,
|
||||
[QueueGuestThresholdNotification::class, 'handle']
|
||||
);
|
||||
|
||||
EventFacade::listen(
|
||||
EventPackageGuestLimitReached::class,
|
||||
[QueueGuestLimitNotification::class, 'handle']
|
||||
);
|
||||
|
||||
EventFacade::listen(
|
||||
EventPackageGalleryExpiring::class,
|
||||
[QueueGalleryWarningNotification::class, 'handle']
|
||||
);
|
||||
|
||||
EventFacade::listen(
|
||||
EventPackageGalleryExpired::class,
|
||||
[QueueGalleryExpiredNotification::class, 'handle']
|
||||
);
|
||||
|
||||
EventFacade::listen(
|
||||
TenantPackageEventThresholdReached::class,
|
||||
[QueueTenantEventThresholdNotification::class, 'handle']
|
||||
);
|
||||
|
||||
EventFacade::listen(
|
||||
TenantPackageEventLimitReached::class,
|
||||
[QueueTenantEventLimitNotification::class, 'handle']
|
||||
);
|
||||
|
||||
EventFacade::listen(
|
||||
TenantPackageExpiring::class,
|
||||
[QueueTenantPackageExpiringNotification::class, 'handle']
|
||||
);
|
||||
|
||||
EventFacade::listen(
|
||||
TenantPackageExpired::class,
|
||||
[QueueTenantPackageExpiredNotification::class, 'handle']
|
||||
);
|
||||
|
||||
EventFacade::listen(
|
||||
TenantCreditsLow::class,
|
||||
[QueueTenantCreditsLowNotification::class, 'handle']
|
||||
);
|
||||
|
||||
RateLimiter::for('tenant-api', function (Request $request) {
|
||||
$tenantId = $request->attributes->get('tenant_id')
|
||||
?? $request->user()?->tenant_id
|
||||
?? $request->user()?->tenant?->id;
|
||||
|
||||
$key = $tenantId ? 'tenant:' . $tenantId : ('ip:' . ($request->ip() ?? 'unknown'));
|
||||
$key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown'));
|
||||
|
||||
return Limit::perMinute(100)->by($key);
|
||||
});
|
||||
|
||||
RateLimiter::for('oauth', function (Request $request) {
|
||||
return Limit::perMinute(10)->by('oauth:' . ($request->ip() ?? 'unknown'));
|
||||
return Limit::perMinute(10)->by('oauth:'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
|
||||
Inertia::share('locale', fn () => app()->getLocale());
|
||||
Inertia::share('analytics', static function () {
|
||||
$config = config('services.matomo');
|
||||
|
||||
if (!($config['enabled'] ?? false)) {
|
||||
if (! ($config['enabled'] ?? false)) {
|
||||
return [
|
||||
'matomo' => [
|
||||
'enabled' => false,
|
||||
|
||||
@@ -58,6 +58,25 @@ class EventJoinTokenService
|
||||
public function incrementUsage(EventJoinToken $joinToken): void
|
||||
{
|
||||
$joinToken->increment('usage_count');
|
||||
|
||||
$event = $joinToken->event()
|
||||
->with(['eventPackage.package', 'eventPackages.package', 'tenant'])
|
||||
->first();
|
||||
|
||||
if ($event && $event->tenant) {
|
||||
$usageTracker = app(\App\Services\Packages\PackageUsageTracker::class);
|
||||
$limitEvaluator = app(\App\Services\Packages\PackageLimitEvaluator::class);
|
||||
|
||||
$eventPackage = $limitEvaluator->resolveEventPackageForPhotoUpload($event->tenant, $event->id, $event);
|
||||
|
||||
if ($eventPackage && $eventPackage->package?->max_guests !== null) {
|
||||
$previous = (int) $eventPackage->used_guests;
|
||||
$eventPackage->increment('used_guests');
|
||||
$eventPackage->refresh();
|
||||
|
||||
$usageTracker->recordGuestUsage($eventPackage, $previous, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken
|
||||
|
||||
151
app/Services/Packages/PackageLimitEvaluator.php
Normal file
151
app/Services/Packages/PackageLimitEvaluator.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Packages;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\Tenant;
|
||||
|
||||
class PackageLimitEvaluator
|
||||
{
|
||||
public function assessEventCreation(Tenant $tenant): ?array
|
||||
{
|
||||
if ($tenant->hasEventAllowance()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$package = $tenant->getActiveResellerPackage();
|
||||
|
||||
if ($package) {
|
||||
$limit = $package->package->max_events_per_year ?? 0;
|
||||
|
||||
return [
|
||||
'code' => 'event_limit_exceeded',
|
||||
'title' => 'Event quota reached',
|
||||
'message' => 'Your current package has no remaining event slots. Please upgrade or renew your subscription.',
|
||||
'status' => 402,
|
||||
'meta' => [
|
||||
'scope' => 'events',
|
||||
'used' => (int) $package->used_events,
|
||||
'limit' => $limit,
|
||||
'remaining' => max(0, $limit - $package->used_events),
|
||||
'tenant_package_id' => $package->id,
|
||||
'package_id' => $package->package_id,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 'event_credits_exhausted',
|
||||
'title' => 'No event credits remaining',
|
||||
'message' => 'You have no event credits remaining. Purchase additional credits or a package to create new events.',
|
||||
'status' => 402,
|
||||
'meta' => [
|
||||
'scope' => 'credits',
|
||||
'balance' => (int) ($tenant->event_credits_balance ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function assessPhotoUpload(Tenant $tenant, int $eventId, ?Event $preloadedEvent = null): ?array
|
||||
{
|
||||
[$event, $eventPackage] = $this->resolveEventAndPackage($tenant, $eventId, $preloadedEvent);
|
||||
|
||||
if (! $event) {
|
||||
return [
|
||||
'code' => 'event_not_found',
|
||||
'title' => 'Event not accessible',
|
||||
'message' => 'The selected event could not be found or belongs to another tenant.',
|
||||
'status' => 404,
|
||||
'meta' => [
|
||||
'scope' => 'photos',
|
||||
'event_id' => $eventId,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if (! $eventPackage || ! $eventPackage->package) {
|
||||
return [
|
||||
'code' => 'event_package_missing',
|
||||
'title' => 'Event package missing',
|
||||
'message' => 'No package is attached to this event. Assign a package to enable uploads.',
|
||||
'status' => 409,
|
||||
'meta' => [
|
||||
'scope' => 'photos',
|
||||
'event_id' => $event->id,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$maxPhotos = $eventPackage->package->max_photos;
|
||||
|
||||
if ($maxPhotos === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($eventPackage->used_photos >= $maxPhotos) {
|
||||
return [
|
||||
'code' => 'photo_limit_exceeded',
|
||||
'title' => 'Photo upload limit reached',
|
||||
'message' => 'This event has reached its photo allowance. Upgrade the event package to accept more uploads.',
|
||||
'status' => 402,
|
||||
'meta' => [
|
||||
'scope' => 'photos',
|
||||
'used' => (int) $eventPackage->used_photos,
|
||||
'limit' => (int) $maxPhotos,
|
||||
'remaining' => 0,
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $eventPackage->package_id,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function resolveEventPackageForPhotoUpload(
|
||||
Tenant $tenant,
|
||||
int $eventId,
|
||||
?Event $preloadedEvent = null
|
||||
): ?EventPackage {
|
||||
[, $eventPackage] = $this->resolveEventAndPackage($tenant, $eventId, $preloadedEvent);
|
||||
|
||||
return $eventPackage;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?Event, 1: ?\App\Models\EventPackage}
|
||||
*/
|
||||
private function resolveEventAndPackage(
|
||||
Tenant $tenant,
|
||||
int $eventId,
|
||||
?Event $preloadedEvent = null
|
||||
): array {
|
||||
$event = $preloadedEvent;
|
||||
|
||||
if (! $event) {
|
||||
$event = Event::with(['eventPackage.package', 'eventPackages.package'])
|
||||
->find($eventId);
|
||||
}
|
||||
|
||||
if (! $event || $event->tenant_id !== $tenant->id) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
$eventPackage = $event->eventPackage;
|
||||
|
||||
if (! $eventPackage && method_exists($event, 'eventPackages')) {
|
||||
$eventPackage = $event->eventPackages()
|
||||
->with('package')
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
if ($eventPackage && ! $eventPackage->relationLoaded('package')) {
|
||||
$eventPackage->load('package');
|
||||
}
|
||||
|
||||
return [$event, $eventPackage];
|
||||
}
|
||||
}
|
||||
87
app/Services/Packages/PackageUsageTracker.php
Normal file
87
app/Services/Packages/PackageUsageTracker.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Packages;
|
||||
|
||||
use App\Events\Packages\EventPackageGuestLimitReached;
|
||||
use App\Events\Packages\EventPackageGuestThresholdReached;
|
||||
use App\Events\Packages\EventPackagePhotoLimitReached;
|
||||
use App\Events\Packages\EventPackagePhotoThresholdReached;
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
|
||||
class PackageUsageTracker
|
||||
{
|
||||
public function __construct(private readonly Dispatcher $dispatcher) {}
|
||||
|
||||
public function recordPhotoUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void
|
||||
{
|
||||
$limit = $eventPackage->package?->max_photos;
|
||||
|
||||
if ($limit === null || $limit <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newUsed = $eventPackage->used_photos;
|
||||
|
||||
$thresholds = collect(config('package-limits.photo_thresholds', []))
|
||||
->filter(fn (float $value) => $value > 0 && $value < 1)
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
if ($limit > 0) {
|
||||
$previousRatio = $previousUsed / $limit;
|
||||
$newRatio = $newUsed / $limit;
|
||||
|
||||
foreach ($thresholds as $threshold) {
|
||||
if ($previousRatio < $threshold && $newRatio >= $threshold) {
|
||||
$this->dispatcher->dispatch(new EventPackagePhotoThresholdReached(
|
||||
$eventPackage,
|
||||
$threshold,
|
||||
$limit,
|
||||
$newUsed,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($newUsed >= $limit && ($previousUsed < $limit)) {
|
||||
$this->dispatcher->dispatch(new EventPackagePhotoLimitReached($eventPackage, $limit));
|
||||
}
|
||||
}
|
||||
|
||||
public function recordGuestUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void
|
||||
{
|
||||
$limit = $eventPackage->package?->max_guests;
|
||||
|
||||
if ($limit === null || $limit <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newUsed = $eventPackage->used_guests;
|
||||
|
||||
$thresholds = collect(config('package-limits.guest_thresholds', []))
|
||||
->filter(fn (float $value) => $value > 0 && $value < 1)
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
if ($limit > 0) {
|
||||
$previousRatio = $previousUsed / $limit;
|
||||
$newRatio = $newUsed / $limit;
|
||||
|
||||
foreach ($thresholds as $threshold) {
|
||||
if ($previousRatio < $threshold && $newRatio >= $threshold) {
|
||||
$this->dispatcher->dispatch(new EventPackageGuestThresholdReached(
|
||||
$eventPackage,
|
||||
$threshold,
|
||||
$limit,
|
||||
$newUsed,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($newUsed >= $limit && ($previousUsed < $limit)) {
|
||||
$this->dispatcher->dispatch(new EventPackageGuestLimitReached($eventPackage, $limit));
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/Services/Packages/TenantNotificationPreferences.php
Normal file
37
app/Services/Packages/TenantNotificationPreferences.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Packages;
|
||||
|
||||
use App\Models\Tenant;
|
||||
|
||||
class TenantNotificationPreferences
|
||||
{
|
||||
private const DEFAULTS = [
|
||||
'photo_thresholds' => true,
|
||||
'photo_limits' => true,
|
||||
'guest_thresholds' => true,
|
||||
'guest_limits' => true,
|
||||
'gallery_warnings' => true,
|
||||
'gallery_expired' => true,
|
||||
'event_thresholds' => true,
|
||||
'event_limits' => true,
|
||||
'package_expiring' => true,
|
||||
'package_expired' => true,
|
||||
'credits_low' => true,
|
||||
];
|
||||
|
||||
public function shouldNotify(Tenant $tenant, string $preferenceKey): bool
|
||||
{
|
||||
$preferences = $tenant->notification_preferences ?? [];
|
||||
|
||||
if (! is_array($preferences)) {
|
||||
$preferences = [];
|
||||
}
|
||||
|
||||
if (array_key_exists($preferenceKey, $preferences)) {
|
||||
return (bool) $preferences[$preferenceKey];
|
||||
}
|
||||
|
||||
return self::DEFAULTS[$preferenceKey] ?? true;
|
||||
}
|
||||
}
|
||||
97
app/Services/Packages/TenantUsageTracker.php
Normal file
97
app/Services/Packages/TenantUsageTracker.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Packages;
|
||||
|
||||
use App\Events\Packages\TenantCreditsLow;
|
||||
use App\Events\Packages\TenantPackageEventLimitReached;
|
||||
use App\Events\Packages\TenantPackageEventThresholdReached;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
|
||||
class TenantUsageTracker
|
||||
{
|
||||
public function __construct(private readonly Dispatcher $dispatcher) {}
|
||||
|
||||
public function recordEventUsage(TenantPackage $tenantPackage, int $previousUsed, int $delta = 1): void
|
||||
{
|
||||
$limit = $tenantPackage->package?->max_events_per_year;
|
||||
|
||||
if ($limit === null || $limit <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newUsed = (int) $tenantPackage->used_events;
|
||||
|
||||
$thresholds = collect(config('package-limits.event_thresholds', []))
|
||||
->filter(fn (float $value) => $value > 0 && $value < 1)
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
if ($limit > 0) {
|
||||
$previousRatio = $previousUsed / $limit;
|
||||
$newRatio = $newUsed / $limit;
|
||||
$currentThreshold = $tenantPackage->event_warning_threshold ?? null;
|
||||
|
||||
foreach ($thresholds as $threshold) {
|
||||
if ($previousRatio < $threshold && $newRatio >= $threshold) {
|
||||
if ($currentThreshold !== null && $currentThreshold >= $threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tenantPackage->forceFill([
|
||||
'event_warning_sent_at' => now(),
|
||||
'event_warning_threshold' => $threshold,
|
||||
])->save();
|
||||
|
||||
$this->dispatcher->dispatch(new TenantPackageEventThresholdReached(
|
||||
$tenantPackage,
|
||||
$threshold,
|
||||
$limit,
|
||||
$newUsed,
|
||||
));
|
||||
|
||||
$currentThreshold = $threshold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($newUsed >= $limit && $previousUsed < $limit) {
|
||||
if (! $tenantPackage->event_limit_notified_at) {
|
||||
$tenantPackage->forceFill([
|
||||
'event_limit_notified_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
$this->dispatcher->dispatch(new TenantPackageEventLimitReached($tenantPackage, $limit));
|
||||
}
|
||||
}
|
||||
|
||||
public function recordCreditBalance(Tenant $tenant, int $previousBalance, int $newBalance): void
|
||||
{
|
||||
$thresholds = collect(config('package-limits.credit_thresholds', []))
|
||||
->filter(fn ($value) => is_numeric($value) && $value >= 0)
|
||||
->map(fn ($value) => (int) $value)
|
||||
->sortDesc()
|
||||
->values();
|
||||
|
||||
$currentThreshold = $tenant->credit_warning_threshold ?? null;
|
||||
|
||||
foreach ($thresholds as $threshold) {
|
||||
if ($previousBalance > $threshold && $newBalance <= $threshold) {
|
||||
if ($currentThreshold !== null && $threshold >= $currentThreshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tenant->forceFill([
|
||||
'credit_warning_sent_at' => now(),
|
||||
'credit_warning_threshold' => $threshold,
|
||||
])->save();
|
||||
|
||||
$this->dispatcher->dispatch(new TenantCreditsLow($tenant, $newBalance, $threshold));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/Support/ApiError.php
Normal file
30
app/Support/ApiError.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ApiError
|
||||
{
|
||||
public static function response(
|
||||
string $code,
|
||||
string $title,
|
||||
string $message,
|
||||
int $status,
|
||||
array $meta = []
|
||||
): JsonResponse {
|
||||
$payload = [
|
||||
'error' => [
|
||||
'code' => $code,
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
],
|
||||
];
|
||||
|
||||
if ($meta !== []) {
|
||||
$payload['error']['meta'] = $meta;
|
||||
}
|
||||
|
||||
return response()->json($payload, $status);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class JoinTokenLayoutRegistry
|
||||
'link_heading' => 'Falls der Scan nicht klappt',
|
||||
'cta_label' => 'Gästegalerie öffnen',
|
||||
'cta_caption' => 'Jetzt Erinnerungen sammeln',
|
||||
'qr' => ['size_px' => 520],
|
||||
'qr' => ['size_px' => 640],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'QR-Code scannen und mit eurem Lieblingsnamen anmelden.',
|
||||
@@ -62,7 +62,7 @@ class JoinTokenLayoutRegistry
|
||||
'link_heading' => 'Link teilen statt scannen',
|
||||
'cta_label' => 'Jetzt Event-Hub öffnen',
|
||||
'cta_caption' => 'Programm, Uploads & Highlights',
|
||||
'qr' => ['size_px' => 560],
|
||||
'qr' => ['size_px' => 640],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'QR-Code scannen oder Kurzlink eingeben.',
|
||||
@@ -91,7 +91,7 @@ class JoinTokenLayoutRegistry
|
||||
'link_heading' => 'Alternativ zum Scannen',
|
||||
'cta_label' => 'Gästebuch öffnen',
|
||||
'cta_caption' => 'Eure Grüße festhalten',
|
||||
'qr' => ['size_px' => 520],
|
||||
'qr' => ['size_px' => 660],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'QR-Code scannen und Namen eintragen.',
|
||||
@@ -120,7 +120,7 @@ class JoinTokenLayoutRegistry
|
||||
'link_heading' => 'QR funktioniert nicht?',
|
||||
'cta_label' => 'Partyfeed starten',
|
||||
'cta_caption' => 'Momente live teilen',
|
||||
'qr' => ['size_px' => 560],
|
||||
'qr' => ['size_px' => 680],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'Code scannen und kurz registrieren.',
|
||||
@@ -149,7 +149,7 @@ class JoinTokenLayoutRegistry
|
||||
'link_heading' => 'Kurzlink für Gäste',
|
||||
'cta_label' => 'Zur Geburtstagswand',
|
||||
'cta_caption' => 'Fotos & Grüße posten',
|
||||
'qr' => ['size_px' => 520],
|
||||
'qr' => ['size_px' => 680],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'QR-Code scannen und Wunschname auswählen.',
|
||||
@@ -223,7 +223,7 @@ class JoinTokenLayoutRegistry
|
||||
'link_label' => null,
|
||||
'logo_url' => null,
|
||||
'qr' => [
|
||||
'size_px' => 360,
|
||||
'size_px' => 640,
|
||||
],
|
||||
'svg' => [
|
||||
'width' => 1240,
|
||||
|
||||
Reference in New Issue
Block a user