Compare commits
129 Commits
8f13465415
...
beads-sync
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9fa1546f7 | ||
|
|
7c6eee187c | ||
|
|
fbd46b8e5c | ||
|
|
886b336a08 | ||
|
|
02237735ec | ||
|
|
5e420a0dd8 | ||
|
|
2a55ae934f | ||
|
|
e4100f7800 | ||
|
|
7786e3d134 | ||
|
|
30f3d148bb | ||
|
|
1970c259ed | ||
|
|
dc5c80cda4 | ||
|
|
75a9bcee12 | ||
|
|
6fe363640f | ||
|
|
3df0542013 | ||
|
|
4f4a527010 | ||
|
|
e69c94ad20 | ||
|
|
5afa96251b | ||
|
|
24f053d4c4 | ||
|
|
ec360ed860 | ||
|
|
83e78d7c66 | ||
|
|
9b1c5bf978 | ||
|
|
fb23a0a2f3 | ||
|
|
2287e7f32c | ||
|
|
cceed361b7 | ||
|
|
02363792c8 | ||
|
|
e93a00f0fc | ||
|
|
c1be7dd1ef | ||
|
|
f01a0e823b | ||
|
|
915aede66e | ||
|
|
b854e3feaa | ||
|
|
4bcaef53f7 | ||
|
|
8f1d3a3eb6 | ||
|
|
ab2cf3e023 | ||
|
|
ce0ab269c9 | ||
|
|
dce24bb86a | ||
|
|
03bf178d61 | ||
|
|
8ebaf6c31d | ||
|
|
1b6dc63ec6 | ||
|
|
accc63f4a2 | ||
|
|
59e318e7b9 | ||
|
|
3de1d3deab | ||
|
|
e9afbeb028 | ||
|
|
3e2b63f71f | ||
|
|
cff014ede5 | ||
|
|
8c5d3b93d5 | ||
|
|
22cb7ed7ce | ||
|
|
1ec4987b38 | ||
|
|
6542ac66f1 | ||
|
|
9bf4e8894f | ||
|
|
704683421f | ||
|
|
9e9e04b97e | ||
|
|
59c463dbd3 | ||
|
|
8af2db2976 | ||
|
|
a22bff1879 | ||
|
|
5009697f7b | ||
|
|
a8b9c3623a | ||
|
|
d5d53b563c | ||
|
|
c4fa0fc06e | ||
|
|
ee3e9737c4 | ||
|
|
322cafa3c2 | ||
|
|
232302eb6f | ||
|
|
ef1773d966 | ||
|
|
e3deec9741 | ||
|
|
10fbee4e6e | ||
|
|
4fe589f0e2 | ||
|
|
a3538f6470 | ||
|
|
e82a10cb8b | ||
|
|
cc89cc667a | ||
|
|
a796973861 | ||
|
|
eba212a056 | ||
|
|
54b3fa0d87 | ||
|
|
51e8beb46c | ||
|
|
33af04db1b | ||
|
|
29c3c42134 | ||
|
|
f89f6d6223 | ||
|
|
34eb2b94b3 | ||
|
|
88012c35bd | ||
|
|
3f3061a899 | ||
|
|
53eb560aa5 | ||
|
|
11dc0d77b4 | ||
|
|
35ef8f1586 | ||
|
|
15be3b847c | ||
|
|
7bbce79394 | ||
|
|
99186e8e2f | ||
|
|
148c075d58 | ||
|
|
e3b7271f69 | ||
|
|
7802bed394 | ||
|
|
2abd1d113f | ||
|
|
4718998e07 | ||
|
|
c07687102e | ||
|
|
8805c8264c | ||
|
|
15e19d4e8b | ||
|
|
48b1cfde09 | ||
|
|
540bb97f31 | ||
|
|
cbb010acca | ||
|
|
1afd49bd24 | ||
|
|
fae5ec26fb | ||
|
|
103c8d4dfd | ||
|
|
69fc869990 | ||
|
|
76c04f6873 | ||
|
|
6f5aa5e09b | ||
|
|
8764915fcd | ||
|
|
bd6a8b9c7c | ||
|
|
eb6c8857d1 | ||
|
|
a35808ac15 | ||
|
|
ef05822b70 | ||
|
|
4f1fbcc98b | ||
|
|
43b626cbfc | ||
|
|
3d0ff40382 | ||
|
|
f41578905f | ||
|
|
08fe64b965 | ||
|
|
7ea34b3b20 | ||
|
|
030a00ba46 | ||
|
|
41ed682fbe | ||
|
|
75d862748b | ||
|
|
66bf9e4a8c | ||
|
|
dfdbf09bf8 | ||
|
|
3c0e7afeb2 | ||
|
|
bb67d68eba | ||
|
|
0430f0b1cc | ||
|
|
8b445ae998 | ||
|
|
77b7af13d4 | ||
|
|
3e9f09571b | ||
|
|
eed7699549 | ||
|
|
5fd546c428 | ||
|
|
fc3e6715db | ||
|
|
9057a4cd15 | ||
|
|
bc99929040 |
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
fotospiel-app-097
|
||||
fotospiel-app-29r
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -13,6 +13,8 @@ fotospiel-tenant-app
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
/clients/photobooth-uploader/**/bin
|
||||
/clients/photobooth-uploader/**/obj
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
@@ -23,11 +25,9 @@ Homestead.yaml
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/auth.json
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
tools/git-askpass.ps1
|
||||
podman-compose.dev.yml
|
||||
test-results
|
||||
GEMINI.md
|
||||
.beads/.sync.lock
|
||||
.beads/daemon-error
|
||||
.beads/sync_base.jsonl
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ namespace App\Console\Commands;
|
||||
use App\Console\Concerns\InteractsWithCacheLocks;
|
||||
use App\Jobs\ArchiveEventMediaAssets;
|
||||
use App\Models\Event;
|
||||
use App\Services\Compliance\RetentionOverrideService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Cache\Lock;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -37,6 +38,7 @@ class DispatchStorageArchiveCommand extends Command
|
||||
$maxDispatch = max(1, (int) config('storage-monitor.archive.max_dispatch', 100));
|
||||
$eventId = $this->option('event');
|
||||
$dispatched = 0;
|
||||
$overrides = app(RetentionOverrideService::class);
|
||||
|
||||
try {
|
||||
$query = Event::query()
|
||||
@@ -57,12 +59,16 @@ class DispatchStorageArchiveCommand extends Command
|
||||
});
|
||||
}
|
||||
|
||||
$query->chunkById($chunkSize, function ($events) use (&$dispatched, $maxDispatch, $eventLockTtl) {
|
||||
$query->chunkById($chunkSize, function ($events) use (&$dispatched, $maxDispatch, $eventLockTtl, $overrides) {
|
||||
foreach ($events as $event) {
|
||||
if ($dispatched >= $maxDispatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($overrides->eventOnHold($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$eventLock = $this->acquireCommandLock('storage:archive-event-'.$event->id, $eventLockTtl);
|
||||
if ($eventLock === false) {
|
||||
Log::channel('storage-jobs')->info('Archive dispatch skipped due to in-flight lock', [
|
||||
|
||||
132
app/Console/Commands/PaddleRegisterWebhooks.php
Normal file
132
app/Console/Commands/PaddleRegisterWebhooks.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Paddle\PaddleClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PaddleRegisterWebhooks extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'paddle:webhooks:register
|
||||
{--url= : Destination URL for Paddle webhooks}
|
||||
{--description= : Description for the webhook destination}
|
||||
{--events=* : Override event types to subscribe}
|
||||
{--traffic-source=all : platform|simulation|all}
|
||||
{--include-sensitive : Include sensitive fields in webhook payloads}
|
||||
{--show-secret : Output the endpoint secret key}
|
||||
{--dry-run : Output payload without creating the destination}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Register Paddle webhook notification settings.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(PaddleClient $client): int
|
||||
{
|
||||
$destination = (string) ($this->option('url') ?: $this->defaultWebhookUrl());
|
||||
|
||||
if ($destination === '') {
|
||||
$this->error('Webhook destination URL is required. Use --url=...');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$events = collect((array) $this->option('events'))
|
||||
->filter()
|
||||
->map(fn ($event) => trim((string) $event))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($events === []) {
|
||||
$events = config('paddle.webhook_events', []);
|
||||
}
|
||||
|
||||
if ($events === [] || ! is_array($events)) {
|
||||
$this->error('No webhook events configured. Set config(paddle.webhook_events) or pass --events.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$trafficSource = (string) $this->option('traffic-source');
|
||||
$allowedSources = ['platform', 'simulation', 'all'];
|
||||
|
||||
if (! in_array($trafficSource, $allowedSources, true)) {
|
||||
$this->error(sprintf('Invalid traffic source. Use one of: %s', implode(', ', $allowedSources)));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'type' => 'url',
|
||||
'destination' => $destination,
|
||||
'description' => $this->resolveDescription(),
|
||||
'subscribed_events' => $events,
|
||||
'traffic_source' => $trafficSource,
|
||||
'include_sensitive_fields' => (bool) $this->option('include-sensitive'),
|
||||
];
|
||||
|
||||
if ((bool) $this->option('dry-run')) {
|
||||
$this->line(json_encode($payload, JSON_PRETTY_PRINT));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$response = $client->post('/notification-settings', $payload);
|
||||
$data = Arr::get($response, 'data', $response);
|
||||
$id = Arr::get($data, 'id');
|
||||
$secret = Arr::get($data, 'endpoint_secret_key');
|
||||
|
||||
Log::channel('paddle-sync')->info('Paddle webhook registered', [
|
||||
'notification_setting_id' => $id,
|
||||
'destination' => $destination,
|
||||
'traffic_source' => $trafficSource,
|
||||
]);
|
||||
|
||||
$this->info('Paddle webhook registered.');
|
||||
|
||||
if ($id) {
|
||||
$this->line('ID: '.$id);
|
||||
}
|
||||
|
||||
if ($secret && $this->option('show-secret')) {
|
||||
$this->line('Secret: '.$secret);
|
||||
} elseif ($secret) {
|
||||
$this->line('Secret returned (hidden). Use --show-secret to display.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function defaultWebhookUrl(): string
|
||||
{
|
||||
$base = rtrim((string) config('app.url'), '/');
|
||||
|
||||
return $base !== '' ? $base.'/paddle/webhook' : '';
|
||||
}
|
||||
|
||||
protected function resolveDescription(): string
|
||||
{
|
||||
$description = (string) $this->option('description');
|
||||
|
||||
if ($description !== '') {
|
||||
return $description;
|
||||
}
|
||||
|
||||
$environment = (string) config('paddle.environment', 'production');
|
||||
|
||||
return sprintf('Fotospiel Paddle webhooks (%s)', $environment);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class PaddleSyncPackages extends Command
|
||||
{--package=* : Limit sync to the given package IDs or slugs}
|
||||
{--dry-run : Generate payload snapshots without calling Paddle}
|
||||
{--pull : Fetch remote Paddle state instead of pushing local changes}
|
||||
{--allow-unmapped : Allow sync when packages are missing Paddle product/price IDs}
|
||||
{--queue : Dispatch jobs onto the queue instead of running synchronously}';
|
||||
|
||||
protected $description = 'Synchronise local packages with Paddle products and prices.';
|
||||
@@ -32,6 +33,13 @@ class PaddleSyncPackages extends Command
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$pull = (bool) $this->option('pull');
|
||||
$queue = (bool) $this->option('queue');
|
||||
$allowUnmapped = (bool) $this->option('allow-unmapped');
|
||||
|
||||
if (! $pull && ! $allowUnmapped && ! $this->hasPackageFilter()) {
|
||||
if (! $this->guardUnmappedPackages($packages)) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$packages->each(function (Package $package) use ($dryRun, $pull, $queue) {
|
||||
if ($pull) {
|
||||
@@ -82,6 +90,42 @@ class PaddleSyncPackages extends Command
|
||||
return $query->orderByDesc('id')->get();
|
||||
}
|
||||
|
||||
protected function hasPackageFilter(): bool
|
||||
{
|
||||
return collect((array) $this->option('package'))->filter()->isNotEmpty();
|
||||
}
|
||||
|
||||
protected function guardUnmappedPackages(Collection $packages): bool
|
||||
{
|
||||
$unmapped = $packages->filter(fn (Package $package) => blank($package->paddle_product_id) || blank($package->paddle_price_id));
|
||||
|
||||
if ($unmapped->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->error('Unmapped Paddle package IDs detected. Resolve legacy mappings or pass --allow-unmapped.');
|
||||
$this->table(
|
||||
['ID', 'Slug', 'Missing'],
|
||||
$unmapped->map(function (Package $package): array {
|
||||
$missing = [];
|
||||
if (blank($package->paddle_product_id)) {
|
||||
$missing[] = 'product_id';
|
||||
}
|
||||
if (blank($package->paddle_price_id)) {
|
||||
$missing[] = 'price_id';
|
||||
}
|
||||
|
||||
return [
|
||||
$package->id,
|
||||
$package->slug,
|
||||
implode(', ', $missing),
|
||||
];
|
||||
})->values()->all()
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function dispatchSyncJob(Package $package, bool $dryRun, bool $queue): void
|
||||
{
|
||||
$context = [
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Console\Commands;
|
||||
use App\Jobs\AnonymizeAccount;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\InactiveTenantDeletionWarning;
|
||||
use App\Services\Compliance\RetentionOverrideService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
@@ -27,7 +28,13 @@ class ProcessTenantRetention extends Command
|
||||
->withMax('purchases as last_purchase_activity', 'purchased_at')
|
||||
->withMax('photos as last_photo_activity', 'created_at')
|
||||
->chunkById(100, function ($tenants) use ($warningThreshold, $deletionThreshold) {
|
||||
$overrides = app(RetentionOverrideService::class);
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
if ($overrides->tenantOnHold($tenant)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lastActivity = $this->determineLastActivity($tenant);
|
||||
|
||||
if (! $lastActivity) {
|
||||
|
||||
@@ -203,9 +203,20 @@ class SeedDemoSwitcherTenants extends Command
|
||||
|
||||
$this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel');
|
||||
|
||||
TenantPackage::updateOrCreate(
|
||||
['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id],
|
||||
[
|
||||
'price' => $packages['standard']->price,
|
||||
'purchased_at' => Carbon::now()->subDays(1),
|
||||
'expires_at' => Carbon::now()->addMonths(12),
|
||||
'used_events' => 0,
|
||||
'active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$event = $this->upsertEvent(
|
||||
tenant: $tenant,
|
||||
package: $packages['starter'],
|
||||
package: $packages['standard'],
|
||||
eventType: $eventTypes['wedding'] ?? null,
|
||||
attributes: [
|
||||
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],
|
||||
|
||||
19
app/Enums/DataExportScope.php
Normal file
19
app/Enums/DataExportScope.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum DataExportScope: string
|
||||
{
|
||||
case USER = 'user';
|
||||
case TENANT = 'tenant';
|
||||
case EVENT = 'event';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::USER => __('User'),
|
||||
self::TENANT => __('Tenant'),
|
||||
self::EVENT => __('Event'),
|
||||
};
|
||||
}
|
||||
}
|
||||
12
app/Enums/PhotoLiveStatus.php
Normal file
12
app/Enums/PhotoLiveStatus.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PhotoLiveStatus: string
|
||||
{
|
||||
case NONE = 'none';
|
||||
case PENDING = 'pending';
|
||||
case APPROVED = 'approved';
|
||||
case REJECTED = 'rejected';
|
||||
case EXPIRED = 'expired';
|
||||
}
|
||||
17
app/Enums/RetentionOverrideScope.php
Normal file
17
app/Enums/RetentionOverrideScope.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum RetentionOverrideScope: string
|
||||
{
|
||||
case TENANT = 'tenant';
|
||||
case EVENT = 'event';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::TENANT => __('Tenant'),
|
||||
self::EVENT => __('Event'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Pages;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||
use App\Filament\Widgets\JoinTokenOverviewWidget;
|
||||
use App\Filament\Widgets\JoinTokenTopTokensWidget;
|
||||
use App\Filament\Widgets\JoinTokenTrendWidget;
|
||||
use App\Models\Event;
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Pages\Dashboard\Concerns\HasFiltersForm;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use UnitEnum;
|
||||
|
||||
class JoinTokenAnalyticsDashboard extends Dashboard
|
||||
{
|
||||
use HasFiltersForm;
|
||||
|
||||
protected static ?string $cluster = DailyOpsCluster::class;
|
||||
|
||||
protected static string $routePath = 'join-token-analytics';
|
||||
|
||||
protected static null|string|BackedEnum $navigationIcon = 'heroicon-o-chart-bar';
|
||||
|
||||
protected static null|string|UnitEnum $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 12;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.security');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.join_token_analytics.navigation.label');
|
||||
}
|
||||
|
||||
public function getHeading(): string
|
||||
{
|
||||
return __('admin.join_token_analytics.heading');
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return __('admin.join_token_analytics.subheading');
|
||||
}
|
||||
|
||||
public function getColumns(): int|array
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function getWidgets(): array
|
||||
{
|
||||
return [
|
||||
JoinTokenOverviewWidget::class,
|
||||
JoinTokenTrendWidget::class,
|
||||
JoinTokenTopTokensWidget::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function filtersForm(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make()
|
||||
->schema([
|
||||
Select::make('range')
|
||||
->label(__('admin.join_token_analytics.filters.range'))
|
||||
->options(trans('admin.join_token_analytics.filters.range_options'))
|
||||
->default('24h')
|
||||
->native(false),
|
||||
Select::make('event_id')
|
||||
->label(__('admin.join_token_analytics.filters.event'))
|
||||
->placeholder(__('admin.join_token_analytics.filters.event_placeholder'))
|
||||
->searchable()
|
||||
->getSearchResultsUsing(fn (string $search): array => $this->searchEvents($search))
|
||||
->getOptionLabelUsing(fn ($value): ?string => $this->resolveEventLabel($value))
|
||||
->native(false),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
|
||||
private function searchEvents(string $search): array
|
||||
{
|
||||
return Event::query()
|
||||
->with('tenant')
|
||||
->when($search !== '', function ($query) use ($search) {
|
||||
$query->where('slug', 'like', "%{$search}%")
|
||||
->orWhere('name->de', 'like', "%{$search}%")
|
||||
->orWhere('name->en', 'like', "%{$search}%");
|
||||
})
|
||||
->orderByDesc('date')
|
||||
->limit(25)
|
||||
->get()
|
||||
->mapWithKeys(fn (Event $event) => [$event->id => $this->formatEventLabel($event)])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function resolveEventLabel(mixed $value): ?string
|
||||
{
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$event = Event::query()
|
||||
->with('tenant')
|
||||
->find((int) $value);
|
||||
|
||||
return $event ? $this->formatEventLabel($event) : null;
|
||||
}
|
||||
|
||||
private function formatEventLabel(Event $event): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
$name = $event->name[$locale] ?? $event->name['de'] ?? $event->name['en'] ?? $event->slug ?? __('admin.common.unnamed');
|
||||
$tenant = $event->tenant?->name ?? __('admin.common.unnamed');
|
||||
$date = $event->date?->format('Y-m-d');
|
||||
|
||||
return $date ? "{$name} ({$tenant}) {$date}" : "{$name} ({$tenant})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTenantPaddleHealths extends ListRecords
|
||||
{
|
||||
protected static string $resource = TenantPaddleHealthResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantPaddleHealthTable
|
||||
{
|
||||
private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed'];
|
||||
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('admin.common.tenant'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('slug')
|
||||
->label(__('admin.common.slug'))
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('contact_email')
|
||||
->label(__('admin.tenants.fields.contact_email'))
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_customer_id')
|
||||
->label('Paddle customer')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->copyable()
|
||||
->formatStateUsing(fn (?string $state) => $state ?: '—'),
|
||||
TextColumn::make('subscription_status')
|
||||
->label('Subscription')
|
||||
->badge()
|
||||
->color(fn (?string $state) => match ($state) {
|
||||
'active' => 'success',
|
||||
'suspended' => 'warning',
|
||||
'expired' => 'danger',
|
||||
'free' => 'gray',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextColumn::make('active_reseller_package')
|
||||
->label('Active package')
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->name ?? '—')
|
||||
->badge()
|
||||
->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_subscription_id')
|
||||
->label('Paddle subscription')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->copyable()
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->paddle_subscription_id)
|
||||
->formatStateUsing(fn (?string $state) => $state ?: '—'),
|
||||
IconColumn::make('missing_paddle_subscription')
|
||||
->label('Missing Paddle subscription')
|
||||
->boolean()
|
||||
->getStateUsing(fn (Tenant $record) => self::missingPaddleSubscription($record)),
|
||||
IconColumn::make('status_mismatch')
|
||||
->label('Status mismatch')
|
||||
->boolean()
|
||||
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
|
||||
TextColumn::make('paddle_customer_duplicates')
|
||||
->label('Paddle duplicates')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn (?int $state) => $state && $state > 1 ? (string) $state : '—'),
|
||||
TextColumn::make('paddle_sync_status')
|
||||
->label('Paddle sync')
|
||||
->badge()
|
||||
->color(fn (?string $state) => match ($state) {
|
||||
'synced' => 'success',
|
||||
'syncing' => 'warning',
|
||||
'pulled' => 'info',
|
||||
'dry-run' => 'gray',
|
||||
'failed', 'pull-failed' => 'danger',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—')
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_sync_status)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_synced_at')
|
||||
->label('Paddle synced')
|
||||
->badge()
|
||||
->color(fn ($state) => self::syncAgeColor($state))
|
||||
->formatStateUsing(fn ($state) => $state?->diffForHumans() ?? '—')
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_synced_at),
|
||||
TextColumn::make('last_paddle_transaction_at')
|
||||
->label('Last Paddle tx')
|
||||
->badge()
|
||||
->color(fn (?Carbon $state) => self::transactionAgeColor($state))
|
||||
->getStateUsing(fn (Tenant $record) => $record->last_paddle_transaction_at
|
||||
? Carbon::parse($record->last_paddle_transaction_at)
|
||||
: null)
|
||||
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_transaction_count_window')
|
||||
->label('Paddle tx (30d)')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
TextColumn::make('paddle_transaction_total_window')
|
||||
->label('Paddle total (30d)')
|
||||
->default(0)
|
||||
->money('EUR')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
TextColumn::make('paddle_refund_count_window')
|
||||
->label('Refunds (30d)')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_refund_total_window')
|
||||
->label('Refund total (30d)')
|
||||
->default(0)
|
||||
->money('EUR')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_checkout_requires_action_count')
|
||||
->label('Checkout action required')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_checkout_processing_count')
|
||||
->label('Checkout processing')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_checkout_expired_count')
|
||||
->label('Checkout expired')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_transaction_count')
|
||||
->label('Paddle tx (all)')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_transaction_total')
|
||||
->label('Paddle total (all)')
|
||||
->default(0)
|
||||
->money('EUR')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Filter::make('missing_paddle_customer')
|
||||
->label('Missing Paddle customer')
|
||||
->indicator('Missing Paddle customer')
|
||||
->query(fn (Builder $query) => $query->whereNull('paddle_customer_id')),
|
||||
Filter::make('missing_paddle_subscription')
|
||||
->label('Missing Paddle subscription')
|
||||
->indicator('Missing Paddle subscription')
|
||||
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', fn (Builder $query) => $query
|
||||
->where('active', true)
|
||||
->whereNull('paddle_subscription_id'))),
|
||||
Filter::make('duplicate_paddle_customer')
|
||||
->label('Duplicate Paddle customer')
|
||||
->indicator('Duplicate Paddle customer')
|
||||
->query(fn (Builder $query) => $query
|
||||
->whereNotNull('paddle_customer_id')
|
||||
->whereIn('paddle_customer_id', function ($subquery) {
|
||||
$subquery->select('paddle_customer_id')
|
||||
->from('tenants')
|
||||
->whereNotNull('paddle_customer_id')
|
||||
->groupBy('paddle_customer_id')
|
||||
->havingRaw('count(*) > 1');
|
||||
})),
|
||||
Filter::make('status_mismatch')
|
||||
->label('Status mismatch')
|
||||
->indicator('Status mismatch')
|
||||
->query(fn (Builder $query) => self::applyStatusMismatchFilter($query)),
|
||||
Filter::make('active_package')
|
||||
->label('Active package')
|
||||
->indicator('Active package')
|
||||
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', function (Builder $query) {
|
||||
$query->where('active', true)
|
||||
->where(function (Builder $query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>=', now());
|
||||
});
|
||||
})),
|
||||
Filter::make('not_suspended_or_deleted')
|
||||
->label('Not suspended/deleted')
|
||||
->indicator('Not suspended/deleted')
|
||||
->query(fn (Builder $query) => $query
|
||||
->where('is_suspended', false)
|
||||
->whereNull('pending_deletion_at')
|
||||
->whereNull('anonymized_at')),
|
||||
Filter::make('paddle_sync_failed')
|
||||
->label('Paddle sync failed')
|
||||
->indicator('Paddle sync failed')
|
||||
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
|
||||
->whereIn('paddle_sync_status', self::FAILED_SYNC_STATUSES))),
|
||||
Filter::make('paddle_sync_stale')
|
||||
->label('Paddle sync stale')
|
||||
->indicator('Paddle sync stale')
|
||||
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
|
||||
->whereNotNull('paddle_synced_at')
|
||||
->where('paddle_synced_at', '<', now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS)))),
|
||||
Filter::make('paddle_sync_missing')
|
||||
->label('Missing Paddle sync timestamp')
|
||||
->indicator('Missing Paddle sync timestamp')
|
||||
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
|
||||
->whereNull('paddle_synced_at'))),
|
||||
Filter::make('paddle_transaction_stale')
|
||||
->label('Stale Paddle transactions')
|
||||
->indicator('Stale Paddle transactions')
|
||||
->query(function (Builder $query): Builder {
|
||||
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||
|
||||
return $query
|
||||
->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'paddle'))
|
||||
->whereDoesntHave('purchases', fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('purchased_at', '>=', $cutoff));
|
||||
}),
|
||||
Filter::make('checkout_attention')
|
||||
->label('Checkout attention')
|
||||
->indicator('Checkout attention')
|
||||
->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) {
|
||||
$query->where('provider', 'paddle')
|
||||
->where(function (Builder $query) {
|
||||
$query->whereIn('status', [
|
||||
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
|
||||
CheckoutSession::STATUS_PROCESSING,
|
||||
])
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->whereNotIn('status', [
|
||||
CheckoutSession::STATUS_COMPLETED,
|
||||
CheckoutSession::STATUS_CANCELLED,
|
||||
])
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<', now());
|
||||
});
|
||||
});
|
||||
})),
|
||||
Filter::make('refund_spike')
|
||||
->label('Refund spike (30d)')
|
||||
->form([
|
||||
TextInput::make('min_refunds')
|
||||
->label('Minimum refunds')
|
||||
->numeric()
|
||||
->default(1)
|
||||
->minValue(1),
|
||||
])
|
||||
->indicateUsing(function (array $data): ?string {
|
||||
$min = (int) ($data['min_refunds'] ?? 0);
|
||||
|
||||
return $min > 0 ? "Refunds >= {$min} (30d)" : null;
|
||||
})
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$min = (int) ($data['min_refunds'] ?? 0);
|
||||
|
||||
if ($min < 1) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||
|
||||
return $query->whereHas('purchases', fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', true)
|
||||
->where('purchased_at', '>=', $cutoff), '>=', $min);
|
||||
}),
|
||||
SelectFilter::make('subscription_status')
|
||||
->label('Subscription')
|
||||
->options([
|
||||
'active' => 'Active',
|
||||
'suspended' => 'Suspended',
|
||||
'expired' => 'Expired',
|
||||
'free' => 'Free',
|
||||
]),
|
||||
])
|
||||
->actions([]);
|
||||
}
|
||||
|
||||
private static function hasStatusMismatch(Tenant $record): bool
|
||||
{
|
||||
$hasActivePackage = (bool) ($record->has_active_reseller_package ?? $record->activeResellerPackage);
|
||||
$status = (string) ($record->subscription_status ?? '');
|
||||
$expiresAt = $record->subscription_expires_at;
|
||||
|
||||
if ($status === 'active' && ! $hasActivePackage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($status !== 'active' && $hasActivePackage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($status === 'active' && $expiresAt && $expiresAt->isPast()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function missingPaddleSubscription(Tenant $record): bool
|
||||
{
|
||||
$package = $record->activeResellerPackage;
|
||||
|
||||
return $package && $package->active && ! $package->paddle_subscription_id;
|
||||
}
|
||||
|
||||
private static function applyStatusMismatchFilter(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query) {
|
||||
$query->where(function (Builder $query) {
|
||||
$query->where('subscription_status', 'active')
|
||||
->whereDoesntHave('activeResellerPackage');
|
||||
})->orWhere(function (Builder $query) {
|
||||
$query->where('subscription_status', '!=', 'active')
|
||||
->whereHas('activeResellerPackage');
|
||||
})->orWhere(function (Builder $query) {
|
||||
$query->where('subscription_status', 'active')
|
||||
->whereNotNull('subscription_expires_at')
|
||||
->where('subscription_expires_at', '<', now());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static function syncAgeColor($state): string
|
||||
{
|
||||
if (! $state) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
if ($state->lt(now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS))) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'success';
|
||||
}
|
||||
|
||||
private static function transactionAgeColor(?Carbon $state): string
|
||||
{
|
||||
if (! $state) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
if ($state->lt(now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS))) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'success';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages\ListTenantPaddleHealths;
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables\TenantPaddleHealthTable;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Tenant;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class TenantPaddleHealthResource extends Resource
|
||||
{
|
||||
public const STALE_SYNC_DAYS = 30;
|
||||
|
||||
public const TRANSACTION_WINDOW_DAYS = 30;
|
||||
|
||||
protected static ?string $model = Tenant::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
|
||||
|
||||
protected static ?string $cluster = DailyOpsCluster::class;
|
||||
|
||||
protected static ?string $slug = 'paddle-health';
|
||||
|
||||
protected static ?int $navigationSort = 20;
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return TenantPaddleHealthTable::configure($table);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.paddle_health.navigation.label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.billing');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$windowStart = now()->subDays(self::TRANSACTION_WINDOW_DAYS);
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['activeResellerPackage.package'])
|
||||
->withExists('activeResellerPackage as has_active_reseller_package')
|
||||
->addSelect([
|
||||
'paddle_customer_duplicates' => Tenant::query()
|
||||
->selectRaw('count(*)')
|
||||
->whereColumn('paddle_customer_id', 'tenants.paddle_customer_id')
|
||||
->whereNotNull('paddle_customer_id'),
|
||||
])
|
||||
->withCount([
|
||||
'purchases as paddle_transaction_count' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', false),
|
||||
'purchases as paddle_transaction_count_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', false)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
'purchases as paddle_refund_count_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', true)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
'checkoutSessions as paddle_checkout_requires_action_count' => fn (Builder $query) => $query
|
||||
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
|
||||
'checkoutSessions as paddle_checkout_processing_count' => fn (Builder $query) => $query
|
||||
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||
->where('status', CheckoutSession::STATUS_PROCESSING),
|
||||
'checkoutSessions as paddle_checkout_expired_count' => fn (Builder $query) => $query
|
||||
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||
->whereNotIn('status', [
|
||||
CheckoutSession::STATUS_COMPLETED,
|
||||
CheckoutSession::STATUS_CANCELLED,
|
||||
])
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<', now()),
|
||||
])
|
||||
->withSum([
|
||||
'purchases as paddle_transaction_total' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', false),
|
||||
], 'price')
|
||||
->withSum([
|
||||
'purchases as paddle_transaction_total_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', false)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
], 'price')
|
||||
->withSum([
|
||||
'purchases as paddle_refund_total_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', true)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
], 'price')
|
||||
->withMax([
|
||||
'purchases as last_paddle_transaction_at' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle'),
|
||||
], 'purchased_at');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListTenantPaddleHealths::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RedemptionsRelationManager extends RelationManager
|
||||
{
|
||||
@@ -25,6 +26,30 @@ class RedemptionsRelationManager extends RelationManager
|
||||
TextColumn::make('tenant.name')
|
||||
->label(__('Tenant'))
|
||||
->searchable(),
|
||||
TextColumn::make('ip_address')
|
||||
->label(__('IP'))
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('device_id')
|
||||
->label(__('Device'))
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('user_agent')
|
||||
->label(__('User agent'))
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->wrap(),
|
||||
TextColumn::make('fraud_ip')
|
||||
->label(__('IP reputation'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state, $record) => self::formatReputation(data_get($record->metadata, 'fraud.ip')))
|
||||
->color(fn ($state, $record) => self::riskColor(data_get($record->metadata, 'fraud.ip.risk')))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('fraud_device')
|
||||
->label(__('Device reputation'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state, $record) => self::formatReputation(data_get($record->metadata, 'fraud.device')))
|
||||
->color(fn ($state, $record) => self::riskColor(data_get($record->metadata, 'fraud.device.risk')))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('user.name')
|
||||
->label(__('User'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
@@ -69,4 +94,30 @@ class RedemptionsRelationManager extends RelationManager
|
||||
->recordActions([])
|
||||
->toolbarActions([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{risk?: string, recent_failed?: int, recent_total?: int}|null $snapshot
|
||||
*/
|
||||
private static function formatReputation(?array $snapshot): string
|
||||
{
|
||||
if (! $snapshot) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$risk = Str::headline($snapshot['risk'] ?? 'unknown');
|
||||
$failed = (int) ($snapshot['recent_failed'] ?? 0);
|
||||
$total = (int) ($snapshot['recent_total'] ?? 0);
|
||||
|
||||
return sprintf('%s (%d/%d)', $risk, $failed, $total);
|
||||
}
|
||||
|
||||
private static function riskColor(?string $risk): string
|
||||
{
|
||||
return match ($risk) {
|
||||
'high' => 'danger',
|
||||
'medium' => 'warning',
|
||||
'low' => 'success',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Filament\Resources\Coupons\Schemas;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use Filament\Infolists\Components\KeyValueEntry;
|
||||
use Filament\Infolists\Components\Section;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -22,11 +24,11 @@ class CouponInfolist
|
||||
TextEntry::make('status')
|
||||
->label(__('Status'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
||||
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
|
||||
TextEntry::make('type')
|
||||
->label(__('Discount type'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
||||
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
|
||||
TextEntry::make('amount')
|
||||
->label(__('Amount'))
|
||||
->formatStateUsing(fn ($state, $record) => $record?->type?->value === 'percentage'
|
||||
@@ -78,4 +80,21 @@ class CouponInfolist
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function formatEnumState(mixed $state): string
|
||||
{
|
||||
if ($state instanceof CouponType || $state instanceof CouponStatus) {
|
||||
return $state->label();
|
||||
}
|
||||
|
||||
if ($state instanceof \BackedEnum) {
|
||||
return Str::headline($state->value);
|
||||
}
|
||||
|
||||
if (is_string($state)) {
|
||||
return Str::headline($state);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class CouponsTable
|
||||
TextColumn::make('type')
|
||||
->label(__('Type'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => Str::headline($state))
|
||||
->formatStateUsing(fn ($state) => static::formatEnumState($state))
|
||||
->sortable(),
|
||||
TextColumn::make('amount')
|
||||
->label(__('Amount'))
|
||||
@@ -59,7 +59,7 @@ class CouponsTable
|
||||
->label(__('Status'))
|
||||
->badge()
|
||||
->sortable()
|
||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
||||
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
|
||||
TextColumn::make('starts_at')
|
||||
->label(__('Starts'))
|
||||
->date()
|
||||
@@ -151,4 +151,21 @@ class CouponsTable
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function formatEnumState(mixed $state): string
|
||||
{
|
||||
if ($state instanceof CouponType || $state instanceof CouponStatus) {
|
||||
return $state->label();
|
||||
}
|
||||
|
||||
if ($state instanceof \BackedEnum) {
|
||||
return Str::headline($state->value);
|
||||
}
|
||||
|
||||
if (is_string($state)) {
|
||||
return Str::headline($state);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
78
app/Filament/Resources/DataExportResource.php
Normal file
78
app/Filament/Resources/DataExportResource.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||
use App\Filament\Resources\DataExportResource\Pages\CreateDataExport;
|
||||
use App\Filament\Resources\DataExportResource\Pages\ListDataExports;
|
||||
use App\Filament\Resources\DataExportResource\Schemas\DataExportForm;
|
||||
use App\Filament\Resources\DataExportResource\Tables\DataExportTable;
|
||||
use App\Models\DataExport;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class DataExportResource extends Resource
|
||||
{
|
||||
protected static ?string $model = DataExport::class;
|
||||
|
||||
protected static ?string $cluster = RareAdminCluster::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowDownTray;
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 50;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return DataExportForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return DataExportTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.data_exports.navigation.label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->with(['tenant', 'event', 'user']);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListDataExports::route('/'),
|
||||
'create' => CreateDataExport::route('/create'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canEdit($record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canDelete($record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canDeleteAny(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DataExportResource\Pages;
|
||||
|
||||
use App\Enums\DataExportScope;
|
||||
use App\Filament\Resources\DataExportResource;
|
||||
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||
use App\Jobs\GenerateDataExport;
|
||||
use App\Models\DataExport;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
class CreateDataExport extends AuditedCreateRecord
|
||||
{
|
||||
protected static string $resource = DataExportResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data['user_id'] = Filament::auth()->id();
|
||||
$data['status'] = DataExport::STATUS_PENDING;
|
||||
|
||||
if (($data['scope'] ?? null) !== DataExportScope::EVENT->value) {
|
||||
$data['event_id'] = null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
parent::afterCreate();
|
||||
|
||||
GenerateDataExport::dispatch($this->record->id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DataExportResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DataExportResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListDataExports extends ListRecords
|
||||
{
|
||||
protected static string $resource = DataExportResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('admin.data_exports.actions.request')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DataExportResource\Schemas;
|
||||
|
||||
use App\Enums\DataExportScope;
|
||||
use App\Models\Event;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Get;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class DataExportForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make(__('admin.data_exports.sections.request'))
|
||||
->schema([
|
||||
Select::make('scope')
|
||||
->label(__('admin.data_exports.fields.scope'))
|
||||
->options([
|
||||
DataExportScope::TENANT->value => __('admin.data_exports.scope.tenant'),
|
||||
DataExportScope::EVENT->value => __('admin.data_exports.scope.event'),
|
||||
])
|
||||
->default(DataExportScope::TENANT->value)
|
||||
->live()
|
||||
->required(),
|
||||
Select::make('tenant_id')
|
||||
->label(__('admin.data_exports.fields.tenant'))
|
||||
->options(Tenant::query()->orderBy('name')->pluck('name', 'id'))
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->live(),
|
||||
Select::make('event_id')
|
||||
->label(__('admin.data_exports.fields.event'))
|
||||
->options(function (Get $get): array {
|
||||
$tenantId = $get('tenant_id');
|
||||
if (! $tenantId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Event::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderByDesc('date')
|
||||
->get()
|
||||
->mapWithKeys(function (Event $event): array {
|
||||
$name = $event->name['de'] ?? $event->name['en'] ?? $event->slug;
|
||||
|
||||
return [$event->id => $name];
|
||||
})
|
||||
->all();
|
||||
})
|
||||
->searchable()
|
||||
->preload()
|
||||
->visible(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value)
|
||||
->required(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value)
|
||||
->dehydrated(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value),
|
||||
Toggle::make('include_media')
|
||||
->label(__('admin.data_exports.fields.include_media'))
|
||||
->helperText(__('admin.data_exports.help.include_media')),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DataExportResource\Tables;
|
||||
|
||||
use App\Enums\DataExportScope;
|
||||
use App\Jobs\GenerateDataExport;
|
||||
use App\Models\DataExport;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Number;
|
||||
|
||||
class DataExportTable
|
||||
{
|
||||
public static function formatScope(DataExportScope|string|null $state): string
|
||||
{
|
||||
if ($state instanceof DataExportScope) {
|
||||
$state = $state->value;
|
||||
}
|
||||
|
||||
return $state ? __('admin.data_exports.scope.'.$state) : '—';
|
||||
}
|
||||
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label(__('admin.data_exports.fields.id'))
|
||||
->sortable(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label(__('admin.data_exports.fields.tenant'))
|
||||
->searchable(),
|
||||
TextColumn::make('event.slug')
|
||||
->label(__('admin.data_exports.fields.event'))
|
||||
->toggleable()
|
||||
->placeholder('—'),
|
||||
TextColumn::make('scope')
|
||||
->label(__('admin.data_exports.fields.scope'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (DataExportScope|string|null $state): string => self::formatScope($state)),
|
||||
TextColumn::make('status')
|
||||
->label(__('admin.data_exports.fields.status'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state) => __('admin.data_exports.status.'.$state))
|
||||
->color(fn (string $state) => match ($state) {
|
||||
DataExport::STATUS_READY => 'success',
|
||||
DataExport::STATUS_FAILED => 'danger',
|
||||
DataExport::STATUS_PROCESSING => 'warning',
|
||||
DataExport::STATUS_CANCELED => 'gray',
|
||||
default => 'gray',
|
||||
}),
|
||||
IconColumn::make('include_media')
|
||||
->label(__('admin.data_exports.fields.include_media'))
|
||||
->boolean(),
|
||||
TextColumn::make('size_bytes')
|
||||
->label(__('admin.data_exports.fields.size'))
|
||||
->formatStateUsing(fn (?int $state) => $state ? Number::fileSize($state) : '—')
|
||||
->toggleable(),
|
||||
TextColumn::make('created_at')
|
||||
->label(__('admin.data_exports.fields.created_at'))
|
||||
->since()
|
||||
->sortable(),
|
||||
TextColumn::make('expires_at')
|
||||
->label(__('admin.data_exports.fields.expires_at'))
|
||||
->since()
|
||||
->toggleable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('scope')
|
||||
->label(__('admin.data_exports.fields.scope'))
|
||||
->options([
|
||||
'tenant' => __('admin.data_exports.scope.tenant'),
|
||||
'event' => __('admin.data_exports.scope.event'),
|
||||
'user' => __('admin.data_exports.scope.user'),
|
||||
]),
|
||||
SelectFilter::make('status')
|
||||
->label(__('admin.data_exports.fields.status'))
|
||||
->options([
|
||||
DataExport::STATUS_PENDING => __('admin.data_exports.status.pending'),
|
||||
DataExport::STATUS_PROCESSING => __('admin.data_exports.status.processing'),
|
||||
DataExport::STATUS_READY => __('admin.data_exports.status.ready'),
|
||||
DataExport::STATUS_FAILED => __('admin.data_exports.status.failed'),
|
||||
DataExport::STATUS_CANCELED => __('admin.data_exports.status.canceled'),
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Action::make('download')
|
||||
->label(__('admin.data_exports.actions.download'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->url(fn (DataExport $record) => route('superadmin.data-exports.download', $record))
|
||||
->openUrlInNewTab()
|
||||
->visible(fn (DataExport $record): bool => $record->isReady() && ! $record->hasExpired()),
|
||||
Action::make('retry')
|
||||
->label(__('admin.data_exports.actions.retry'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (DataExport $record): bool => $record->canRetry())
|
||||
->action(function (DataExport $record): void {
|
||||
if (! $record->canRetry()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->resetForRetry();
|
||||
GenerateDataExport::dispatch($record->id);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$record,
|
||||
source: self::class
|
||||
);
|
||||
}),
|
||||
Action::make('cancel')
|
||||
->label(__('admin.data_exports.actions.cancel'))
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (DataExport $record): bool => $record->canCancel())
|
||||
->action(function (DataExport $record): void {
|
||||
if (! $record->canCancel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->markCanceled();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$record,
|
||||
source: self::class
|
||||
);
|
||||
}),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,11 @@ class PackageResource extends Resource
|
||||
->label('Zuletzt synchronisiert')
|
||||
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '–')
|
||||
->columnSpanFull(),
|
||||
Placeholder::make('paddle_sync_error')
|
||||
->label('Letzter Fehler')
|
||||
->content(fn (?Package $record) => $record?->paddle_sync_error_message ?? '–')
|
||||
->visible(fn (?Package $record) => filled($record?->paddle_sync_error_message))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -271,7 +276,7 @@ class PackageResource extends Resource
|
||||
->colors([
|
||||
'success' => 'synced',
|
||||
'warning' => 'syncing',
|
||||
'info' => 'dry-run',
|
||||
'info' => ['dry-run', 'linked', 'pulled'],
|
||||
'danger' => ['failed', 'pull-failed'],
|
||||
])
|
||||
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
|
||||
@@ -280,6 +285,11 @@ class PackageResource extends Resource
|
||||
->label('Sync am')
|
||||
->dateTime()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_sync_error_message')
|
||||
->label('Sync-Fehler')
|
||||
->getStateUsing(fn (Package $record) => $record->paddle_sync_error_message)
|
||||
->wrap()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('type')
|
||||
@@ -306,6 +316,42 @@ class PackageResource extends Resource
|
||||
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.')
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('linkPaddle')
|
||||
->label('Paddle verknüpfen')
|
||||
->icon('heroicon-o-link')
|
||||
->color('info')
|
||||
->form([
|
||||
TextInput::make('paddle_product_id')
|
||||
->label('Paddle Produkt-ID')
|
||||
->required()
|
||||
->maxLength(191),
|
||||
TextInput::make('paddle_price_id')
|
||||
->label('Paddle Preis-ID')
|
||||
->required()
|
||||
->maxLength(191),
|
||||
])
|
||||
->fillForm(fn (Package $record) => [
|
||||
'paddle_product_id' => $record->paddle_product_id,
|
||||
'paddle_price_id' => $record->paddle_price_id,
|
||||
])
|
||||
->action(function (Package $record, array $data): void {
|
||||
$record->linkPaddleIds($data['paddle_product_id'], $data['paddle_price_id']);
|
||||
|
||||
PullPackageFromPaddle::dispatch($record->id);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'linked',
|
||||
$record,
|
||||
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||
static::class
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Paddle-Verknüpfung gespeichert')
|
||||
->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.')
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('pullPaddle')
|
||||
->label('Status von Paddle holen')
|
||||
->icon('heroicon-o-cloud-arrow-down')
|
||||
|
||||
75
app/Filament/Resources/RetentionOverrideResource.php
Normal file
75
app/Filament/Resources/RetentionOverrideResource.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||
use App\Filament\Resources\RetentionOverrideResource\Pages\CreateRetentionOverride;
|
||||
use App\Filament\Resources\RetentionOverrideResource\Pages\EditRetentionOverride;
|
||||
use App\Filament\Resources\RetentionOverrideResource\Pages\ListRetentionOverrides;
|
||||
use App\Filament\Resources\RetentionOverrideResource\Schemas\RetentionOverrideForm;
|
||||
use App\Filament\Resources\RetentionOverrideResource\Tables\RetentionOverrideTable;
|
||||
use App\Models\RetentionOverride;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class RetentionOverrideResource extends Resource
|
||||
{
|
||||
protected static ?string $model = RetentionOverride::class;
|
||||
|
||||
protected static ?string $cluster = RareAdminCluster::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldExclamation;
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 55;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return RetentionOverrideForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return RetentionOverrideTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.retention_overrides.navigation.label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->with(['tenant', 'event', 'createdBy', 'releasedBy']);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListRetentionOverrides::route('/'),
|
||||
'create' => CreateRetentionOverride::route('/create'),
|
||||
'edit' => EditRetentionOverride::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canDelete($record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canDeleteAny(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
|
||||
|
||||
use App\Enums\RetentionOverrideScope;
|
||||
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||
use App\Filament\Resources\RetentionOverrideResource;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
class CreateRetentionOverride extends AuditedCreateRecord
|
||||
{
|
||||
protected static string $resource = RetentionOverrideResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data['created_by_id'] = Filament::auth()->id();
|
||||
$data['released_at'] = null;
|
||||
$data['released_by_id'] = null;
|
||||
|
||||
if (($data['scope'] ?? null) !== RetentionOverrideScope::EVENT->value) {
|
||||
$data['event_id'] = null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
|
||||
|
||||
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||
use App\Filament\Resources\RetentionOverrideResource;
|
||||
use App\Models\RetentionOverride;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
class EditRetentionOverride extends AuditedEditRecord
|
||||
{
|
||||
protected static string $resource = RetentionOverrideResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('release')
|
||||
->label(__('admin.retention_overrides.actions.release'))
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->visible(fn () => $this->record instanceof RetentionOverride && $this->record->released_at === null)
|
||||
->action(function (): void {
|
||||
if (! ($this->record instanceof RetentionOverride) || $this->record->released_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record->forceFill([
|
||||
'released_at' => now(),
|
||||
'released_by_id' => Filament::auth()->id(),
|
||||
])->save();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$this->record,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['released_at', 'released_by_id']),
|
||||
static::class
|
||||
);
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
|
||||
|
||||
use App\Filament\Resources\RetentionOverrideResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListRetentionOverrides extends ListRecords
|
||||
{
|
||||
protected static string $resource = RetentionOverrideResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('admin.retention_overrides.actions.request')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RetentionOverrideResource\Schemas;
|
||||
|
||||
use App\Enums\RetentionOverrideScope;
|
||||
use App\Models\Event;
|
||||
use App\Models\RetentionOverride;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class RetentionOverrideForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Section::make(__('admin.retention_overrides.sections.override'))
|
||||
->schema([
|
||||
Select::make('scope')
|
||||
->label(__('admin.retention_overrides.fields.scope'))
|
||||
->options([
|
||||
RetentionOverrideScope::TENANT->value => __('admin.retention_overrides.scope.tenant'),
|
||||
RetentionOverrideScope::EVENT->value => __('admin.retention_overrides.scope.event'),
|
||||
])
|
||||
->default(RetentionOverrideScope::TENANT->value)
|
||||
->required()
|
||||
->live()
|
||||
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||
Select::make('tenant_id')
|
||||
->label(__('admin.retention_overrides.fields.tenant'))
|
||||
->options(Tenant::query()->orderBy('name')->pluck('name', 'id'))
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->live()
|
||||
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||
Select::make('event_id')
|
||||
->label(__('admin.retention_overrides.fields.event'))
|
||||
->options(function (Get $get): array {
|
||||
$tenantId = $get('tenant_id');
|
||||
if (! $tenantId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Event::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderByDesc('date')
|
||||
->get()
|
||||
->mapWithKeys(function (Event $event): array {
|
||||
$name = $event->name['de'] ?? $event->name['en'] ?? $event->slug;
|
||||
|
||||
return [$event->id => $name];
|
||||
})
|
||||
->all();
|
||||
})
|
||||
->searchable()
|
||||
->preload()
|
||||
->visible(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value)
|
||||
->required(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value)
|
||||
->dehydrated(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value)
|
||||
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||
TextInput::make('reason')
|
||||
->label(__('admin.retention_overrides.fields.reason'))
|
||||
->maxLength(200)
|
||||
->required()
|
||||
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||
Textarea::make('note')
|
||||
->label(__('admin.retention_overrides.fields.note'))
|
||||
->rows(3)
|
||||
->maxLength(2000)
|
||||
->columnSpanFull()
|
||||
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make(__('admin.retention_overrides.sections.status'))
|
||||
->schema([
|
||||
Placeholder::make('created_by_id')
|
||||
->label(__('admin.retention_overrides.fields.created_by'))
|
||||
->content(fn (?RetentionOverride $record) => $record?->createdBy?->name ?? '—'),
|
||||
Placeholder::make('created_at')
|
||||
->label(__('admin.retention_overrides.fields.created_at'))
|
||||
->content(fn (?RetentionOverride $record) => $record?->created_at?->diffForHumans() ?? '—'),
|
||||
Placeholder::make('released_by_id')
|
||||
->label(__('admin.retention_overrides.fields.released_by'))
|
||||
->content(fn (?RetentionOverride $record) => $record?->releasedBy?->name ?? '—'),
|
||||
Placeholder::make('released_at')
|
||||
->label(__('admin.retention_overrides.fields.released_at'))
|
||||
->content(fn (?RetentionOverride $record) => $record?->released_at?->diffForHumans() ?? '—'),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RetentionOverrideResource\Tables;
|
||||
|
||||
use App\Models\RetentionOverride;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RetentionOverrideTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label(__('admin.retention_overrides.fields.id'))
|
||||
->sortable(),
|
||||
TextColumn::make('scope')
|
||||
->label(__('admin.retention_overrides.fields.scope'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state) => $state ? __('admin.retention_overrides.scope.'.$state) : '—'),
|
||||
TextColumn::make('tenant.name')
|
||||
->label(__('admin.retention_overrides.fields.tenant'))
|
||||
->searchable(),
|
||||
TextColumn::make('event.slug')
|
||||
->label(__('admin.retention_overrides.fields.event'))
|
||||
->toggleable()
|
||||
->placeholder('—'),
|
||||
TextColumn::make('reason')
|
||||
->label(__('admin.retention_overrides.fields.reason'))
|
||||
->limit(40)
|
||||
->searchable(),
|
||||
TextColumn::make('status')
|
||||
->label(__('admin.retention_overrides.fields.status'))
|
||||
->state(fn (RetentionOverride $record) => $record->released_at ? 'released' : 'active')
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state) => __('admin.retention_overrides.status.'.$state))
|
||||
->color(fn (string $state) => $state === 'released' ? 'gray' : 'success'),
|
||||
TextColumn::make('createdBy.name')
|
||||
->label(__('admin.retention_overrides.fields.created_by'))
|
||||
->toggleable()
|
||||
->placeholder('—'),
|
||||
TextColumn::make('created_at')
|
||||
->label(__('admin.retention_overrides.fields.created_at'))
|
||||
->since()
|
||||
->sortable(),
|
||||
TextColumn::make('releasedBy.name')
|
||||
->label(__('admin.retention_overrides.fields.released_by'))
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->placeholder('—'),
|
||||
TextColumn::make('released_at')
|
||||
->label(__('admin.retention_overrides.fields.released_at'))
|
||||
->since()
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->placeholder('—'),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('scope')
|
||||
->label(__('admin.retention_overrides.fields.scope'))
|
||||
->options([
|
||||
'tenant' => __('admin.retention_overrides.scope.tenant'),
|
||||
'event' => __('admin.retention_overrides.scope.event'),
|
||||
]),
|
||||
SelectFilter::make('status')
|
||||
->label(__('admin.retention_overrides.fields.status'))
|
||||
->options([
|
||||
'active' => __('admin.retention_overrides.status.active'),
|
||||
'released' => __('admin.retention_overrides.status.released'),
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
return match ($data['value'] ?? null) {
|
||||
'active' => $query->whereNull('released_at'),
|
||||
'released' => $query->whereNotNull('released_at'),
|
||||
default => $query,
|
||||
};
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Action::make('release')
|
||||
->label(__('admin.retention_overrides.actions.release'))
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (RetentionOverride $record): bool => $record->released_at === null)
|
||||
->action(function (RetentionOverride $record): void {
|
||||
if ($record->released_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->forceFill([
|
||||
'released_at' => now(),
|
||||
'released_by_id' => Filament::auth()->id(),
|
||||
])->save();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$record,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['released_at', 'released_by_id']),
|
||||
static::class
|
||||
);
|
||||
}),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Filament\Resources\TenantAnnouncementResource\Pages;
|
||||
use App\Models\TenantAnnouncement;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
@@ -201,7 +202,7 @@ class TenantAnnouncementResource extends Resource
|
||||
->options($audienceOptions),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make()
|
||||
Actions\EditAction::make()
|
||||
->after(fn (array $data, TenantAnnouncement $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$record,
|
||||
@@ -210,7 +211,7 @@ class TenantAnnouncementResource extends Resource
|
||||
)),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\DeleteBulkAction::make()
|
||||
Actions\DeleteBulkAction::make()
|
||||
->after(function (Collection $records): void {
|
||||
$logger = app(SuperAdminAuditLogger::class);
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class Login extends BaseLogin implements HasForms
|
||||
}
|
||||
|
||||
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
|
||||
if ($user->role !== 'super_admin') {
|
||||
if (! $user->isSuperAdmin()) {
|
||||
$authGuard->logout();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
|
||||
@@ -45,14 +45,16 @@ class GuestPolicySettingsPage extends Page
|
||||
|
||||
public int $join_token_failure_decay_minutes = 5;
|
||||
|
||||
public int $join_token_access_limit = 120;
|
||||
public int $join_token_access_limit = 300;
|
||||
|
||||
public int $join_token_access_decay_minutes = 1;
|
||||
|
||||
public int $join_token_download_limit = 60;
|
||||
public int $join_token_download_limit = 120;
|
||||
|
||||
public int $join_token_download_decay_minutes = 1;
|
||||
|
||||
public int $join_token_ttl_hours = 168;
|
||||
|
||||
public int $share_link_ttl_hours = 48;
|
||||
|
||||
public ?int $guest_notification_ttl_hours = null;
|
||||
@@ -67,10 +69,11 @@ class GuestPolicySettingsPage extends Page
|
||||
$this->per_device_upload_limit = (int) ($settings->per_device_upload_limit ?? 50);
|
||||
$this->join_token_failure_limit = (int) ($settings->join_token_failure_limit ?? 10);
|
||||
$this->join_token_failure_decay_minutes = (int) ($settings->join_token_failure_decay_minutes ?? 5);
|
||||
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 120);
|
||||
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 300);
|
||||
$this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1);
|
||||
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 60);
|
||||
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 120);
|
||||
$this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1);
|
||||
$this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168);
|
||||
$this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);
|
||||
$this->guest_notification_ttl_hours = $settings->guest_notification_ttl_hours;
|
||||
}
|
||||
@@ -130,6 +133,11 @@ class GuestPolicySettingsPage extends Page
|
||||
->columns(2),
|
||||
Section::make(__('admin.guest_policy.sections.retention'))
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('join_token_ttl_hours')
|
||||
->label(__('admin.guest_policy.fields.join_token_ttl_hours'))
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->helperText(__('admin.guest_policy.help.join_token_ttl')),
|
||||
Forms\Components\TextInput::make('share_link_ttl_hours')
|
||||
->label(__('admin.guest_policy.fields.share_link_ttl_hours'))
|
||||
->numeric()
|
||||
@@ -160,6 +168,7 @@ class GuestPolicySettingsPage extends Page
|
||||
$settings->join_token_access_decay_minutes = (int) $this->join_token_access_decay_minutes;
|
||||
$settings->join_token_download_limit = (int) $this->join_token_download_limit;
|
||||
$settings->join_token_download_decay_minutes = (int) $this->join_token_download_decay_minutes;
|
||||
$settings->join_token_ttl_hours = (int) $this->join_token_ttl_hours;
|
||||
$settings->share_link_ttl_hours = (int) $this->share_link_ttl_hours;
|
||||
$settings->guest_notification_ttl_hours = $this->guest_notification_ttl_hours;
|
||||
$settings->save();
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\SuperAdmin\Pages;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||
use App\Filament\Widgets\IntegrationsHealthWidget;
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class IntegrationsHealthDashboard extends Page
|
||||
{
|
||||
protected string $view = 'filament.super-admin.pages.integrations-health-dashboard';
|
||||
|
||||
protected static ?string $cluster = DailyOpsCluster::class;
|
||||
|
||||
protected static null|string|BackedEnum $navigationIcon = 'heroicon-o-link';
|
||||
|
||||
protected static null|string|UnitEnum $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 15;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.infrastructure');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.integrations_health.navigation.label');
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
IntegrationsHealthWidget::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Pages;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Support\JoinTokenLayoutRegistry;
|
||||
use App\Support\TenantOnboardingState;
|
||||
use BackedEnum;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class InviteStudio extends Page
|
||||
{
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-qr-code';
|
||||
|
||||
protected string $view = 'filament.tenant.pages.invite-studio';
|
||||
|
||||
protected static ?string $navigationLabel = 'Einladungen & QR';
|
||||
|
||||
protected static ?string $slug = 'invite-studio';
|
||||
|
||||
protected static ?string $title = 'Einladungen & QR-Codes';
|
||||
|
||||
protected static ?int $navigationSort = 50;
|
||||
|
||||
public ?int $selectedEventId = null;
|
||||
|
||||
public string $tokenLabel = '';
|
||||
|
||||
public array $tokens = [];
|
||||
|
||||
public array $layouts = [];
|
||||
|
||||
protected static bool $shouldRegisterNavigation = true;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
abort_if(! $tenant, 403);
|
||||
|
||||
if (! TenantOnboardingState::completed($tenant)) {
|
||||
$this->redirect(TenantOnboarding::getUrl());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$firstEventId = $tenant->events()->orderBy('date')->value('id');
|
||||
|
||||
$this->selectedEventId = $firstEventId;
|
||||
$this->layouts = $this->buildLayouts();
|
||||
|
||||
if ($this->selectedEventId) {
|
||||
$this->loadEventContext();
|
||||
}
|
||||
}
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return TenantOnboardingState::completed();
|
||||
}
|
||||
|
||||
public function updatedSelectedEventId(): void
|
||||
{
|
||||
$this->loadEventContext();
|
||||
}
|
||||
|
||||
public function createInvite(EventJoinTokenService $service): void
|
||||
{
|
||||
$this->validate([
|
||||
'selectedEventId' => ['required', 'exists:events,id'],
|
||||
'tokenLabel' => ['nullable', 'string', 'max:120'],
|
||||
]);
|
||||
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
abort_if(! $tenant, 403);
|
||||
|
||||
$event = $tenant->events()->whereKey($this->selectedEventId)->first();
|
||||
|
||||
if (! $event) {
|
||||
Notification::make()
|
||||
->title('Event konnte nicht gefunden werden')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$label = $this->tokenLabel ?: 'Einladung '.now()->format('d.m.');
|
||||
|
||||
$layoutPreference = Arr::get($tenant->settings ?? [], 'branding.preferred_invite_layout');
|
||||
|
||||
$service->createToken($event, [
|
||||
'label' => $label,
|
||||
'metadata' => [
|
||||
'preferred_layout' => $layoutPreference,
|
||||
],
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$this->tokenLabel = '';
|
||||
|
||||
$this->loadEventContext();
|
||||
|
||||
Notification::make()
|
||||
->title('Neuer Einladungslink erstellt')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
protected function loadEventContext(): void
|
||||
{
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
if (! $tenant || ! $this->selectedEventId) {
|
||||
$this->tokens = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$event = $tenant->events()->whereKey($this->selectedEventId)->first();
|
||||
|
||||
if (! $event) {
|
||||
$this->tokens = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tokens = $event->joinTokens()
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn (EventJoinToken $token) => $this->mapToken($event, $token))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected function mapToken(Event $event, EventJoinToken $token): array
|
||||
{
|
||||
$downloadUrls = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) {
|
||||
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
||||
'event' => $event->slug,
|
||||
'joinToken' => $token->getKey(),
|
||||
'layout' => $layoutId,
|
||||
'format' => $format,
|
||||
]);
|
||||
});
|
||||
|
||||
return [
|
||||
'id' => $token->getKey(),
|
||||
'label' => $token->label ?? 'Einladungslink',
|
||||
'url' => URL::to('/e/'.$token->token),
|
||||
'created_at' => optional($token->created_at)->format('d.m.Y H:i'),
|
||||
'usage_count' => $token->usage_count,
|
||||
'usage_limit' => $token->usage_limit,
|
||||
'active' => $token->isActive(),
|
||||
'downloads' => $downloadUrls,
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildLayouts(): array
|
||||
{
|
||||
return collect(JoinTokenLayoutRegistry::all())
|
||||
->map(fn (array $layout) => [
|
||||
'id' => $layout['id'],
|
||||
'name' => $layout['name'],
|
||||
'subtitle' => $layout['subtitle'] ?? '',
|
||||
'description' => $layout['description'] ?? '',
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getEventsProperty(): Collection
|
||||
{
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
if (! $tenant) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $tenant->events()->orderBy('date')->get();
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\EventResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventType;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Services\Tenant\TaskCollectionImportService;
|
||||
use App\Support\JoinTokenLayoutRegistry;
|
||||
use App\Support\TenantOnboardingState;
|
||||
use BackedEnum;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
use UnitEnum;
|
||||
|
||||
class TenantOnboarding extends Page
|
||||
{
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-sparkles';
|
||||
|
||||
protected string $view = 'filament.tenant.pages.onboarding';
|
||||
|
||||
protected static ?string $navigationLabel = 'Willkommen';
|
||||
|
||||
protected static ?string $slug = 'willkommen';
|
||||
|
||||
protected static ?string $title = 'Euer Start mit Fotospiel';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = null;
|
||||
|
||||
public string $step = 'intro';
|
||||
|
||||
public array $status = [];
|
||||
|
||||
public array $inviteDownloads = [];
|
||||
|
||||
public array $selectedPackages = [];
|
||||
|
||||
public string $eventName = '';
|
||||
|
||||
public ?string $eventDate = null;
|
||||
|
||||
public ?int $eventTypeId = null;
|
||||
|
||||
public ?string $palette = null;
|
||||
|
||||
public ?string $inviteLayout = null;
|
||||
|
||||
public bool $isProcessing = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = true;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
abort_if(! $tenant, 403);
|
||||
|
||||
$this->status = TenantOnboardingState::status($tenant);
|
||||
|
||||
if (TenantOnboardingState::completed($tenant)) {
|
||||
$this->redirect(EventResource::getUrl());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->eventDate = Carbon::now()->addWeeks(2)->format('Y-m-d');
|
||||
$this->eventTypeId = $this->getDefaultEventTypeId();
|
||||
}
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
return ! TenantOnboardingState::completed($tenant);
|
||||
}
|
||||
|
||||
public function start(): void
|
||||
{
|
||||
$this->step = 'packages';
|
||||
}
|
||||
|
||||
public function savePackages(): void
|
||||
{
|
||||
$this->validate([
|
||||
'selectedPackages' => ['required', 'array', 'min:1'],
|
||||
'selectedPackages.*' => ['integer', 'exists:task_collections,id'],
|
||||
], [
|
||||
'selectedPackages.required' => 'Bitte wählt mindestens ein Aufgabenpaket aus.',
|
||||
]);
|
||||
|
||||
$this->step = 'event';
|
||||
}
|
||||
|
||||
public function saveEvent(): void
|
||||
{
|
||||
$this->validate([
|
||||
'eventName' => ['required', 'string', 'max:255'],
|
||||
'eventDate' => ['required', 'date'],
|
||||
'eventTypeId' => ['required', 'exists:event_types,id'],
|
||||
]);
|
||||
|
||||
$this->step = 'palette';
|
||||
}
|
||||
|
||||
public function savePalette(): void
|
||||
{
|
||||
$this->validate([
|
||||
'palette' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$this->step = 'invite';
|
||||
}
|
||||
|
||||
public function finish(
|
||||
TaskCollectionImportService $importService,
|
||||
EventJoinTokenService $joinTokenService
|
||||
): void {
|
||||
$this->validate([
|
||||
'inviteLayout' => ['required', 'string'],
|
||||
], [
|
||||
'inviteLayout.required' => 'Bitte wählt ein Layout aus.',
|
||||
]);
|
||||
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
abort_if(! $tenant, 403);
|
||||
|
||||
$this->isProcessing = true;
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($tenant, $importService, $joinTokenService) {
|
||||
$event = $this->createEvent($tenant);
|
||||
$this->importPackages($importService, $this->selectedPackages, $event);
|
||||
|
||||
$token = $joinTokenService->createToken($event, [
|
||||
'label' => 'Fotospiel Einladung',
|
||||
'metadata' => [
|
||||
'preferred_layout' => $this->inviteLayout,
|
||||
],
|
||||
]);
|
||||
|
||||
$settings = $tenant->settings ?? [];
|
||||
Arr::set($settings, 'branding.palette', $this->palette);
|
||||
Arr::set($settings, 'branding.primary_event_id', $event->id);
|
||||
Arr::set($settings, 'branding.preferred_invite_layout', $this->inviteLayout);
|
||||
$tenant->forceFill(['settings' => $settings])->save();
|
||||
|
||||
TenantOnboardingState::markCompleted($tenant, [
|
||||
'primary_event_id' => $event->id,
|
||||
'selected_packages' => $this->selectedPackages,
|
||||
'qr_layout' => $this->inviteLayout,
|
||||
]);
|
||||
|
||||
$this->inviteDownloads = $this->buildInviteDownloads($event, $token);
|
||||
$this->status = TenantOnboardingState::status($tenant);
|
||||
|
||||
Notification::make()
|
||||
->title('Euer Setup ist bereit!')
|
||||
->body('Wir haben euer Event erstellt, Aufgaben importiert und euren Einladungslink vorbereitet.')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(EventResource::getUrl('view', ['record' => $event]));
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
Notification::make()
|
||||
->title('Setup konnte nicht abgeschlossen werden')
|
||||
->body('Bitte prüft eure Eingaben oder versucht es später erneut.')
|
||||
->danger()
|
||||
->send();
|
||||
} finally {
|
||||
$this->isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function createEvent($tenant): Event
|
||||
{
|
||||
$slugBase = Str::slug($this->eventName) ?: 'event';
|
||||
|
||||
do {
|
||||
$slug = Str::of($slugBase)->append('-', Str::random(6))->lower();
|
||||
} while (Event::where('slug', $slug)->exists());
|
||||
|
||||
return Event::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => [
|
||||
app()->getLocale() => $this->eventName,
|
||||
'de' => $this->eventName,
|
||||
],
|
||||
'description' => null,
|
||||
'date' => $this->eventDate,
|
||||
'slug' => (string) $slug,
|
||||
'event_type_id' => $this->eventTypeId,
|
||||
'is_active' => true,
|
||||
'default_locale' => app()->getLocale(),
|
||||
'status' => 'draft',
|
||||
'settings' => [
|
||||
'appearance' => [
|
||||
'palette' => $this->palette,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function importPackages(
|
||||
TaskCollectionImportService $importService,
|
||||
array $packageIds,
|
||||
Event $event
|
||||
): void {
|
||||
if (empty($packageIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var EloquentCollection<TaskCollection> $collections */
|
||||
$collections = TaskCollection::query()
|
||||
->whereIn('id', $packageIds)
|
||||
->get();
|
||||
|
||||
$collections->each(function (TaskCollection $collection) use ($importService, $event) {
|
||||
$importService->import($collection, $event);
|
||||
});
|
||||
}
|
||||
|
||||
protected function buildInviteDownloads(Event $event, $token): array
|
||||
{
|
||||
return JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) {
|
||||
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
||||
'event' => $event->slug,
|
||||
'joinToken' => $token->getKey(),
|
||||
'layout' => $layoutId,
|
||||
'format' => $format,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function getPackageListProperty(): array
|
||||
{
|
||||
return TaskCollection::query()
|
||||
->whereNull('tenant_id')
|
||||
->orderBy('position')
|
||||
->get()
|
||||
->map(fn (TaskCollection $collection) => [
|
||||
'id' => $collection->getKey(),
|
||||
'name' => $collection->name,
|
||||
'description' => $collection->description,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getEventTypeOptionsProperty(): array
|
||||
{
|
||||
return EventType::query()
|
||||
->orderBy('name->'.app()->getLocale())
|
||||
->get()
|
||||
->mapWithKeys(function (EventType $type) {
|
||||
$name = $type->name[app()->getLocale()] ?? $type->name['de'] ?? Arr::first($type->name);
|
||||
|
||||
return [$type->getKey() => $name];
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getPaletteOptionsProperty(): array
|
||||
{
|
||||
return [
|
||||
'romance' => [
|
||||
'label' => 'Rosé & Gold',
|
||||
'description' => 'Warme Rosé-Töne mit goldenen Akzenten – romantisch und elegant.',
|
||||
],
|
||||
'sunset' => [
|
||||
'label' => 'Sonnenuntergang',
|
||||
'description' => 'Leuchtende Orange- und Pink-Verläufe für lebhafte Partys.',
|
||||
],
|
||||
'evergreen' => [
|
||||
'label' => 'Evergreen',
|
||||
'description' => 'Sanfte Grüntöne und Naturakzente für Boho- & Outdoor-Events.',
|
||||
],
|
||||
'midnight' => [
|
||||
'label' => 'Midnight',
|
||||
'description' => 'Tiefes Navy und Flieder – perfekt für elegante Abendveranstaltungen.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getLayoutOptionsProperty(): array
|
||||
{
|
||||
return collect(JoinTokenLayoutRegistry::all())
|
||||
->map(fn ($layout) => [
|
||||
'id' => $layout['id'],
|
||||
'name' => $layout['name'],
|
||||
'subtitle' => $layout['subtitle'] ?? '',
|
||||
'description' => $layout['description'] ?? '',
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected function getDefaultEventTypeId(): ?int
|
||||
{
|
||||
return EventType::query()->orderBy('name->'.app()->getLocale())->value('id');
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\EventResource\Pages;
|
||||
use App\Filament\Tenant\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinTokenEvent;
|
||||
use App\Models\EventType;
|
||||
use App\Support\JoinTokenLayoutRegistry;
|
||||
use App\Support\TenantOnboardingState;
|
||||
use BackedEnum;
|
||||
use Carbon\Carbon;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use UnitEnum;
|
||||
|
||||
class EventResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Event::class;
|
||||
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-calendar';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = null;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform');
|
||||
}
|
||||
|
||||
protected static ?int $navigationSort = 20;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return TenantOnboardingState::completed();
|
||||
}
|
||||
|
||||
public static function form(Schema $form): Schema
|
||||
{
|
||||
$tenantId = Auth::user()?->tenant_id;
|
||||
|
||||
return $form->schema([
|
||||
Hidden::make('tenant_id')
|
||||
->default($tenantId)
|
||||
->dehydrated(),
|
||||
TextInput::make('name')
|
||||
->label(__('admin.events.fields.name'))
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('slug')
|
||||
->label(__('admin.events.fields.slug'))
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(255),
|
||||
DatePicker::make('date')
|
||||
->label(__('admin.events.fields.date'))
|
||||
->required(),
|
||||
Select::make('event_type_id')
|
||||
->label(__('admin.events.fields.type'))
|
||||
->options(EventType::all()->pluck('name', 'id'))
|
||||
->searchable(),
|
||||
Select::make('package_id')
|
||||
->label(__('admin.events.fields.package'))
|
||||
->options(\App\Models\Package::where('type', 'endcustomer')->pluck('name', 'id'))
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
TextInput::make('default_locale')
|
||||
->label(__('admin.events.fields.default_locale'))
|
||||
->default('de')
|
||||
->maxLength(5),
|
||||
Toggle::make('is_active')
|
||||
->label(__('admin.events.fields.is_active'))
|
||||
->default(true),
|
||||
KeyValue::make('settings')
|
||||
->label(__('admin.events.fields.settings'))
|
||||
->keyLabel(__('admin.common.key'))
|
||||
->valueLabel(__('admin.common.value')),
|
||||
])->columns(2);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')->sortable(),
|
||||
Tables\Columns\TextColumn::make('eventPackage.package.name')
|
||||
->label(__('admin.events.table.package'))
|
||||
->badge()
|
||||
->color('success'),
|
||||
Tables\Columns\TextColumn::make('name')->limit(30),
|
||||
Tables\Columns\TextColumn::make('slug')->searchable(),
|
||||
Tables\Columns\TextColumn::make('date')->date(),
|
||||
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
||||
Tables\Columns\TextColumn::make('default_locale'),
|
||||
Tables\Columns\TextColumn::make('eventPackage.used_photos')
|
||||
->label(__('admin.events.table.used_photos'))
|
||||
->badge(),
|
||||
Tables\Columns\TextColumn::make('eventPackage.remaining_photos')
|
||||
->label(__('admin.events.table.remaining_photos'))
|
||||
->badge()
|
||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
|
||||
Tables\Columns\TextColumn::make('primary_join_token')
|
||||
->label(__('admin.events.table.join'))
|
||||
->getStateUsing(function ($record) {
|
||||
$token = $record->joinTokens()->orderByDesc('created_at')->first();
|
||||
|
||||
return $token ? url('/e/'.$token->token) : __('admin.events.table.no_join_tokens');
|
||||
})
|
||||
->description(function ($record) {
|
||||
$total = $record->joinTokens()->count();
|
||||
|
||||
return $total > 0
|
||||
? __('admin.events.table.join_tokens_total', ['count' => $total])
|
||||
: __('admin.events.table.join_tokens_missing');
|
||||
})
|
||||
->copyable()
|
||||
->copyMessage(__('admin.events.messages.join_link_copied')),
|
||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||
])
|
||||
->modifyQueryUsing(function (Builder $query) {
|
||||
if ($tenantId = Auth::user()?->tenant_id) {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
}
|
||||
})
|
||||
->filters([])
|
||||
->actions([
|
||||
Actions\EditAction::make(),
|
||||
Actions\Action::make('toggle')
|
||||
->label(__('admin.events.actions.toggle_active'))
|
||||
->icon('heroicon-o-power')
|
||||
->action(fn ($record) => $record->update(['is_active' => ! $record->is_active])),
|
||||
Actions\Action::make('join_tokens')
|
||||
->label(__('admin.events.actions.join_link_qr'))
|
||||
->icon('heroicon-o-qr-code')
|
||||
->modalHeading(__('admin.events.modal.join_link_heading'))
|
||||
->modalSubmitActionLabel(__('admin.common.close'))
|
||||
->modalWidth('xl')
|
||||
->modalContent(function ($record) {
|
||||
$tokens = $record->joinTokens()
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
if ($tokens->isEmpty()) {
|
||||
return view('filament.events.join-link', [
|
||||
'event' => $record,
|
||||
'tokens' => collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
$tokenIds = $tokens->pluck('id');
|
||||
$now = now();
|
||||
|
||||
$totals = EventJoinTokenEvent::query()
|
||||
->selectRaw('event_join_token_id, event_type, COUNT(*) as total')
|
||||
->whereIn('event_join_token_id', $tokenIds)
|
||||
->groupBy('event_join_token_id', 'event_type')
|
||||
->get()
|
||||
->groupBy('event_join_token_id');
|
||||
|
||||
$recent24h = EventJoinTokenEvent::query()
|
||||
->selectRaw('event_join_token_id, COUNT(*) as total')
|
||||
->whereIn('event_join_token_id', $tokenIds)
|
||||
->where('occurred_at', '>=', $now->copy()->subHours(24))
|
||||
->groupBy('event_join_token_id')
|
||||
->pluck('total', 'event_join_token_id');
|
||||
|
||||
$lastSeen = EventJoinTokenEvent::query()
|
||||
->whereIn('event_join_token_id', $tokenIds)
|
||||
->selectRaw('event_join_token_id, MAX(occurred_at) as last_at')
|
||||
->groupBy('event_join_token_id')
|
||||
->pluck('last_at', 'event_join_token_id');
|
||||
|
||||
$tokens = $tokens->map(function ($token) use ($record, $totals, $recent24h, $lastSeen) {
|
||||
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) {
|
||||
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
||||
'event' => $record->slug,
|
||||
'joinToken' => $token->getKey(),
|
||||
'layout' => $layoutId,
|
||||
'format' => $format,
|
||||
]);
|
||||
});
|
||||
|
||||
$analyticsGroup = $totals->get($token->id, collect());
|
||||
$analytics = $analyticsGroup->mapWithKeys(function ($row) {
|
||||
return [$row->event_type => (int) $row->total];
|
||||
});
|
||||
|
||||
$successCount = (int) ($analytics['access_granted'] ?? 0) + (int) ($analytics['gallery_access_granted'] ?? 0);
|
||||
$failureCount = (int) ($analytics['invalid_token'] ?? 0)
|
||||
+ (int) ($analytics['token_expired'] ?? 0)
|
||||
+ (int) ($analytics['token_revoked'] ?? 0)
|
||||
+ (int) ($analytics['token_rate_limited'] ?? 0)
|
||||
+ (int) ($analytics['event_not_public'] ?? 0)
|
||||
+ (int) ($analytics['gallery_expired'] ?? 0);
|
||||
|
||||
$lastSeenAt = $lastSeen->get($token->id);
|
||||
|
||||
return [
|
||||
'id' => $token->id,
|
||||
'label' => $token->label,
|
||||
'token' => $token->token,
|
||||
'url' => url('/e/'.$token->token),
|
||||
'usage_limit' => $token->usage_limit,
|
||||
'usage_count' => $token->usage_count,
|
||||
'expires_at' => optional($token->expires_at)->toIso8601String(),
|
||||
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
|
||||
'is_active' => $token->isActive(),
|
||||
'created_at' => optional($token->created_at)->toIso8601String(),
|
||||
'layouts' => $layouts,
|
||||
'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [
|
||||
'event' => $record->slug,
|
||||
'joinToken' => $token->getKey(),
|
||||
]),
|
||||
'analytics' => [
|
||||
'success_total' => $successCount,
|
||||
'failure_total' => $failureCount,
|
||||
'rate_limited_total' => (int) ($analytics['token_rate_limited'] ?? 0),
|
||||
'recent_24h' => (int) $recent24h->get($token->id, 0),
|
||||
'last_seen_at' => $lastSeenAt ? Carbon::parse($lastSeenAt)->toIso8601String() : null,
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
return view('filament.events.join-link', [
|
||||
'event' => $record,
|
||||
'tokens' => $tokens,
|
||||
]);
|
||||
}),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\DeleteBulkAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListEvents::route('/'),
|
||||
'create' => Pages\CreateEvent::route('/create'),
|
||||
'view' => Pages\ViewEvent::route('/{record}'),
|
||||
'edit' => Pages\EditEvent::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
EventPackagesRelationManager::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\EventResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\EventResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateEvent extends CreateRecord
|
||||
{
|
||||
protected static string $resource = EventResource::class;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\EventResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\EventResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditEvent extends EditRecord
|
||||
{
|
||||
protected static string $resource = EventResource::class;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\EventResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\EventResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListEvents extends ListRecords
|
||||
{
|
||||
protected static string $resource = EventResource::class;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\EventResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\EventResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewEvent extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EventResource::class;
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\EventResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use App\Models\EventPackage;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class EventPackagesRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'eventPackages';
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Select::make('package_id')
|
||||
->label('Package')
|
||||
->relationship('package', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
TextInput::make('purchased_price')
|
||||
->label('Kaufpreis')
|
||||
->prefix('€')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->required(),
|
||||
TextInput::make('used_photos')
|
||||
->label('Verwendete Fotos')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->readOnly(),
|
||||
TextInput::make('used_guests')
|
||||
->label('Verwendete Gäste')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->readOnly(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('package.name')
|
||||
->columns([
|
||||
TextColumn::make('package.name')
|
||||
->label('Package')
|
||||
->badge()
|
||||
->color('success'),
|
||||
TextColumn::make('used_photos')
|
||||
->label('Verwendete Fotos')
|
||||
->badge(),
|
||||
TextColumn::make('remaining_photos')
|
||||
->label('Verbleibende Fotos')
|
||||
->badge()
|
||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||
->getStateUsing(fn (EventPackage $record) => $record->remaining_photos),
|
||||
TextColumn::make('used_guests')
|
||||
->label('Verwendete Gäste')
|
||||
->badge(),
|
||||
TextColumn::make('remaining_guests')
|
||||
->label('Verbleibende Gäste')
|
||||
->badge()
|
||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||
->getStateUsing(fn (EventPackage $record) => $record->remaining_guests),
|
||||
TextColumn::make('expires_at')
|
||||
->label('Ablauf')
|
||||
->dateTime()
|
||||
->badge()
|
||||
->color(fn ($state) => $state && $state->isPast() ? 'danger' : 'success'),
|
||||
TextColumn::make('price')
|
||||
->label('Preis')
|
||||
->money('EUR')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
CreateAction::make(),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function getRelationExistenceQuery(
|
||||
Builder $query,
|
||||
string $relationshipName,
|
||||
?string $ownerKeyName,
|
||||
mixed $ownerKeyValue,
|
||||
): Builder {
|
||||
return $query;
|
||||
}
|
||||
|
||||
public static function getTitle(Model $ownerRecord, string $pageClass): string
|
||||
{
|
||||
return __('admin.events.relation_managers.event_packages.title');
|
||||
}
|
||||
|
||||
public function getTableQuery(): Builder | Relation
|
||||
{
|
||||
return parent::getTableQuery()
|
||||
->with('package');
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\PhotoResource\Pages;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Event;
|
||||
use App\Support\TenantOnboardingState;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use UnitEnum;
|
||||
use BackedEnum;
|
||||
|
||||
class PhotoResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Photo::class;
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-photo';
|
||||
protected static UnitEnum|string|null $navigationGroup = null;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.content');
|
||||
}
|
||||
protected static ?int $navigationSort = 30;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return TenantOnboardingState::completed();
|
||||
}
|
||||
|
||||
public static function form(Schema $form): Schema
|
||||
{
|
||||
$tenantId = Auth::user()?->tenant_id;
|
||||
|
||||
return $form->schema([
|
||||
Select::make('event_id')
|
||||
->label(__('admin.photos.fields.event'))
|
||||
->options(
|
||||
Event::query()
|
||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||
->pluck('name', 'id')
|
||||
)
|
||||
->searchable()
|
||||
->required(),
|
||||
FileUpload::make('file_path')
|
||||
->label(__('admin.photos.fields.photo'))
|
||||
->image() // enable FilePond image preview
|
||||
->disk('public')
|
||||
->directory('photos')
|
||||
->visibility('public')
|
||||
->required(),
|
||||
Toggle::make('is_featured')
|
||||
->label(__('admin.photos.fields.is_featured'))
|
||||
->default(false),
|
||||
KeyValue::make('metadata')
|
||||
->label(__('admin.photos.fields.metadata'))
|
||||
->keyLabel(__('admin.common.key'))
|
||||
->valueLabel(__('admin.common.value')),
|
||||
])->columns(2);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\ImageColumn::make('file_path')->label(__('admin.photos.table.photo'))->disk('public')->visibility('public'),
|
||||
Tables\Columns\TextColumn::make('id')->sortable(),
|
||||
Tables\Columns\TextColumn::make('event.name')->label(__('admin.photos.table.event'))->searchable(),
|
||||
Tables\Columns\TextColumn::make('likes_count')->label(__('admin.photos.table.likes')),
|
||||
Tables\Columns\IconColumn::make('is_featured')->boolean(),
|
||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||
])
|
||||
->modifyQueryUsing(function (Builder $query) {
|
||||
if ($tenantId = Auth::user()?->tenant_id) {
|
||||
$query->whereHas('event', fn (Builder $eventQuery) => $eventQuery->where('tenant_id', $tenantId));
|
||||
}
|
||||
})
|
||||
->filters([])
|
||||
->actions([
|
||||
Actions\EditAction::make(),
|
||||
Actions\Action::make('feature')
|
||||
->label(__('admin.photos.actions.feature'))
|
||||
->visible(fn($record) => !$record->is_featured)
|
||||
->action(fn($record) => $record->update(['is_featured' => true]))
|
||||
->icon('heroicon-o-star'),
|
||||
Actions\Action::make('unfeature')
|
||||
->label(__('admin.photos.actions.unfeature'))
|
||||
->visible(fn($record) => $record->is_featured)
|
||||
->action(fn($record) => $record->update(['is_featured' => false]))
|
||||
->icon('heroicon-o-star'),
|
||||
Actions\DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\BulkAction::make('feature')
|
||||
->label(__('admin.photos.actions.feature_selected'))
|
||||
->icon('heroicon-o-star')
|
||||
->action(fn($records) => $records->each->update(['is_featured' => true])),
|
||||
Actions\BulkAction::make('unfeature')
|
||||
->label(__('admin.photos.actions.unfeature_selected'))
|
||||
->icon('heroicon-o-star')
|
||||
->action(fn($records) => $records->each->update(['is_featured' => false])),
|
||||
Actions\DeleteBulkAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListPhotos::route('/'),
|
||||
'view' => Pages\ViewPhoto::route('/{record}'),
|
||||
'edit' => Pages\EditPhoto::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\PhotoResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditPhoto extends EditRecord
|
||||
{
|
||||
protected static string $resource = PhotoResource::class;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\PhotoResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPhotos extends ListRecords
|
||||
{
|
||||
protected static string $resource = PhotoResource::class;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\PhotoResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewPhoto extends ViewRecord
|
||||
{
|
||||
protected static string $resource = PhotoResource::class;
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventType;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Services\Tenant\TaskCollectionImportService;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Actions;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use BackedEnum;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Support\TenantOnboardingState;
|
||||
|
||||
class TaskCollectionResource extends Resource
|
||||
{
|
||||
protected static ?string $model = TaskCollection::class;
|
||||
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-folder';
|
||||
|
||||
protected static ?int $navigationSort = 50;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return TenantOnboardingState::completed();
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return __('admin.nav.library');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
$tenantId = auth()->user()?->tenant_id;
|
||||
|
||||
return $schema->components([
|
||||
Section::make(__('Task Collection Details'))
|
||||
->schema([
|
||||
TextInput::make('name_translations.de')
|
||||
->label(__('Name (DE)'))
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
|
||||
TextInput::make('name_translations.en')
|
||||
->label(__('Name (EN)'))
|
||||
->maxLength(255)
|
||||
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
|
||||
Select::make('event_type_id')
|
||||
->label(__('Event Type'))
|
||||
->options(fn () => EventType::orderBy('name->' . app()->getLocale())
|
||||
->get()
|
||||
->mapWithKeys(function (EventType $type) {
|
||||
$name = $type->name[app()->getLocale()] ?? $type->name['de'] ?? reset($type->name);
|
||||
|
||||
return [$type->id => $name];
|
||||
})->toArray())
|
||||
->searchable()
|
||||
->required()
|
||||
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
|
||||
Textarea::make('description_translations.de')
|
||||
->label(__('Description (DE)'))
|
||||
->rows(3)
|
||||
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
|
||||
Textarea::make('description_translations.en')
|
||||
->label(__('Description (EN)'))
|
||||
->rows(3)
|
||||
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
|
||||
])->columns(2),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('Name'))
|
||||
->searchable(['name_translations->de', 'name_translations->en'])
|
||||
->sortable(),
|
||||
BadgeColumn::make('eventType.name')
|
||||
->label(__('Event Type'))
|
||||
->color('info'),
|
||||
IconColumn::make('tenant_id')
|
||||
->label(__('Scope'))
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-user-group')
|
||||
->falseIcon('heroicon-o-globe-alt')
|
||||
->state(fn (TaskCollection $record) => $record->tenant_id !== null)
|
||||
->tooltip(fn (TaskCollection $record) => $record->tenant_id ? __('Tenant-only') : __('Global template')),
|
||||
TextColumn::make('tasks_count')
|
||||
->label(__('Tasks'))
|
||||
->counts('tasks')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('event_type_id')
|
||||
->label(__('Event Type'))
|
||||
->relationship('eventType', 'name->' . app()->getLocale()),
|
||||
SelectFilter::make('scope')
|
||||
->options([
|
||||
'global' => __('Global template'),
|
||||
'tenant' => __('Tenant-owned'),
|
||||
])
|
||||
->query(function ($query, $value) {
|
||||
$tenantId = auth()->user()?->tenant_id;
|
||||
|
||||
if ($value === 'global') {
|
||||
$query->whereNull('tenant_id');
|
||||
}
|
||||
|
||||
if ($value === 'tenant') {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
}
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('import')
|
||||
->label(__('Import to Event'))
|
||||
->icon('heroicon-o-cloud-arrow-down')
|
||||
->form([
|
||||
Select::make('event_slug')
|
||||
->label(__('Select Event'))
|
||||
->options(function () {
|
||||
$tenantId = auth()->user()?->tenant_id;
|
||||
|
||||
return Event::where('tenant_id', $tenantId)
|
||||
->orderBy('date', 'desc')
|
||||
->get()
|
||||
->mapWithKeys(function (Event $event) {
|
||||
$name = $event->name[app()->getLocale()] ?? $event->name['de'] ?? reset($event->name);
|
||||
|
||||
return [
|
||||
$event->slug => sprintf('%s (%s)', $name, $event->date?->format('d.m.Y')),
|
||||
];
|
||||
})->toArray();
|
||||
})
|
||||
->required()
|
||||
->searchable(),
|
||||
])
|
||||
->action(function (TaskCollection $record, array $data) {
|
||||
$event = Event::where('slug', $data['event_slug'])
|
||||
->where('tenant_id', auth()->user()?->tenant_id)
|
||||
->firstOrFail();
|
||||
|
||||
/** @var TaskCollectionImportService $service */
|
||||
$service = app(TaskCollectionImportService::class);
|
||||
$service->import($record, $event);
|
||||
|
||||
Notification::make()
|
||||
->title(__('Task collection imported'))
|
||||
->body(__('The collection :name has been imported.', ['name' => $record->name]))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\EditAction::make()
|
||||
->label(__('Edit'))
|
||||
->visible(fn (TaskCollection $record) => $record->tenant_id === auth()->user()?->tenant_id),
|
||||
])
|
||||
->headerActions([
|
||||
Actions\CreateAction::make()
|
||||
->label(__('Create Task Collection'))
|
||||
->mutateFormDataUsing(function (array $data) {
|
||||
$tenantId = auth()->user()?->tenant_id;
|
||||
|
||||
$data['tenant_id'] = $tenantId;
|
||||
$data['slug'] = static::generateSlug($data['name_translations']['en'] ?? $data['name_translations']['de'] ?? 'collection', $tenantId);
|
||||
|
||||
return $data;
|
||||
}),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\DeleteBulkAction::make()
|
||||
->visible(fn () => false),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListTaskCollections::route('/'),
|
||||
'create' => Pages\CreateTaskCollection::route('/create'),
|
||||
'edit' => Pages\EditTaskCollection::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = auth()->user()?->tenant_id;
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->forTenant($tenantId)
|
||||
->with('eventType')
|
||||
->withCount('tasks');
|
||||
}
|
||||
|
||||
public static function getGloballySearchableAttributes(): array
|
||||
{
|
||||
return ['name_translations->de', 'name_translations->en'];
|
||||
}
|
||||
|
||||
public static function generateSlug(string $base, int $tenantId): string
|
||||
{
|
||||
$slugBase = Str::slug($base) ?: 'collection';
|
||||
|
||||
do {
|
||||
$candidate = $slugBase . '-' . $tenantId . '-' . Str::random(4);
|
||||
} while (TaskCollection::where('slug', $candidate)->exists());
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
public static function scopeEloquentQueryToTenant(Builder $query, ?Model $tenant): Builder
|
||||
{
|
||||
$tenant ??= Filament::getTenant();
|
||||
|
||||
if (! $tenant) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where(function (Builder $innerQuery) use ($tenant) {
|
||||
$innerQuery->whereNull('tenant_id')
|
||||
->orWhere('tenant_id', $tenant->getKey());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskCollectionResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CreateTaskCollection extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TaskCollectionResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$tenantId = Auth::user()?->tenant_id;
|
||||
|
||||
$data['tenant_id'] = $tenantId;
|
||||
$data['slug'] = TaskCollectionResource::generateSlug(
|
||||
$data['name_translations']['en'] ?? $data['name_translations']['de'] ?? 'collection',
|
||||
$tenantId
|
||||
);
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskCollectionResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class EditTaskCollection extends EditRecord
|
||||
{
|
||||
protected static string $resource = TaskCollectionResource::class;
|
||||
|
||||
protected function authorizeAccess(): void
|
||||
{
|
||||
parent::authorizeAccess();
|
||||
|
||||
$record = $this->getRecord();
|
||||
|
||||
if ($record->tenant_id !== Auth::user()?->tenant_id) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskCollectionResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTaskCollections extends ListRecords
|
||||
{
|
||||
protected static string $resource = TaskCollectionResource::class;
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskResource\Pages;
|
||||
use App\Models\Event;
|
||||
use App\Models\Task;
|
||||
use App\Support\TenantOnboardingState;
|
||||
use BackedEnum;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\MarkdownEditor;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Schemas\Components\Tabs as SchemaTabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Actions;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use UnitEnum;
|
||||
|
||||
class TaskResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Task::class;
|
||||
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-check';
|
||||
|
||||
protected static ?int $navigationSort = 40;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return TenantOnboardingState::completed();
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.library');
|
||||
}
|
||||
|
||||
public static function form(Schema $form): Schema
|
||||
{
|
||||
$tenantId = Auth::user()?->tenant_id;
|
||||
|
||||
return $form->schema([
|
||||
Select::make('emotion_id')
|
||||
->relationship('emotion', 'name')
|
||||
->required()
|
||||
->searchable()
|
||||
->preload(),
|
||||
Select::make('event_type_id')
|
||||
->relationship('eventType', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->label(__('admin.tasks.fields.event_type_optional')),
|
||||
SchemaTabs::make('content_tabs')
|
||||
->label(__('admin.tasks.fields.content_localization'))
|
||||
->tabs([
|
||||
SchemaTab::make(__('admin.common.german'))
|
||||
->icon('heroicon-o-language')
|
||||
->schema([
|
||||
TextInput::make('title.de')
|
||||
->label(__('admin.tasks.fields.title_de'))
|
||||
->required(),
|
||||
MarkdownEditor::make('description.de')
|
||||
->label(__('admin.tasks.fields.description_de'))
|
||||
->columnSpanFull(),
|
||||
MarkdownEditor::make('example_text.de')
|
||||
->label(__('admin.tasks.fields.example_de'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
SchemaTab::make(__('admin.common.english'))
|
||||
->icon('heroicon-o-language')
|
||||
->schema([
|
||||
TextInput::make('title.en')
|
||||
->label(__('admin.tasks.fields.title_en'))
|
||||
->required(),
|
||||
MarkdownEditor::make('description.en')
|
||||
->label(__('admin.tasks.fields.description_en'))
|
||||
->columnSpanFull(),
|
||||
MarkdownEditor::make('example_text.en')
|
||||
->label(__('admin.tasks.fields.example_en'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Select::make('difficulty')
|
||||
->label(__('admin.tasks.fields.difficulty.label'))
|
||||
->options([
|
||||
'easy' => __('admin.tasks.fields.difficulty.easy'),
|
||||
'medium' => __('admin.tasks.fields.difficulty.medium'),
|
||||
'hard' => __('admin.tasks.fields.difficulty.hard'),
|
||||
])
|
||||
->default('easy'),
|
||||
TextInput::make('sort_order')
|
||||
->numeric()
|
||||
->default(0),
|
||||
Toggle::make('is_active')
|
||||
->default(true),
|
||||
Select::make('assigned_events')
|
||||
->label(__('admin.tasks.fields.events'))
|
||||
->multiple()
|
||||
->relationship(
|
||||
'assignedEvents',
|
||||
'name',
|
||||
fn (Builder $query) => $tenantId
|
||||
? $query->where('tenant_id', $tenantId)
|
||||
: $query
|
||||
)
|
||||
->searchable()
|
||||
->preload()
|
||||
->getOptionLabelFromRecordUsing(fn (Event $record) => $record->name)
|
||||
->helperText(__('admin.tasks.fields.events_helper')),
|
||||
])->columns(2);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
$tenantId = Auth::user()?->tenant_id;
|
||||
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')
|
||||
->label('#')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('title')
|
||||
->label(__('admin.tasks.table.title'))
|
||||
->getStateUsing(function ($record) {
|
||||
$value = $record->title;
|
||||
if (is_array($value)) {
|
||||
$loc = app()->getLocale();
|
||||
return $value[$loc] ?? ($value['de'] ?? ($value['en'] ?? ''));
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
})
|
||||
->limit(60)
|
||||
->searchable(['title->de', 'title->en']),
|
||||
Tables\Columns\TextColumn::make('emotion.name')
|
||||
->label(__('admin.tasks.fields.emotion'))
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('eventType.name')
|
||||
->label(__('admin.tasks.fields.event_type'))
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('assignedEvents.name')
|
||||
->label(__('admin.tasks.table.events'))
|
||||
->badge()
|
||||
->separator(', ')
|
||||
->limitList(2),
|
||||
Tables\Columns\TextColumn::make('difficulty')
|
||||
->label(__('admin.tasks.fields.difficulty.label'))
|
||||
->badge(),
|
||||
Tables\Columns\IconColumn::make('is_active')
|
||||
->label(__('admin.tasks.table.is_active'))
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('sort_order')
|
||||
->label(__('admin.tasks.table.sort_order'))
|
||||
->sortable(),
|
||||
])
|
||||
->filters([])
|
||||
->actions([
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\DeleteBulkAction::make(),
|
||||
])
|
||||
->modifyQueryUsing(function (Builder $query) use ($tenantId) {
|
||||
if (! $tenantId) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$query->forTenant($tenantId);
|
||||
|
||||
return $query;
|
||||
});
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListTasks::route('/'),
|
||||
'create' => Pages\CreateTask::route('/create'),
|
||||
'edit' => Pages\EditTask::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function scopeEloquentQueryToTenant(Builder $query, ?Model $tenant): Builder
|
||||
{
|
||||
$tenant ??= Filament::getTenant();
|
||||
|
||||
if (! $tenant) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where(function (Builder $innerQuery) use ($tenant) {
|
||||
$innerQuery->whereNull('tenant_id')
|
||||
->orWhere('tenant_id', $tenant->getKey());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TaskResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTask extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TaskResource::class;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TaskResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditTask extends EditRecord
|
||||
{
|
||||
protected static string $resource = TaskResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TaskResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTasks extends ListRecords
|
||||
{
|
||||
protected static string $resource = TaskResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Widgets;
|
||||
|
||||
use Filament\Tables;
|
||||
use Filament\Widgets\TableWidget as BaseWidget;
|
||||
use Illuminate\Support\Carbon;
|
||||
use App\Models\Event;
|
||||
|
||||
class EventsActiveToday extends BaseWidget
|
||||
{
|
||||
protected static ?string $heading = null;
|
||||
|
||||
public function getHeading()
|
||||
{
|
||||
return __('admin.widgets.events_active_today.heading');
|
||||
}
|
||||
protected ?string $pollingInterval = '60s';
|
||||
|
||||
public function table(Tables\Table $table): Tables\Table
|
||||
{
|
||||
$today = Carbon::today()->toDateString();
|
||||
return $table
|
||||
->query(
|
||||
Event::query()
|
||||
->where('is_active', true)
|
||||
->whereDate('date', '<=', $today)
|
||||
->withCount([
|
||||
'photos as uploads_today' => function ($q) use ($today) {
|
||||
$q->whereDate('created_at', $today);
|
||||
},
|
||||
])
|
||||
->orderByDesc('date')
|
||||
->limit(10)
|
||||
)
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')->label(__('admin.common.hash'))->width('60px'),
|
||||
Tables\Columns\TextColumn::make('slug')->label(__('admin.common.slug'))->searchable(),
|
||||
Tables\Columns\TextColumn::make('date')->date(),
|
||||
Tables\Columns\TextColumn::make('uploads_today')->label(__('admin.common.uploads_today'))->numeric(),
|
||||
])
|
||||
->paginated(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Widgets;
|
||||
|
||||
use Filament\Tables;
|
||||
use Filament\Widgets\TableWidget as BaseWidget;
|
||||
use App\Models\Photo;
|
||||
use Filament\Actions;
|
||||
|
||||
class RecentPhotosTable extends BaseWidget
|
||||
{
|
||||
protected static ?string $heading = null;
|
||||
|
||||
public function getHeading()
|
||||
{
|
||||
return __('admin.widgets.recent_uploads.heading');
|
||||
}
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function table(Tables\Table $table): Tables\Table
|
||||
{
|
||||
return $table
|
||||
->query(
|
||||
Photo::query()
|
||||
->orderByDesc('created_at')
|
||||
->limit(10)
|
||||
)
|
||||
->columns([
|
||||
Tables\Columns\ImageColumn::make('thumbnail_path')->label(__('admin.common.thumb'))->circular(),
|
||||
Tables\Columns\TextColumn::make('id')->label(__('admin.common.hash')),
|
||||
Tables\Columns\TextColumn::make('event_id')->label(__('admin.common.event')),
|
||||
Tables\Columns\TextColumn::make('likes_count')->label(__('admin.common.likes')),
|
||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('feature')
|
||||
->label(__('admin.photos.actions.feature'))
|
||||
->visible(fn(Photo $record) => ! (bool)($record->is_featured ?? 0))
|
||||
->action(fn(Photo $record) => $record->update(['is_featured' => 1])),
|
||||
Actions\Action::make('unfeature')
|
||||
->label(__('admin.photos.actions.unfeature'))
|
||||
->visible(fn(Photo $record) => (bool)($record->is_featured ?? 0))
|
||||
->action(fn(Photo $record) => $record->update(['is_featured' => 0])),
|
||||
])
|
||||
->paginated(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Widgets;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class UploadsPerDayChart extends ChartWidget
|
||||
{
|
||||
protected ?string $heading = null;
|
||||
protected ?string $maxHeight = '220px';
|
||||
protected ?string $pollingInterval = '60s';
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
// Build last 14 days labels
|
||||
$labels = [];
|
||||
$start = Carbon::now()->startOfDay()->subDays(13);
|
||||
for ($i = 0; $i < 14; $i++) {
|
||||
$labels[] = $start->copy()->addDays($i)->format('Y-m-d');
|
||||
}
|
||||
|
||||
// SQLite-friendly group by date
|
||||
$rows = DB::table('photos')
|
||||
->selectRaw("strftime('%Y-%m-%d', created_at) as d, count(*) as c")
|
||||
->where('created_at', '>=', $start)
|
||||
->groupBy('d')
|
||||
->orderBy('d')
|
||||
->get();
|
||||
$map = collect($rows)->keyBy('d');
|
||||
$data = array_map(fn ($d) => (int) ($map[$d]->c ?? 0), $labels);
|
||||
|
||||
return [
|
||||
'labels' => $labels,
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => __('admin.common.uploads'),
|
||||
'data' => $data,
|
||||
'borderColor' => '#f59e0b',
|
||||
'backgroundColor' => 'rgba(245, 158, 11, 0.2)',
|
||||
'tension' => 0.3,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getType(): string
|
||||
{
|
||||
return 'line';
|
||||
}
|
||||
|
||||
public function getHeading(): string|\Illuminate\Contracts\Support\Htmlable|null
|
||||
{
|
||||
return __('admin.widgets.uploads_per_day.heading');
|
||||
}
|
||||
}
|
||||
22
app/Filament/Widgets/IntegrationsHealthWidget.php
Normal file
22
app/Filament/Widgets/IntegrationsHealthWidget.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Services\Integrations\IntegrationHealthService;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class IntegrationsHealthWidget extends Widget
|
||||
{
|
||||
protected string $view = 'filament.widgets.integrations-health';
|
||||
|
||||
protected ?string $pollingInterval = '60s';
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$health = app(IntegrationHealthService::class);
|
||||
|
||||
return [
|
||||
'providers' => $health->providers(),
|
||||
];
|
||||
}
|
||||
}
|
||||
147
app/Filament/Widgets/JoinTokenOverviewWidget.php
Normal file
147
app/Filament/Widgets/JoinTokenOverviewWidget.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinTokenEvent;
|
||||
use Filament\Widgets\Concerns\InteractsWithPageFilters;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class JoinTokenOverviewWidget extends StatsOverviewWidget
|
||||
{
|
||||
use InteractsWithPageFilters;
|
||||
|
||||
protected static ?int $sort = 1;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
private const SUCCESS_EVENTS = [
|
||||
'access_granted',
|
||||
'gallery_access_granted',
|
||||
];
|
||||
|
||||
private const RATE_LIMIT_EVENTS = [
|
||||
'token_rate_limited',
|
||||
'access_rate_limited',
|
||||
'download_rate_limited',
|
||||
];
|
||||
|
||||
private const FAILURE_EVENTS = [
|
||||
'invalid_token',
|
||||
'token_expired',
|
||||
'token_revoked',
|
||||
'event_not_public',
|
||||
'gallery_expired',
|
||||
'token_rate_limited',
|
||||
'access_rate_limited',
|
||||
'download_rate_limited',
|
||||
];
|
||||
|
||||
private const UPLOAD_EVENTS = [
|
||||
'upload_completed',
|
||||
];
|
||||
|
||||
protected function getStats(): array
|
||||
{
|
||||
$filters = $this->resolveFilters();
|
||||
$totals = $this->totalsByEventType($filters);
|
||||
|
||||
$success = $this->sumTotals($totals, self::SUCCESS_EVENTS);
|
||||
$failures = $this->sumTotals($totals, self::FAILURE_EVENTS);
|
||||
$rateLimited = $this->sumTotals($totals, self::RATE_LIMIT_EVENTS);
|
||||
$uploads = $this->sumTotals($totals, self::UPLOAD_EVENTS);
|
||||
|
||||
$ratio = $success > 0 ? round(($failures / $success) * 100, 1) : null;
|
||||
$ratioLabel = $ratio !== null ? "{$ratio}%" : __('admin.join_token_analytics.stats.no_data');
|
||||
|
||||
return [
|
||||
Stat::make(__('admin.join_token_analytics.stats.success'), number_format($success))
|
||||
->color('success'),
|
||||
Stat::make(__('admin.join_token_analytics.stats.failures'), number_format($failures))
|
||||
->color($failures > 0 ? 'danger' : 'success'),
|
||||
Stat::make(__('admin.join_token_analytics.stats.rate_limited'), number_format($rateLimited))
|
||||
->color($rateLimited > 0 ? 'warning' : 'success'),
|
||||
Stat::make(__('admin.join_token_analytics.stats.uploads'), number_format($uploads))
|
||||
->color('primary'),
|
||||
Stat::make(__('admin.join_token_analytics.stats.failure_ratio'), $ratioLabel)
|
||||
->color($ratio !== null && $ratio >= 50 ? 'danger' : 'warning'),
|
||||
];
|
||||
}
|
||||
|
||||
private function totalsByEventType(array $filters): array
|
||||
{
|
||||
return $this->baseQuery($filters)
|
||||
->selectRaw('event_type, COUNT(*) as total')
|
||||
->groupBy('event_type')
|
||||
->get()
|
||||
->mapWithKeys(fn (EventJoinTokenEvent $event) => [$event->event_type => (int) $event->total])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function sumTotals(array $totals, array $types): int
|
||||
{
|
||||
$sum = 0;
|
||||
|
||||
foreach ($types as $type) {
|
||||
$sum += (int) ($totals[$type] ?? 0);
|
||||
}
|
||||
|
||||
return $sum;
|
||||
}
|
||||
|
||||
private function baseQuery(array $filters): Builder
|
||||
{
|
||||
$query = EventJoinTokenEvent::query()
|
||||
->whereBetween('occurred_at', [$filters['start'], $filters['end']]);
|
||||
|
||||
if ($filters['event_id']) {
|
||||
$query->where('event_id', $filters['event_id']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function resolveFilters(): array
|
||||
{
|
||||
$eventId = $this->pageFilters['event_id'] ?? null;
|
||||
$eventId = is_numeric($eventId) ? (int) $eventId : null;
|
||||
$range = is_string($this->pageFilters['range'] ?? null) ? $this->pageFilters['range'] : '24h';
|
||||
|
||||
[$start, $end] = $this->resolveWindow($range, $eventId);
|
||||
|
||||
return [
|
||||
'event_id' => $eventId,
|
||||
'range' => $range,
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveWindow(string $range, ?int $eventId): array
|
||||
{
|
||||
$now = now();
|
||||
$start = match ($range) {
|
||||
'2h' => $now->copy()->subHours(2),
|
||||
'6h' => $now->copy()->subHours(6),
|
||||
'12h' => $now->copy()->subHours(12),
|
||||
'7d' => $now->copy()->subDays(7),
|
||||
default => $now->copy()->subHours(24),
|
||||
};
|
||||
$end = $now;
|
||||
|
||||
if ($range === 'event_day' && $eventId) {
|
||||
$eventDate = Event::query()->whereKey($eventId)->value('date');
|
||||
|
||||
if ($eventDate) {
|
||||
$eventDay = Carbon::parse($eventDate);
|
||||
$start = $eventDay->copy()->startOfDay();
|
||||
$end = $eventDay->copy()->endOfDay();
|
||||
}
|
||||
}
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
}
|
||||
191
app/Filament/Widgets/JoinTokenTopTokensWidget.php
Normal file
191
app/Filament/Widgets/JoinTokenTopTokensWidget.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Filament\Resources\EventResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use Filament\Tables;
|
||||
use Filament\Widgets\Concerns\InteractsWithPageFilters;
|
||||
use Filament\Widgets\TableWidget as BaseWidget;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class JoinTokenTopTokensWidget extends BaseWidget
|
||||
{
|
||||
use InteractsWithPageFilters;
|
||||
|
||||
protected static ?int $sort = 3;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected static ?string $heading = null;
|
||||
|
||||
public function getHeading(): ?string
|
||||
{
|
||||
return __('admin.join_token_analytics.table.heading');
|
||||
}
|
||||
|
||||
public function table(Tables\Table $table): Tables\Table
|
||||
{
|
||||
$filters = $this->resolveFilters();
|
||||
|
||||
return $table
|
||||
->query(fn (): Builder => $this->buildQuery($filters))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('token_preview')
|
||||
->label(__('admin.join_token_analytics.table.token'))
|
||||
->copyable()
|
||||
->copyMessage(__('admin.events.messages.join_link_copied')),
|
||||
Tables\Columns\TextColumn::make('event_label')
|
||||
->label(__('admin.join_token_analytics.table.event'))
|
||||
->getStateUsing(fn (EventJoinToken $record) => $this->formatEventLabel($record->event))
|
||||
->url(fn (EventJoinToken $record) => $record->event
|
||||
? EventResource::getUrl('view', ['record' => $record->event])
|
||||
: null)
|
||||
->openUrlInNewTab(),
|
||||
Tables\Columns\TextColumn::make('tenant_label')
|
||||
->label(__('admin.join_token_analytics.table.tenant'))
|
||||
->getStateUsing(fn (EventJoinToken $record) => $record->event?->tenant?->name ?? __('admin.common.unnamed')),
|
||||
Tables\Columns\TextColumn::make('success_total')
|
||||
->label(__('admin.join_token_analytics.table.success'))
|
||||
->numeric(),
|
||||
Tables\Columns\TextColumn::make('failure_total')
|
||||
->label(__('admin.join_token_analytics.table.failures'))
|
||||
->numeric(),
|
||||
Tables\Columns\TextColumn::make('rate_limited_total')
|
||||
->label(__('admin.join_token_analytics.table.rate_limited'))
|
||||
->numeric(),
|
||||
Tables\Columns\TextColumn::make('upload_total')
|
||||
->label(__('admin.join_token_analytics.table.uploads'))
|
||||
->numeric(),
|
||||
Tables\Columns\TextColumn::make('last_seen_at')
|
||||
->label(__('admin.join_token_analytics.table.last_seen'))
|
||||
->since()
|
||||
->placeholder('—'),
|
||||
])
|
||||
->paginated(false);
|
||||
}
|
||||
|
||||
private function buildQuery(array $filters): Builder
|
||||
{
|
||||
$query = EventJoinToken::query()
|
||||
->with(['event.tenant'])
|
||||
->when($filters['event_id'], fn (Builder $builder, int $eventId) => $builder->where('event_id', $eventId))
|
||||
->withCount([
|
||||
'analytics as success_total' => function (Builder $builder) use ($filters) {
|
||||
$this->applyAnalyticsFilters($builder, $filters)
|
||||
->whereIn('event_type', self::SUCCESS_EVENTS);
|
||||
},
|
||||
'analytics as failure_total' => function (Builder $builder) use ($filters) {
|
||||
$this->applyAnalyticsFilters($builder, $filters)
|
||||
->whereIn('event_type', self::FAILURE_EVENTS);
|
||||
},
|
||||
'analytics as rate_limited_total' => function (Builder $builder) use ($filters) {
|
||||
$this->applyAnalyticsFilters($builder, $filters)
|
||||
->whereIn('event_type', self::RATE_LIMIT_EVENTS);
|
||||
},
|
||||
'analytics as upload_total' => function (Builder $builder) use ($filters) {
|
||||
$this->applyAnalyticsFilters($builder, $filters)
|
||||
->whereIn('event_type', self::UPLOAD_EVENTS);
|
||||
},
|
||||
])
|
||||
->withMax([
|
||||
'analytics as last_seen_at' => function (Builder $builder) use ($filters) {
|
||||
$this->applyAnalyticsFilters($builder, $filters);
|
||||
},
|
||||
], 'occurred_at')
|
||||
->orderByDesc('failure_total')
|
||||
->orderByDesc('rate_limited_total')
|
||||
->orderByDesc('success_total')
|
||||
->limit(10);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function applyAnalyticsFilters(Builder $query, array $filters): Builder
|
||||
{
|
||||
return $query->whereBetween('occurred_at', [$filters['start'], $filters['end']]);
|
||||
}
|
||||
|
||||
private function resolveFilters(): array
|
||||
{
|
||||
$eventId = $this->pageFilters['event_id'] ?? null;
|
||||
$eventId = is_numeric($eventId) ? (int) $eventId : null;
|
||||
$range = is_string($this->pageFilters['range'] ?? null) ? $this->pageFilters['range'] : '24h';
|
||||
|
||||
[$start, $end] = $this->resolveWindow($range, $eventId);
|
||||
|
||||
return [
|
||||
'event_id' => $eventId,
|
||||
'range' => $range,
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveWindow(string $range, ?int $eventId): array
|
||||
{
|
||||
$now = now();
|
||||
$start = match ($range) {
|
||||
'2h' => $now->copy()->subHours(2),
|
||||
'6h' => $now->copy()->subHours(6),
|
||||
'12h' => $now->copy()->subHours(12),
|
||||
'7d' => $now->copy()->subDays(7),
|
||||
default => $now->copy()->subHours(24),
|
||||
};
|
||||
$end = $now;
|
||||
|
||||
if ($range === 'event_day' && $eventId) {
|
||||
$eventDate = Event::query()->whereKey($eventId)->value('date');
|
||||
|
||||
if ($eventDate) {
|
||||
$eventDay = Carbon::parse($eventDate);
|
||||
$start = $eventDay->copy()->startOfDay();
|
||||
$end = $eventDay->copy()->endOfDay();
|
||||
}
|
||||
}
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
|
||||
private function formatEventLabel(?Event $event): string
|
||||
{
|
||||
if (! $event) {
|
||||
return __('admin.common.unnamed');
|
||||
}
|
||||
|
||||
$locale = app()->getLocale();
|
||||
$name = $event->name[$locale] ?? $event->name['de'] ?? $event->name['en'] ?? $event->slug ?? __('admin.common.unnamed');
|
||||
$tenant = $event->tenant?->name ?? __('admin.common.unnamed');
|
||||
$date = $event->date?->format('Y-m-d');
|
||||
|
||||
return $date ? "{$name} ({$tenant}) {$date}" : "{$name} ({$tenant})";
|
||||
}
|
||||
|
||||
private const SUCCESS_EVENTS = [
|
||||
'access_granted',
|
||||
'gallery_access_granted',
|
||||
];
|
||||
|
||||
private const RATE_LIMIT_EVENTS = [
|
||||
'token_rate_limited',
|
||||
'access_rate_limited',
|
||||
'download_rate_limited',
|
||||
];
|
||||
|
||||
private const FAILURE_EVENTS = [
|
||||
'invalid_token',
|
||||
'token_expired',
|
||||
'token_revoked',
|
||||
'event_not_public',
|
||||
'gallery_expired',
|
||||
'token_rate_limited',
|
||||
'access_rate_limited',
|
||||
'download_rate_limited',
|
||||
];
|
||||
|
||||
private const UPLOAD_EVENTS = [
|
||||
'upload_completed',
|
||||
];
|
||||
}
|
||||
178
app/Filament/Widgets/JoinTokenTrendWidget.php
Normal file
178
app/Filament/Widgets/JoinTokenTrendWidget.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinTokenEvent;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
use Filament\Widgets\Concerns\InteractsWithPageFilters;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class JoinTokenTrendWidget extends ChartWidget
|
||||
{
|
||||
use InteractsWithPageFilters;
|
||||
|
||||
protected static ?int $sort = 2;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
$filters = $this->resolveFilters();
|
||||
$events = $this->baseQuery($filters)->get(['event_type', 'occurred_at']);
|
||||
$hourly = $filters['start']->diffInHours($filters['end']) <= 48;
|
||||
$bucketFormat = $hourly ? 'Y-m-d H:00' : 'Y-m-d';
|
||||
$labelFormat = $hourly ? 'M d H:00' : 'M d';
|
||||
|
||||
$periodStart = $hourly ? $filters['start']->copy()->startOfHour() : $filters['start']->copy()->startOfDay();
|
||||
$period = CarbonPeriod::create($periodStart, $hourly ? '1 hour' : '1 day', $filters['end']);
|
||||
|
||||
$grouped = $events->groupBy(fn (EventJoinTokenEvent $event) => $event->occurred_at?->format($bucketFormat));
|
||||
|
||||
$labels = [];
|
||||
$success = [];
|
||||
$failures = [];
|
||||
$rateLimited = [];
|
||||
$uploads = [];
|
||||
|
||||
foreach ($period as $point) {
|
||||
$key = $point->format($bucketFormat);
|
||||
$bucket = $grouped->get($key, collect());
|
||||
|
||||
$labels[] = $point->translatedFormat($labelFormat);
|
||||
$success[] = $bucket->whereIn('event_type', self::SUCCESS_EVENTS)->count();
|
||||
$failures[] = $bucket->whereIn('event_type', self::FAILURE_EVENTS)->count();
|
||||
$rateLimited[] = $bucket->whereIn('event_type', self::RATE_LIMIT_EVENTS)->count();
|
||||
$uploads[] = $bucket->whereIn('event_type', self::UPLOAD_EVENTS)->count();
|
||||
}
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => __('admin.join_token_analytics.trend.success'),
|
||||
'data' => $success,
|
||||
'borderColor' => '#16a34a',
|
||||
'backgroundColor' => 'rgba(22, 163, 74, 0.2)',
|
||||
'tension' => 0.35,
|
||||
'fill' => false,
|
||||
],
|
||||
[
|
||||
'label' => __('admin.join_token_analytics.trend.failures'),
|
||||
'data' => $failures,
|
||||
'borderColor' => '#dc2626',
|
||||
'backgroundColor' => 'rgba(220, 38, 38, 0.2)',
|
||||
'tension' => 0.35,
|
||||
'fill' => false,
|
||||
],
|
||||
[
|
||||
'label' => __('admin.join_token_analytics.trend.rate_limited'),
|
||||
'data' => $rateLimited,
|
||||
'borderColor' => '#f59e0b',
|
||||
'backgroundColor' => 'rgba(245, 158, 11, 0.2)',
|
||||
'tension' => 0.35,
|
||||
'fill' => false,
|
||||
],
|
||||
[
|
||||
'label' => __('admin.join_token_analytics.trend.uploads'),
|
||||
'data' => $uploads,
|
||||
'borderColor' => '#2563eb',
|
||||
'backgroundColor' => 'rgba(37, 99, 235, 0.2)',
|
||||
'tension' => 0.35,
|
||||
'fill' => false,
|
||||
],
|
||||
],
|
||||
'labels' => $labels,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getType(): string
|
||||
{
|
||||
return 'line';
|
||||
}
|
||||
|
||||
public function getHeading(): ?string
|
||||
{
|
||||
return __('admin.join_token_analytics.trend.heading');
|
||||
}
|
||||
|
||||
private function baseQuery(array $filters): Builder
|
||||
{
|
||||
$query = EventJoinTokenEvent::query()
|
||||
->whereBetween('occurred_at', [$filters['start'], $filters['end']]);
|
||||
|
||||
if ($filters['event_id']) {
|
||||
$query->where('event_id', $filters['event_id']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function resolveFilters(): array
|
||||
{
|
||||
$eventId = $this->pageFilters['event_id'] ?? null;
|
||||
$eventId = is_numeric($eventId) ? (int) $eventId : null;
|
||||
$range = is_string($this->pageFilters['range'] ?? null) ? $this->pageFilters['range'] : '24h';
|
||||
|
||||
[$start, $end] = $this->resolveWindow($range, $eventId);
|
||||
|
||||
return [
|
||||
'event_id' => $eventId,
|
||||
'range' => $range,
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveWindow(string $range, ?int $eventId): array
|
||||
{
|
||||
$now = now();
|
||||
$start = match ($range) {
|
||||
'2h' => $now->copy()->subHours(2),
|
||||
'6h' => $now->copy()->subHours(6),
|
||||
'12h' => $now->copy()->subHours(12),
|
||||
'7d' => $now->copy()->subDays(7),
|
||||
default => $now->copy()->subHours(24),
|
||||
};
|
||||
$end = $now;
|
||||
|
||||
if ($range === 'event_day' && $eventId) {
|
||||
$eventDate = Event::query()->whereKey($eventId)->value('date');
|
||||
|
||||
if ($eventDate) {
|
||||
$eventDay = Carbon::parse($eventDate);
|
||||
$start = $eventDay->copy()->startOfDay();
|
||||
$end = $eventDay->copy()->endOfDay();
|
||||
}
|
||||
}
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
|
||||
private const SUCCESS_EVENTS = [
|
||||
'access_granted',
|
||||
'gallery_access_granted',
|
||||
];
|
||||
|
||||
private const RATE_LIMIT_EVENTS = [
|
||||
'token_rate_limited',
|
||||
'access_rate_limited',
|
||||
'download_rate_limited',
|
||||
];
|
||||
|
||||
private const FAILURE_EVENTS = [
|
||||
'invalid_token',
|
||||
'token_expired',
|
||||
'token_revoked',
|
||||
'event_not_public',
|
||||
'gallery_expired',
|
||||
'token_rate_limited',
|
||||
'access_rate_limited',
|
||||
'download_rate_limited',
|
||||
];
|
||||
|
||||
private const UPLOAD_EVENTS = [
|
||||
'upload_completed',
|
||||
];
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Enums\GuestNotificationAudience;
|
||||
use App\Enums\GuestNotificationDeliveryStatus;
|
||||
use App\Enums\GuestNotificationState;
|
||||
use App\Enums\GuestNotificationType;
|
||||
use App\Enums\PhotoLiveStatus;
|
||||
use App\Events\GuestPhotoUploaded;
|
||||
use App\Jobs\ProcessPhotoSecurityScan;
|
||||
use App\Models\Event;
|
||||
@@ -1899,6 +1900,8 @@ class EventPublicController extends BaseController
|
||||
$branding = $this->buildGalleryBranding($event);
|
||||
$settings = $this->normalizeSettings($event->settings ?? []);
|
||||
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
|
||||
$liveShowSettings = Arr::get($settings, 'live_show', []);
|
||||
$liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : [];
|
||||
$event->loadMissing('photoboothSetting');
|
||||
$policy = $this->guestPolicy();
|
||||
|
||||
@@ -1921,6 +1924,9 @@ class EventPublicController extends BaseController
|
||||
'photobooth_enabled' => (bool) ($event->photoboothSetting?->enabled),
|
||||
'branding' => $branding,
|
||||
'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility),
|
||||
'live_show' => [
|
||||
'moderation_mode' => $liveShowSettings['moderation_mode'] ?? 'manual',
|
||||
],
|
||||
'engagement_mode' => $engagementMode,
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
@@ -2987,6 +2993,7 @@ class EventPublicController extends BaseController
|
||||
'emotion_slug' => ['nullable', 'string'],
|
||||
'task_id' => ['nullable', 'integer'],
|
||||
'guest_name' => ['nullable', 'string', 'max:255'],
|
||||
'live_show_opt_in' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$file = $validated['photo'];
|
||||
@@ -3022,6 +3029,26 @@ class EventPublicController extends BaseController
|
||||
$url = $this->resolveDiskUrl($disk, $watermarkedPath);
|
||||
$thumbUrl = $this->resolveDiskUrl($disk, $watermarkedThumb);
|
||||
|
||||
$liveShowSettings = Arr::get($eventModel->settings ?? [], 'live_show', []);
|
||||
$liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : [];
|
||||
$liveModerationMode = $liveShowSettings['moderation_mode'] ?? 'manual';
|
||||
$liveOptIn = $request->boolean('live_show_opt_in');
|
||||
$liveSubmittedAt = null;
|
||||
$liveApprovedAt = null;
|
||||
$liveReviewedAt = null;
|
||||
$liveStatus = PhotoLiveStatus::NONE->value;
|
||||
|
||||
if ($liveOptIn) {
|
||||
$liveSubmittedAt = now();
|
||||
if ($liveModerationMode === 'off') {
|
||||
$liveStatus = PhotoLiveStatus::APPROVED->value;
|
||||
$liveApprovedAt = $liveSubmittedAt;
|
||||
$liveReviewedAt = $liveSubmittedAt;
|
||||
} else {
|
||||
$liveStatus = PhotoLiveStatus::PENDING->value;
|
||||
}
|
||||
}
|
||||
|
||||
$photoId = DB::table('photos')->insertGetId([
|
||||
'event_id' => $eventId,
|
||||
'tenant_id' => $tenantModel->id,
|
||||
@@ -3033,6 +3060,12 @@ class EventPublicController extends BaseController
|
||||
'likes_count' => 0,
|
||||
'ingest_source' => Photo::SOURCE_GUEST_PWA,
|
||||
'status' => $autoApproveUploads ? 'approved' : 'pending',
|
||||
'live_status' => $liveStatus,
|
||||
'live_submitted_at' => $liveSubmittedAt,
|
||||
'live_approved_at' => $liveApprovedAt,
|
||||
'live_reviewed_at' => $liveReviewedAt,
|
||||
'live_reviewed_by' => null,
|
||||
'live_rejection_reason' => null,
|
||||
|
||||
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
|
||||
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
|
||||
|
||||
348
app/Http/Controllers/Api/LiveShowController.php
Normal file
348
app/Http/Controllers/Api/LiveShowController.php
Normal file
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Enums\PhotoLiveStatus;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\StreamedEvent;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Arr;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LiveShowController extends BaseController
|
||||
{
|
||||
private const DEFAULT_RETENTION_HOURS = 12;
|
||||
|
||||
private const DEFAULT_LIMIT = 50;
|
||||
|
||||
private const MAX_LIMIT = 200;
|
||||
|
||||
private const STREAM_TICK_SECONDS = 2;
|
||||
|
||||
private const STREAM_MAX_SECONDS = 60;
|
||||
|
||||
private const STREAM_PING_SECONDS = 10;
|
||||
|
||||
public function state(Request $request, string $token): JsonResponse
|
||||
{
|
||||
$event = $this->resolveEvent($token);
|
||||
|
||||
if (! $event) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$settings = $this->liveShowSettings($event);
|
||||
$settingsVersion = $this->settingsVersion($settings);
|
||||
$limit = $this->resolveLimit($request);
|
||||
|
||||
$photos = $this->baseLiveShowQuery($event, $settings)
|
||||
->orderByDesc('live_approved_at')
|
||||
->orderByDesc('id')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->reverse()
|
||||
->values();
|
||||
|
||||
$cursor = $this->buildCursor($photos->last());
|
||||
|
||||
return response()->json([
|
||||
'event' => [
|
||||
'id' => $event->id,
|
||||
'slug' => $event->slug,
|
||||
'name' => $event->name,
|
||||
'default_locale' => $event->default_locale,
|
||||
],
|
||||
'settings' => $settings,
|
||||
'settings_version' => $settingsVersion,
|
||||
'photos' => $photos->map(fn (Photo $photo) => $this->serializePhoto($photo)),
|
||||
'cursor' => $cursor,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updates(Request $request, string $token): JsonResponse
|
||||
{
|
||||
$event = $this->resolveEvent($token);
|
||||
|
||||
if (! $event) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$cursor = $this->parseCursor($request);
|
||||
|
||||
if ($cursor === null) {
|
||||
return response()->json([
|
||||
'error' => 'invalid_cursor',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$settings = $this->liveShowSettings($event);
|
||||
$settingsVersion = $this->settingsVersion($settings);
|
||||
$limit = $this->resolveLimit($request);
|
||||
|
||||
$query = $this->baseLiveShowQuery($event, $settings);
|
||||
$this->applyCursor($query, $cursor);
|
||||
|
||||
$photos = $query
|
||||
->orderBy('live_approved_at')
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
$nextCursor = $this->buildCursor($photos->last());
|
||||
$requestedSettingsVersion = (string) $request->query('settings_version', '');
|
||||
$includeSettings = $requestedSettingsVersion === '' || $requestedSettingsVersion !== $settingsVersion;
|
||||
|
||||
return response()->json([
|
||||
'settings' => $includeSettings ? $settings : null,
|
||||
'settings_version' => $settingsVersion,
|
||||
'photos' => $photos->map(fn (Photo $photo) => $this->serializePhoto($photo)),
|
||||
'cursor' => $nextCursor ?? $cursor,
|
||||
]);
|
||||
}
|
||||
|
||||
public function stream(Request $request, string $token): Response
|
||||
{
|
||||
$event = $this->resolveEvent($token);
|
||||
|
||||
if (! $event) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$cursor = $this->parseCursor($request);
|
||||
|
||||
if ($cursor === null) {
|
||||
return response()->json([
|
||||
'error' => 'invalid_cursor',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$settings = $this->liveShowSettings($event);
|
||||
$settingsVersion = $this->settingsVersion($settings);
|
||||
$requestedSettingsVersion = (string) $request->query('settings_version', '');
|
||||
$lastSettingsCheck = CarbonImmutable::now();
|
||||
$lastPingAt = CarbonImmutable::now();
|
||||
$startedAt = CarbonImmutable::now();
|
||||
|
||||
return response()->eventStream(function () use ($event, $cursor, $settings, $settingsVersion, $requestedSettingsVersion, $startedAt, $lastPingAt, $lastSettingsCheck) {
|
||||
$lastApprovedAt = $cursor['approved_at'];
|
||||
$lastId = $cursor['id'];
|
||||
$currentSettingsVersion = $requestedSettingsVersion !== '' ? $requestedSettingsVersion : $settingsVersion;
|
||||
$currentSettings = $settings;
|
||||
|
||||
while (CarbonImmutable::now()->diffInSeconds($startedAt) < self::STREAM_MAX_SECONDS) {
|
||||
if (connection_aborted()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
if ($now->diffInSeconds($lastSettingsCheck) >= self::STREAM_PING_SECONDS) {
|
||||
$event->refresh();
|
||||
$currentSettings = $this->liveShowSettings($event);
|
||||
$newSettingsVersion = $this->settingsVersion($currentSettings);
|
||||
|
||||
if ($newSettingsVersion !== $currentSettingsVersion) {
|
||||
$currentSettingsVersion = $newSettingsVersion;
|
||||
yield new StreamedEvent(
|
||||
event: 'settings.updated',
|
||||
data: [
|
||||
'settings' => $currentSettings,
|
||||
'settings_version' => $currentSettingsVersion,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$lastSettingsCheck = $now;
|
||||
}
|
||||
|
||||
$query = $this->baseLiveShowQuery($event, $currentSettings);
|
||||
$this->applyCursor($query, [
|
||||
'approved_at' => $lastApprovedAt,
|
||||
'id' => $lastId,
|
||||
]);
|
||||
|
||||
$updates = $query
|
||||
->orderBy('live_approved_at')
|
||||
->orderBy('id')
|
||||
->limit(self::MAX_LIMIT)
|
||||
->get();
|
||||
|
||||
foreach ($updates as $photo) {
|
||||
$payload = $this->serializePhoto($photo);
|
||||
$lastApprovedAt = $photo->live_approved_at;
|
||||
$lastId = $photo->id;
|
||||
|
||||
yield new StreamedEvent(
|
||||
event: 'photo.approved',
|
||||
data: [
|
||||
'photo' => $payload,
|
||||
'cursor' => $this->buildCursor($photo),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if ($now->diffInSeconds($lastPingAt) >= self::STREAM_PING_SECONDS) {
|
||||
$lastPingAt = $now;
|
||||
yield new StreamedEvent(
|
||||
event: 'ping',
|
||||
data: [
|
||||
'time' => $now->toAtomString(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
sleep(self::STREAM_TICK_SECONDS);
|
||||
}
|
||||
}, Response::HTTP_OK, [
|
||||
'Cache-Control' => 'no-cache',
|
||||
'X-Accel-Buffering' => 'no',
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveEvent(string $token): ?Event
|
||||
{
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Event::query()
|
||||
->where('live_show_token', $token)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function liveShowSettings(Event $event): array
|
||||
{
|
||||
$settings = is_array($event->settings) ? $event->settings : [];
|
||||
$liveShow = Arr::get($settings, 'live_show', []);
|
||||
|
||||
return array_merge([
|
||||
'retention_window_hours' => self::DEFAULT_RETENTION_HOURS,
|
||||
'moderation_mode' => 'manual',
|
||||
'playback_mode' => 'newest_first',
|
||||
'pace_mode' => 'auto',
|
||||
'fixed_interval_seconds' => 8,
|
||||
'layout_mode' => 'single',
|
||||
'effect_preset' => 'film_cut',
|
||||
'effect_intensity' => 70,
|
||||
'background_mode' => 'blur_last',
|
||||
], is_array($liveShow) ? $liveShow : []);
|
||||
}
|
||||
|
||||
private function settingsVersion(array $settings): string
|
||||
{
|
||||
return sha1(json_encode($settings));
|
||||
}
|
||||
|
||||
private function resolveLimit(Request $request): int
|
||||
{
|
||||
$limit = (int) $request->query('limit', self::DEFAULT_LIMIT);
|
||||
|
||||
if ($limit <= 0) {
|
||||
return self::DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
return min($limit, self::MAX_LIMIT);
|
||||
}
|
||||
|
||||
private function baseLiveShowQuery(Event $event, array $settings): Builder
|
||||
{
|
||||
$query = Photo::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('live_status', PhotoLiveStatus::APPROVED->value)
|
||||
->where('status', 'approved')
|
||||
->whereNotNull('live_approved_at');
|
||||
|
||||
$retention = (int) ($settings['retention_window_hours'] ?? self::DEFAULT_RETENTION_HOURS);
|
||||
if ($retention > 0) {
|
||||
$query->where('live_approved_at', '>=', now()->subHours($retention));
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function applyCursor(Builder $query, array $cursor): void
|
||||
{
|
||||
$afterAt = $cursor['approved_at'];
|
||||
$afterId = $cursor['id'];
|
||||
|
||||
if (! $afterAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($afterId <= 0) {
|
||||
$query->where('live_approved_at', '>', $afterAt);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function (Builder $inner) use ($afterAt, $afterId) {
|
||||
$inner->where('live_approved_at', '>', $afterAt)
|
||||
->orWhere(function (Builder $tie) use ($afterAt, $afterId) {
|
||||
$tie->where('live_approved_at', $afterAt)
|
||||
->where('id', '>', $afterId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private function serializePhoto(Photo $photo): array
|
||||
{
|
||||
return [
|
||||
'id' => $photo->id,
|
||||
'full_url' => $photo->file_path,
|
||||
'thumb_url' => $photo->thumbnail_path,
|
||||
'approved_at' => $photo->live_approved_at?->toAtomString(),
|
||||
'width' => $photo->width,
|
||||
'height' => $photo->height,
|
||||
'is_featured' => (bool) $photo->is_featured,
|
||||
'live_priority' => (int) ($photo->live_priority ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildCursor(?Photo $photo): ?array
|
||||
{
|
||||
if (! $photo || ! $photo->live_approved_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'approved_at' => $photo->live_approved_at,
|
||||
'id' => $photo->id,
|
||||
];
|
||||
}
|
||||
|
||||
private function parseCursor(Request $request): ?array
|
||||
{
|
||||
$afterAt = trim((string) $request->query('after_approved_at', ''));
|
||||
$afterId = (int) $request->query('after_id', 0);
|
||||
|
||||
if ($afterAt === '' && $afterId === 0) {
|
||||
return [
|
||||
'approved_at' => null,
|
||||
'id' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$approvedAt = CarbonImmutable::parse($afterAt);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'approved_at' => $approvedAt,
|
||||
'id' => max(0, $afterId),
|
||||
];
|
||||
}
|
||||
|
||||
private function notFound(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'error' => 'live_show_not_found',
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,12 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Checkout\CheckoutSessionStatusRequest;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -14,7 +17,10 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PackageController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {}
|
||||
public function __construct(
|
||||
private readonly PaddleCheckoutService $paddleCheckout,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
@@ -165,23 +171,82 @@ class PackageController extends Controller
|
||||
|
||||
$package = Package::findOrFail($request->integer('package_id'));
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
$user = $request->user();
|
||||
|
||||
if (! $tenant) {
|
||||
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
throw ValidationException::withMessages(['user' => 'User context missing.']);
|
||||
}
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
}
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
$now = now();
|
||||
|
||||
$session->forceFill([
|
||||
'accepted_terms_at' => $now,
|
||||
'accepted_privacy_at' => $now,
|
||||
'accepted_withdrawal_notice_at' => $now,
|
||||
'digital_content_waiver_at' => null,
|
||||
'legal_version' => config('app.legal_version', $now->toDateString()),
|
||||
])->save();
|
||||
|
||||
$payload = [
|
||||
'success_url' => $request->input('success_url'),
|
||||
'return_url' => $request->input('return_url'),
|
||||
'metadata' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'legal_version' => $session->legal_version,
|
||||
'accepted_terms' => true,
|
||||
],
|
||||
];
|
||||
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
|
||||
|
||||
return response()->json($checkout);
|
||||
$session->forceFill([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
return response()->json(array_merge($checkout, [
|
||||
'checkout_session_id' => $session->id,
|
||||
]));
|
||||
}
|
||||
|
||||
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
|
||||
{
|
||||
$history = $session->status_history ?? [];
|
||||
$reason = null;
|
||||
|
||||
foreach (array_reverse($history) as $entry) {
|
||||
if (($entry['status'] ?? null) === $session->status) {
|
||||
$reason = $entry['reason'] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
|
||||
|
||||
return response()->json([
|
||||
'status' => $session->status,
|
||||
'completed_at' => optional($session->completed_at)->toIso8601String(),
|
||||
'reason' => $reason,
|
||||
'checkout_url' => is_string($checkoutUrl) ? $checkoutUrl : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||
|
||||
45
app/Http/Controllers/Api/PhotoboothConnectController.php
Normal file
45
app/Http/Controllers/Api/PhotoboothConnectController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
|
||||
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PhotoboothConnectController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PhotoboothConnectCodeService $service) {}
|
||||
|
||||
public function store(PhotoboothConnectRedeemRequest $request): JsonResponse
|
||||
{
|
||||
$record = $this->service->redeem($request->input('code'));
|
||||
|
||||
if (! $record) {
|
||||
return response()->json([
|
||||
'message' => __('Ungültiger oder abgelaufener Verbindungscode.'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$record->loadMissing('event.photoboothSetting');
|
||||
$event = $record->event;
|
||||
$setting = $event?->photoboothSetting;
|
||||
|
||||
if (! $event || ! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') {
|
||||
return response()->json([
|
||||
'message' => __('Photobooth ist nicht im Sparkbooth-Modus aktiv.'),
|
||||
], 409);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
|
||||
'username' => $setting->username,
|
||||
'password' => $setting->password,
|
||||
'expires_at' => optional($setting->expires_at)->toIso8601String(),
|
||||
'response_format' => ($setting->metadata ?? [])['sparkbooth_response_format']
|
||||
?? config('photobooth.sparkbooth.response_format', 'json'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
190
app/Http/Controllers/Api/Tenant/DataExportController.php
Normal file
190
app/Http/Controllers/Api/Tenant/DataExportController.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\DataExportStoreRequest;
|
||||
use App\Jobs\GenerateDataExport;
|
||||
use App\Models\DataExport;
|
||||
use App\Models\Event;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class DataExportController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $this->resolveTenant($request);
|
||||
|
||||
$exports = DataExport::query()
|
||||
->with('event')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('scope', ['tenant', 'event'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn (DataExport $export) => [
|
||||
'id' => $export->id,
|
||||
'scope' => $export->scope?->value ?? $export->scope,
|
||||
'status' => $export->status,
|
||||
'include_media' => (bool) $export->include_media,
|
||||
'size_bytes' => $export->size_bytes,
|
||||
'created_at' => optional($export->created_at)->toIso8601String(),
|
||||
'expires_at' => optional($export->expires_at)->toIso8601String(),
|
||||
'download_url' => $export->isReady() && ! $export->hasExpired()
|
||||
? route('api.v1.tenant.exports.download', $export)
|
||||
: null,
|
||||
'error_message' => $export->error_message,
|
||||
'event' => $export->event ? [
|
||||
'id' => $export->event->id,
|
||||
'slug' => $export->event->slug,
|
||||
'name' => $export->event->name,
|
||||
] : null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => $exports,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(DataExportStoreRequest $request): JsonResponse
|
||||
{
|
||||
$tenant = $this->resolveTenant($request);
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user) {
|
||||
return ApiError::response(
|
||||
'export_user_missing',
|
||||
'Export user missing',
|
||||
'Unable to determine the requesting user.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$scope = $payload['scope'];
|
||||
$event = null;
|
||||
|
||||
if ($scope === 'event') {
|
||||
$event = Event::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->find($payload['event_id']);
|
||||
|
||||
if (! $event) {
|
||||
return ApiError::response(
|
||||
'export_event_missing',
|
||||
'Event not found',
|
||||
'The selected event does not exist for this tenant.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$hasInProgress = DataExport::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING])
|
||||
->exists();
|
||||
|
||||
if ($hasInProgress) {
|
||||
return ApiError::response(
|
||||
'export_in_progress',
|
||||
'Export already in progress',
|
||||
'Please wait for the current export to finish before requesting another.',
|
||||
Response::HTTP_CONFLICT
|
||||
);
|
||||
}
|
||||
|
||||
$export = DataExport::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event?->id,
|
||||
'scope' => $scope,
|
||||
'include_media' => (bool) ($payload['include_media'] ?? false),
|
||||
'status' => DataExport::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
GenerateDataExport::dispatch($export->id);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Export started.',
|
||||
'data' => [
|
||||
'id' => $export->id,
|
||||
'scope' => $export->scope?->value ?? $export->scope,
|
||||
'status' => $export->status,
|
||||
'include_media' => (bool) $export->include_media,
|
||||
'created_at' => optional($export->created_at)->toIso8601String(),
|
||||
],
|
||||
], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
public function download(Request $request, DataExport $export): StreamedResponse|JsonResponse
|
||||
{
|
||||
$tenant = $this->resolveTenant($request);
|
||||
|
||||
if ((int) $export->tenant_id !== (int) $tenant->id) {
|
||||
return ApiError::response(
|
||||
'export_not_found',
|
||||
'Export not found',
|
||||
'The requested export is not available for this tenant.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
if (! $export->isReady() || $export->hasExpired() || ! $export->path) {
|
||||
return ApiError::response(
|
||||
'export_not_ready',
|
||||
'Export not ready',
|
||||
'The export is not ready or has expired.',
|
||||
Response::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$disk = 'local';
|
||||
|
||||
if (! Storage::disk($disk)->exists($export->path)) {
|
||||
return ApiError::response(
|
||||
'export_missing',
|
||||
'Export not found',
|
||||
'The export archive could not be located.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return Storage::disk($disk)->download(
|
||||
$export->path,
|
||||
sprintf('fotospiel-data-export-%s.zip', $export->created_at?->format('Ymd') ?? now()->format('Ymd')),
|
||||
[
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveTenant(Request $request): Tenant
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
$tenantId = $request->attributes->get('tenant_id')
|
||||
?? $request->attributes->get('current_tenant_id')
|
||||
?? $request->user()?->tenant_id;
|
||||
|
||||
if ($tenantId) {
|
||||
$tenant = Tenant::query()->find($tenantId);
|
||||
if ($tenant) {
|
||||
$request->attributes->set('tenant', $tenant);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
abort(401, 'Tenant context missing.');
|
||||
}
|
||||
}
|
||||
46
app/Http/Controllers/Api/Tenant/EventAnalyticsController.php
Normal file
46
app/Http/Controllers/Api/Tenant/EventAnalyticsController.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use App\Services\Analytics\EventAnalyticsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class EventAnalyticsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventAnalyticsService $analyticsService
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
// Check if package has advanced_analytics feature
|
||||
$packageFeatures = $event->eventPackage?->package?->features ?? [];
|
||||
// Handle array or JSON string features
|
||||
if (is_string($packageFeatures)) {
|
||||
$packageFeatures = json_decode($packageFeatures, true) ?? [];
|
||||
}
|
||||
|
||||
$hasAccess = in_array('advanced_analytics', $packageFeatures, true);
|
||||
|
||||
if (!$hasAccess) {
|
||||
return response()->json([
|
||||
'message' => 'This feature is only available in the Premium package.',
|
||||
'code' => 'feature_locked'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$timeline = $this->analyticsService->getTimeline($event);
|
||||
$contributors = $this->analyticsService->getTopContributors($event);
|
||||
$tasks = $this->analyticsService->getTaskStats($event);
|
||||
|
||||
return response()->json([
|
||||
'timeline' => $timeline,
|
||||
'contributors' => $contributors,
|
||||
'tasks' => $tasks,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -88,12 +89,15 @@ class EventController extends Controller
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
}
|
||||
|
||||
$actor = $request->user();
|
||||
$isSuperAdmin = $actor instanceof User && $actor->isSuperAdmin();
|
||||
|
||||
// Package check is now handled by middleware
|
||||
|
||||
$validated = $request->validated();
|
||||
$tenantId = $tenant->id;
|
||||
|
||||
$requestedPackageId = $validated['package_id'] ?? null;
|
||||
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
|
||||
unset($validated['package_id']);
|
||||
|
||||
$tenantPackage = $tenant->tenantPackages()
|
||||
@@ -108,6 +112,10 @@ class EventController extends Controller
|
||||
$package = Package::query()->find($requestedPackageId);
|
||||
}
|
||||
|
||||
if (! $package && $isSuperAdmin) {
|
||||
$package = $this->resolveOwnerPackage();
|
||||
}
|
||||
|
||||
if (! $package && $tenantPackage) {
|
||||
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
||||
}
|
||||
@@ -121,7 +129,7 @@ class EventController extends Controller
|
||||
$requiresWaiver = $package->isEndcustomer();
|
||||
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
||||
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
|
||||
$needsWaiver = $requiresWaiver && ! $existingWaiver;
|
||||
$needsWaiver = ! $isSuperAdmin && $requiresWaiver && ! $existingWaiver;
|
||||
|
||||
if ($needsWaiver && ! $request->boolean('accepted_waiver')) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -182,7 +190,7 @@ class EventController extends Controller
|
||||
|
||||
$eventData = Arr::only($eventData, $allowed);
|
||||
|
||||
$event = DB::transaction(function () use ($tenant, $eventData, $package) {
|
||||
$event = DB::transaction(function () use ($tenant, $eventData, $package, $isSuperAdmin) {
|
||||
$event = Event::create($eventData);
|
||||
|
||||
EventPackage::create([
|
||||
@@ -193,7 +201,7 @@ class EventController extends Controller
|
||||
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
|
||||
]);
|
||||
|
||||
if ($package->isReseller()) {
|
||||
if ($package->isReseller() && ! $isSuperAdmin) {
|
||||
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
||||
|
||||
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
||||
@@ -229,6 +237,15 @@ class EventController extends Controller
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveOwnerPackage(): ?Package
|
||||
{
|
||||
$ownerPackage = Package::query()
|
||||
->where('slug', 'pro')
|
||||
->first();
|
||||
|
||||
return $ownerPackage ?? Package::query()->find(3);
|
||||
}
|
||||
|
||||
private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void
|
||||
{
|
||||
$timestamp = now();
|
||||
|
||||
@@ -135,7 +135,7 @@ class EventMemberController extends Controller
|
||||
$user->password = Hash::make(Str::random(32));
|
||||
}
|
||||
|
||||
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && $user->role !== 'super_admin') {
|
||||
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && ! $user->isSuperAdmin()) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
|
||||
]);
|
||||
@@ -143,9 +143,9 @@ class EventMemberController extends Controller
|
||||
|
||||
$user->tenant_id = $tenant->id;
|
||||
|
||||
if ($role === 'tenant_admin' && $user->role !== 'super_admin') {
|
||||
if ($role === 'tenant_admin' && ! $user->isSuperAdmin()) {
|
||||
$user->role = 'tenant_admin';
|
||||
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||
$user->role = 'member';
|
||||
}
|
||||
|
||||
|
||||
135
app/Http/Controllers/Api/Tenant/LiveShowLinkController.php
Normal file
135
app/Http/Controllers/Api/Tenant/LiveShowLinkController.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
|
||||
class LiveShowLinkController extends Controller
|
||||
{
|
||||
public function show(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->authorizeEvent($request, $event);
|
||||
|
||||
$token = $event->ensureLiveShowToken();
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->buildPayload($event, $token),
|
||||
]);
|
||||
}
|
||||
|
||||
public function rotate(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->authorizeEvent($request, $event);
|
||||
|
||||
$token = $event->rotateLiveShowToken();
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->buildPayload($event, $token),
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizeEvent(Request $request, Event $event): void
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
abort(404, 'Event not found');
|
||||
}
|
||||
}
|
||||
|
||||
private function buildPayload(Event $event, string $token): array
|
||||
{
|
||||
$url = $this->buildLiveShowUrl($event, $token);
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'url' => $url,
|
||||
'qr_code_data_url' => $this->buildQrCodeDataUrl($url),
|
||||
'rotated_at' => $event->live_show_token_rotated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildLiveShowUrl(Event $event, string $token): string
|
||||
{
|
||||
$baseUrl = $this->resolveBaseUrl($event);
|
||||
|
||||
return rtrim($baseUrl, '/').'/show/'.$token;
|
||||
}
|
||||
|
||||
private function resolveBaseUrl(Event $event): string
|
||||
{
|
||||
$settings = is_array($event->settings) ? $event->settings : [];
|
||||
$customDomain = $settings['custom_domain'] ?? null;
|
||||
|
||||
if (is_string($customDomain) && $customDomain !== '') {
|
||||
return sprintf('%s://%s', $this->resolveScheme(), $customDomain);
|
||||
}
|
||||
|
||||
$publicUrl = $settings['public_url'] ?? null;
|
||||
|
||||
if (is_string($publicUrl) && $publicUrl !== '') {
|
||||
$parsed = parse_url($publicUrl);
|
||||
$host = is_array($parsed) ? ($parsed['host'] ?? null) : null;
|
||||
|
||||
if (is_string($host) && $host !== '') {
|
||||
$scheme = $parsed['scheme'] ?? $this->resolveScheme();
|
||||
$port = $parsed['port'] ?? null;
|
||||
$base = $scheme.'://'.$host;
|
||||
|
||||
if ($port) {
|
||||
$base .= ':'.$port;
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
}
|
||||
|
||||
return (string) config('app.url');
|
||||
}
|
||||
|
||||
private function resolveScheme(): string
|
||||
{
|
||||
$appUrl = config('app.url');
|
||||
|
||||
if (is_string($appUrl)) {
|
||||
$scheme = parse_url($appUrl, PHP_URL_SCHEME);
|
||||
|
||||
if (is_string($scheme) && $scheme !== '') {
|
||||
return $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
return 'https';
|
||||
}
|
||||
|
||||
private function buildQrCodeDataUrl(string $url): ?string
|
||||
{
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$png = QrCode::format('png')
|
||||
->size(360)
|
||||
->margin(1)
|
||||
->errorCorrection('M')
|
||||
->generate($url);
|
||||
|
||||
$pngBinary = (string) $png;
|
||||
|
||||
if ($pngBinary === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'data:image/png;base64,'.base64_encode($pngBinary);
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
195
app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php
Normal file
195
app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\LiveShowApproveRequest;
|
||||
use App\Http\Requests\Tenant\LiveShowQueueRequest;
|
||||
use App\Http\Requests\Tenant\LiveShowRejectRequest;
|
||||
use App\Http\Resources\Tenant\PhotoResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LiveShowPhotoController extends Controller
|
||||
{
|
||||
public function index(LiveShowQueueRequest $request, string $eventSlug): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
|
||||
$liveStatus = $request->string('live_status', 'pending')->toString();
|
||||
$perPage = (int) $request->input('per_page', 20);
|
||||
$perPage = max(1, min($perPage, 50));
|
||||
|
||||
$query = Photo::query()
|
||||
->where('event_id', $event->id)
|
||||
->with('event')
|
||||
->withCount('likes');
|
||||
|
||||
if ($liveStatus !== '' && $liveStatus !== 'all') {
|
||||
$query->where('live_status', $liveStatus);
|
||||
}
|
||||
|
||||
$photos = $query
|
||||
->orderByDesc('live_submitted_at')
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage);
|
||||
|
||||
return PhotoResource::collection($photos);
|
||||
}
|
||||
|
||||
public function approve(LiveShowApproveRequest $request, string $eventSlug, Photo $photo): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo not found',
|
||||
'The specified photo could not be located for this event.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['photo_id' => $photo->id]
|
||||
);
|
||||
}
|
||||
|
||||
if ($photo->status !== 'approved') {
|
||||
return ApiError::response(
|
||||
'photo_not_approved',
|
||||
'Photo not approved',
|
||||
'Only approved photos can be added to the Live Show.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
['photo_id' => $photo->id]
|
||||
);
|
||||
}
|
||||
|
||||
$photo->approveForLiveShow($request->user());
|
||||
|
||||
if ($request->filled('priority')) {
|
||||
$photo->forceFill([
|
||||
'live_priority' => $request->integer('priority'),
|
||||
])->save();
|
||||
}
|
||||
|
||||
$photo->refresh()->load('event')->loadCount('likes');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Photo approved for Live Show',
|
||||
'data' => new PhotoResource($photo),
|
||||
]);
|
||||
}
|
||||
|
||||
public function approveAndLive(LiveShowApproveRequest $request, string $eventSlug, Photo $photo): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo not found',
|
||||
'The specified photo could not be located for this event.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['photo_id' => $photo->id]
|
||||
);
|
||||
}
|
||||
|
||||
if (in_array($photo->status, ['rejected', 'hidden'], true)) {
|
||||
return ApiError::response(
|
||||
'photo_not_eligible',
|
||||
'Photo not eligible',
|
||||
'Rejected or hidden photos cannot be approved for Live Show.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
['photo_id' => $photo->id]
|
||||
);
|
||||
}
|
||||
|
||||
if ($photo->status !== 'approved') {
|
||||
$photo->forceFill([
|
||||
'status' => 'approved',
|
||||
'moderated_at' => now(),
|
||||
'moderated_by' => $request->user()?->id,
|
||||
'moderation_notes' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
$photo->approveForLiveShow($request->user());
|
||||
|
||||
if ($request->filled('priority')) {
|
||||
$photo->forceFill([
|
||||
'live_priority' => $request->integer('priority'),
|
||||
])->save();
|
||||
}
|
||||
|
||||
$photo->refresh()->load('event')->loadCount('likes');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Photo approved and added to Live Show',
|
||||
'data' => new PhotoResource($photo),
|
||||
]);
|
||||
}
|
||||
|
||||
public function reject(LiveShowRejectRequest $request, string $eventSlug, Photo $photo): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo not found',
|
||||
'The specified photo could not be located for this event.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['photo_id' => $photo->id]
|
||||
);
|
||||
}
|
||||
|
||||
$reason = $request->string('reason')->toString();
|
||||
$photo->rejectForLiveShow($request->user(), $reason !== '' ? $reason : null);
|
||||
$photo->refresh()->load('event')->loadCount('likes');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Photo rejected for Live Show',
|
||||
'data' => new PhotoResource($photo),
|
||||
]);
|
||||
}
|
||||
|
||||
public function clear(Request $request, string $eventSlug, Photo $photo): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo not found',
|
||||
'The specified photo could not be located for this event.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['photo_id' => $photo->id]
|
||||
);
|
||||
}
|
||||
|
||||
$photo->clearFromLiveShow($request->user());
|
||||
$photo->refresh()->load('event')->loadCount('likes');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Photo removed from Live Show',
|
||||
'data' => new PhotoResource($photo),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ class OnboardingController extends Controller
|
||||
'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'),
|
||||
'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'),
|
||||
'selected_packages' => Arr::get($settings, 'onboarding.selected_packages'),
|
||||
'summary_seen_package_id' => Arr::get($settings, 'onboarding.summary_seen_package_id'),
|
||||
'summary_seen_at' => Arr::get($settings, 'onboarding.summary_seen_at'),
|
||||
'dismissed_at' => Arr::get($settings, 'onboarding.dismissed_at'),
|
||||
'completed_at' => Arr::get($settings, 'onboarding.completed_at'),
|
||||
'branding_completed' => (bool) ($status['palette'] ?? false),
|
||||
@@ -86,6 +88,11 @@ class OnboardingController extends Controller
|
||||
Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String());
|
||||
break;
|
||||
|
||||
case 'summary_seen':
|
||||
Arr::set($settings, 'onboarding.summary_seen_package_id', Arr::get($meta, 'package_id'));
|
||||
Arr::set($settings, 'onboarding.summary_seen_at', Carbon::now()->toIso8601String());
|
||||
break;
|
||||
|
||||
case 'dismissed':
|
||||
Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String());
|
||||
break;
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Services\Packages\PackageUsageTracker;
|
||||
use App\Services\Storage\EventStorageManager;
|
||||
use App\Support\ApiError;
|
||||
use App\Support\ImageHelper;
|
||||
use App\Support\UploadStream;
|
||||
use App\Support\WatermarkConfigResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -318,7 +319,7 @@ class PhotoController extends Controller
|
||||
$path = "events/{$eventSlug}/photos/{$filename}";
|
||||
|
||||
// Store original file
|
||||
Storage::disk($disk)->put($path, file_get_contents($file->getRealPath()));
|
||||
UploadStream::putUploadedFile($disk, $path, $file);
|
||||
|
||||
// Generate thumbnail
|
||||
$thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}";
|
||||
@@ -354,6 +355,7 @@ class PhotoController extends Controller
|
||||
|
||||
$photoAttributes = [
|
||||
'event_id' => $event->id,
|
||||
'guest_name' => Photo::SOURCE_TENANT_ADMIN,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
@@ -523,13 +525,13 @@ class PhotoController extends Controller
|
||||
]);
|
||||
|
||||
// Only tenant admins can moderate
|
||||
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
|
||||
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
|
||||
return ApiError::response(
|
||||
'insufficient_scope',
|
||||
'Insufficient Scopes',
|
||||
'You are not allowed to moderate photos for this event.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
['required_scope' => 'tenant:write']
|
||||
['required_scope' => 'tenant-admin']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -821,6 +823,11 @@ class PhotoController extends Controller
|
||||
|
||||
private function tokenHasScope(Request $request, string $scope): bool
|
||||
{
|
||||
$accessToken = $request->user()?->currentAccessToken();
|
||||
if ($accessToken && $accessToken->can($scope)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
|
||||
|
||||
if (! is_array($scopes)) {
|
||||
@@ -904,7 +911,7 @@ class PhotoController extends Controller
|
||||
$path = "events/{$eventSlug}/photos/{$filename}";
|
||||
|
||||
// Store file
|
||||
Storage::disk($disk)->put($path, file_get_contents($file->getRealPath()));
|
||||
UploadStream::putUploadedFile($disk, $path, $file);
|
||||
|
||||
// Generate thumbnail
|
||||
$thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}";
|
||||
@@ -915,6 +922,7 @@ class PhotoController extends Controller
|
||||
|
||||
$photoAttributes = [
|
||||
'event_id' => $event->id,
|
||||
'guest_name' => Photo::SOURCE_TENANT_ADMIN,
|
||||
'original_name' => $request->original_name,
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\PhotoboothConnectCodeStoreRequest;
|
||||
use App\Models\Event;
|
||||
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PhotoboothConnectCodeController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PhotoboothConnectCodeService $service) {}
|
||||
|
||||
public function store(PhotoboothConnectCodeStoreRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventBelongsToTenant($request, $event);
|
||||
|
||||
$event->loadMissing('photoboothSetting');
|
||||
$setting = $event->photoboothSetting;
|
||||
|
||||
if (! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') {
|
||||
return response()->json([
|
||||
'message' => __('Photobooth muss im Sparkbooth-Modus aktiviert sein.'),
|
||||
], 409);
|
||||
}
|
||||
|
||||
$expiresInMinutes = $request->input('expires_in_minutes');
|
||||
$result = $this->service->create($event, $expiresInMinutes ? (int) $expiresInMinutes : null);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'code' => $result['code'],
|
||||
'expires_at' => $result['expires_at']->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function assertEventBelongsToTenant(PhotoboothConnectCodeStoreRequest $request, Event $event): void
|
||||
{
|
||||
$tenantId = (int) $request->attributes->get('tenant_id');
|
||||
|
||||
if ($tenantId !== (int) $event->tenant_id) {
|
||||
abort(403, 'Event gehört nicht zu diesem Tenant.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,11 +193,11 @@ class TenantAdminTokenController extends Controller
|
||||
$abilities[] = 'tenant:'.$user->tenant_id;
|
||||
}
|
||||
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||
$abilities[] = 'tenant-admin';
|
||||
}
|
||||
|
||||
if ($user->role === 'super_admin') {
|
||||
if ($user->isSuperAdmin()) {
|
||||
$abilities[] = 'super-admin';
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ class TenantAdminTokenController extends Controller
|
||||
|
||||
private function ensureUserCanAccessPanel(User $user): void
|
||||
{
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ use App\Models\User;
|
||||
use App\Notifications\TenantFeedbackSubmitted;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TenantFeedbackController extends Controller
|
||||
{
|
||||
@@ -56,7 +56,7 @@ class TenantFeedbackController extends Controller
|
||||
]);
|
||||
|
||||
$recipients = User::query()
|
||||
->where('role', 'super_admin')
|
||||
->whereIn('role', ['super_admin', 'superadmin'])
|
||||
->whereNotNull('email')
|
||||
->get();
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\TenantAuth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\TenantAdminForgotPasswordRequest;
|
||||
use App\Http\Requests\Auth\TenantAdminResetPasswordRequest;
|
||||
use App\Models\EventMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TenantAdminPasswordResetController extends Controller
|
||||
{
|
||||
public function requestLink(TenantAdminForgotPasswordRequest $request): JsonResponse
|
||||
{
|
||||
$email = $request->string('email')->trim()->value();
|
||||
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user || ! $this->canAccessEventAdmin($user)) {
|
||||
return $this->genericSuccessResponse();
|
||||
}
|
||||
|
||||
Password::sendResetLink([
|
||||
'email' => $email,
|
||||
]);
|
||||
|
||||
return $this->genericSuccessResponse();
|
||||
}
|
||||
|
||||
public function reset(TenantAdminResetPasswordRequest $request): JsonResponse
|
||||
{
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$this->ensureUserCanReset($user);
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->string('password')->value()),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
if ($status === Password::PasswordReset) {
|
||||
return response()->json([
|
||||
'status' => __($status),
|
||||
]);
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__($status)],
|
||||
]);
|
||||
}
|
||||
|
||||
private function genericSuccessResponse(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'status' => __('passwords.sent'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureUserCanReset(User $user): void
|
||||
{
|
||||
if ($this->canAccessEventAdmin($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [trans('auth.not_authorized')],
|
||||
]);
|
||||
}
|
||||
|
||||
private function canAccessEventAdmin(User $user): bool
|
||||
{
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($user->role === 'member' && $this->userHasCollaboratorMembership($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function userHasCollaboratorMembership(User $user): bool
|
||||
{
|
||||
if (! $user->tenant_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return EventMember::query()
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->where(function ($query) use ($user) {
|
||||
$query->where('user_id', $user->id)
|
||||
->orWhere('email', $user->email);
|
||||
})
|
||||
->whereIn('status', ['active', 'invited'])
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -29,23 +30,108 @@ class TenantPackageController extends Controller
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$packages->each(function ($package) {
|
||||
$pkg = $package->package;
|
||||
$package->remaining_events = $pkg->max_events_per_year - $package->used_events;
|
||||
$package->package_limits = array_merge(
|
||||
$pkg->limits,
|
||||
[
|
||||
'branding_allowed' => $pkg->branding_allowed,
|
||||
'watermark_allowed' => $pkg->watermark_allowed,
|
||||
'features' => $pkg->features,
|
||||
]
|
||||
);
|
||||
$usageEventPackage = $this->resolveUsageEventPackage($tenant->id);
|
||||
|
||||
$packages->each(function (TenantPackage $package) use ($usageEventPackage): void {
|
||||
$eventPackage = $package->active ? $usageEventPackage : null;
|
||||
$this->hydratePackageSnapshot($package, $eventPackage);
|
||||
});
|
||||
|
||||
$activePackage = $tenant->activeResellerPackage?->load('package');
|
||||
|
||||
if ($activePackage instanceof TenantPackage) {
|
||||
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $packages,
|
||||
'active_package' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->load('package') : null,
|
||||
'active_package' => $activePackage,
|
||||
'message' => 'Tenant packages loaded successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function hydratePackageSnapshot(TenantPackage $package, ?EventPackage $eventPackage = null): void
|
||||
{
|
||||
$pkg = $package->package;
|
||||
|
||||
$maxEvents = $pkg?->max_events_per_year;
|
||||
$package->remaining_events = $maxEvents === null ? null : max($maxEvents - $package->used_events, 0);
|
||||
$package->package_limits = array_merge(
|
||||
$pkg?->limits ?? [],
|
||||
$this->buildUsageSnapshot($eventPackage),
|
||||
[
|
||||
'branding_allowed' => $pkg?->branding_allowed,
|
||||
'watermark_allowed' => $pkg?->watermark_allowed,
|
||||
'features' => $pkg?->features ?? [],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, EventPackage>
|
||||
*/
|
||||
private function resolveUsageEventPackage(int $tenantId): ?EventPackage
|
||||
{
|
||||
$baseQuery = EventPackage::query()
|
||||
->whereHas('event', fn ($query) => $query->where('tenant_id', $tenantId))
|
||||
->with('package')
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
$activeEventPackage = (clone $baseQuery)
|
||||
->whereNotNull('gallery_expires_at')
|
||||
->where('gallery_expires_at', '>=', now())
|
||||
->first();
|
||||
|
||||
return $activeEventPackage ?? $baseQuery->first();
|
||||
}
|
||||
|
||||
private function buildUsageSnapshot(?EventPackage $eventPackage): array
|
||||
{
|
||||
if (! $eventPackage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$limits = $eventPackage->effectiveLimits();
|
||||
$maxPhotos = $this->normalizeLimit($limits['max_photos'] ?? null);
|
||||
$maxGuests = $this->normalizeLimit($limits['max_guests'] ?? null);
|
||||
$galleryDays = $this->normalizeLimit($limits['gallery_days'] ?? null);
|
||||
|
||||
$usedPhotos = (int) $eventPackage->used_photos;
|
||||
$usedGuests = (int) $eventPackage->used_guests;
|
||||
|
||||
$remainingPhotos = $maxPhotos === null ? null : max(0, $maxPhotos - $usedPhotos);
|
||||
$remainingGuests = $maxGuests === null ? null : max(0, $maxGuests - $usedGuests);
|
||||
|
||||
$remainingGalleryDays = null;
|
||||
$usedGalleryDays = null;
|
||||
if ($galleryDays !== null && $eventPackage->gallery_expires_at) {
|
||||
$remainingGalleryDays = max(0, now()->diffInDays($eventPackage->gallery_expires_at, false));
|
||||
$usedGalleryDays = max(0, $galleryDays - $remainingGalleryDays);
|
||||
}
|
||||
|
||||
return array_filter([
|
||||
'used_photos' => $maxPhotos === null ? null : $usedPhotos,
|
||||
'remaining_photos' => $remainingPhotos,
|
||||
'used_guests' => $maxGuests === null ? null : $usedGuests,
|
||||
'remaining_guests' => $remainingGuests,
|
||||
'used_gallery_days' => $usedGalleryDays,
|
||||
'remaining_gallery_days' => $remainingGalleryDays,
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
private function normalizeLimit(?int $value): ?int
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = (int) $value;
|
||||
|
||||
if ($value <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,8 +155,8 @@ class AuthenticatedSessionController extends Controller
|
||||
}
|
||||
|
||||
// Super admins go to Filament superadmin panel
|
||||
if ($user && $user->role === 'super_admin') {
|
||||
return '/admin';
|
||||
if ($user && $user->isSuperAdmin()) {
|
||||
return '/super-admin';
|
||||
}
|
||||
|
||||
// Tenant admins go to their PWA dashboard
|
||||
|
||||
@@ -3,153 +3,47 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use App\Support\LocaleConfig;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the registration page.
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
public function create(Request $request): RedirectResponse
|
||||
{
|
||||
$package = $request->query('package_id') ? \App\Models\Package::find($request->query('package_id')) : null;
|
||||
|
||||
return Inertia::render('auth/register', [
|
||||
'package' => $package,
|
||||
]);
|
||||
return $this->redirectToPackages($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request)
|
||||
public function store(Request $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$fullName = trim($request->first_name.' '.$request->last_name);
|
||||
|
||||
$validated = $request->validate([
|
||||
'username' => ['required', 'string', 'max:255', 'unique:'.User::class],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
'first_name' => ['required', 'string', 'max:255'],
|
||||
'last_name' => ['required', 'string', 'max:255'],
|
||||
'address' => ['required', 'string', 'max:500'],
|
||||
'phone' => ['required', 'string', 'max:20'],
|
||||
'privacy_consent' => ['accepted'],
|
||||
'package_id' => ['nullable', 'exists:packages,id'],
|
||||
]);
|
||||
|
||||
$shouldAutoVerify = App::environment('local');
|
||||
|
||||
$user = User::create([
|
||||
'username' => $validated['username'],
|
||||
'email' => $validated['email'],
|
||||
'first_name' => $validated['first_name'],
|
||||
'last_name' => $validated['last_name'],
|
||||
'address' => $validated['address'],
|
||||
'phone' => $validated['phone'],
|
||||
'password' => Hash::make($validated['password']),
|
||||
'privacy_consent_at' => now(), // Neues Feld für Consent (füge Migration hinzu, falls nötig)
|
||||
'role' => 'user',
|
||||
]);
|
||||
|
||||
if ($shouldAutoVerify) {
|
||||
$user->forceFill(['email_verified_at' => now()])->save();
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => 'Registration is only available during checkout.',
|
||||
], 410);
|
||||
}
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $fullName,
|
||||
'slug' => Str::slug($fullName.'-'.now()->timestamp),
|
||||
'email' => $request->email,
|
||||
'contact_email' => $request->email,
|
||||
'is_active' => true,
|
||||
'is_suspended' => false,
|
||||
'subscription_tier' => 'free',
|
||||
'subscription_expires_at' => null,
|
||||
'settings' => json_encode([
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#3B82F6',
|
||||
'secondary_color' => '#1F2937',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
'features' => [
|
||||
'photo_likes_enabled' => false,
|
||||
'event_checklist' => false,
|
||||
'custom_domain' => false,
|
||||
'advanced_analytics' => false,
|
||||
],
|
||||
'custom_domain' => null,
|
||||
'contact_email' => $request->email,
|
||||
'event_default_type' => 'general',
|
||||
]),
|
||||
]);
|
||||
return $this->redirectToPackages($request);
|
||||
}
|
||||
|
||||
if (! $user->tenant_id) {
|
||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||
private function redirectToPackages(Request $request): RedirectResponse
|
||||
{
|
||||
$preferredLocale = $request->session()->get('preferred_locale')
|
||||
?? $request->getPreferredLanguage(LocaleConfig::normalized());
|
||||
$locale = LocaleConfig::canonicalize($request->route('locale') ?? $preferredLocale);
|
||||
$packageId = $request->input('package_id');
|
||||
$routeParams = ['locale' => $locale];
|
||||
|
||||
if ($packageId) {
|
||||
$routeParams['package_id'] = $packageId;
|
||||
}
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
// Send Welcome Email
|
||||
Mail::to($user)
|
||||
->locale($user->preferred_locale ?? app()->getLocale())
|
||||
->send(new \App\Mail\Welcome($user));
|
||||
|
||||
if ($request->filled('package_id')) {
|
||||
$package = \App\Models\Package::find($request->package_id);
|
||||
if ($package && $package->price == 0) {
|
||||
// Assign free package
|
||||
\App\Models\TenantPackage::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'active' => true,
|
||||
'price' => 0,
|
||||
]);
|
||||
|
||||
\App\Models\PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
|
||||
'price' => 0,
|
||||
'purchased_at' => now(),
|
||||
'provider' => 'free',
|
||||
'provider_id' => 'free',
|
||||
]);
|
||||
|
||||
$tenant->update(['subscription_status' => 'active']);
|
||||
$user->update(['role' => 'tenant_admin']);
|
||||
Auth::login($user);
|
||||
} elseif ($package) {
|
||||
// Redirect to buy for paid package
|
||||
return redirect()->route('buy.packages', [
|
||||
'locale' => session('preferred_locale', app()->getLocale()),
|
||||
'packageId' => $package->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
if ($shouldAutoVerify) {
|
||||
return Inertia::location(route('dashboard'));
|
||||
}
|
||||
|
||||
session()->flash('status', 'registration-success');
|
||||
|
||||
return Inertia::location(route('verification.notice'));
|
||||
return redirect()->route('packages', $routeParams);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Services\Checkout\CheckoutAssignmentService;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use App\Support\CheckoutRoutes;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -81,12 +82,12 @@ class CheckoutController extends Controller
|
||||
// User erstellen
|
||||
$user = User::create([
|
||||
'email' => $validated['email'],
|
||||
'username' => $validated['username'],
|
||||
'username' => Str::lower($validated['email']),
|
||||
'first_name' => $validated['first_name'],
|
||||
'last_name' => $validated['last_name'],
|
||||
'name' => trim($validated['first_name'].' '.$validated['last_name']),
|
||||
'address' => $validated['address'],
|
||||
'phone' => $validated['phone'],
|
||||
'address' => $validated['address'] ?? null,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'preferred_locale' => $validated['locale'] ?? null,
|
||||
'role' => 'user',
|
||||
'password' => Hash::make($validated['password']),
|
||||
@@ -226,10 +227,13 @@ class CheckoutController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$session = $sessions->createOrResume($user, $package, [
|
||||
'tenant' => $user->tenant,
|
||||
'locale' => $validated['locale'] ?? null,
|
||||
]);
|
||||
$session = $sessions->createOrResume($user, $package, array_merge(
|
||||
CheckoutRequestContext::fromRequest($request),
|
||||
[
|
||||
'tenant' => $user->tenant,
|
||||
'locale' => $validated['locale'] ?? null,
|
||||
]
|
||||
));
|
||||
|
||||
$sessions->selectProvider($session, CheckoutSession::PROVIDER_FREE);
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use App\Support\CheckoutRoutes;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -150,7 +152,7 @@ class MarketingController extends Controller
|
||||
$couponCode = $this->rememberCouponFromRequest($request, $package);
|
||||
|
||||
if (! Auth::check()) {
|
||||
return redirect()->route('register', ['package_id' => $package->id, 'coupon' => $couponCode])
|
||||
return redirect()->to(CheckoutRoutes::wizardUrl($package->id, $locale))
|
||||
->with('message', __('marketing.packages.register_required'));
|
||||
}
|
||||
|
||||
@@ -203,9 +205,12 @@ class MarketingController extends Controller
|
||||
->with('error', __('marketing.packages.paddle_not_configured'));
|
||||
}
|
||||
|
||||
$session = $this->checkoutSessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
$session = $this->checkoutSessions->createOrResume($user, $package, array_merge(
|
||||
CheckoutRequestContext::fromRequest($request),
|
||||
[
|
||||
'tenant' => $tenant,
|
||||
]
|
||||
));
|
||||
|
||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Package;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -38,9 +39,12 @@ class PaddleCheckoutController extends Controller
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
}
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
$session = $this->sessions->createOrResume($user, $package, array_merge(
|
||||
CheckoutRequestContext::fromRequest($request),
|
||||
[
|
||||
'tenant' => $tenant,
|
||||
]
|
||||
));
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Addons\EventAddonWebhookService;
|
||||
use App\Services\Checkout\CheckoutWebhookService;
|
||||
use App\Services\Integrations\IntegrationWebhookRecorder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -14,6 +15,7 @@ class PaddleWebhookController extends Controller
|
||||
public function __construct(
|
||||
private readonly CheckoutWebhookService $webhooks,
|
||||
private readonly EventAddonWebhookService $addonWebhooks,
|
||||
private readonly IntegrationWebhookRecorder $recorder,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): JsonResponse
|
||||
@@ -32,6 +34,12 @@ class PaddleWebhookController extends Controller
|
||||
}
|
||||
|
||||
$eventType = $payload['event_type'] ?? null;
|
||||
$eventId = $payload['event_id'] ?? $payload['id'] ?? data_get($payload, 'data.id');
|
||||
$webhookEvent = $this->recorder->recordReceived(
|
||||
'paddle',
|
||||
$eventId ? (string) $eventId : null,
|
||||
$eventType ? (string) $eventType : null,
|
||||
);
|
||||
$handled = false;
|
||||
|
||||
$this->logDev('Paddle webhook received', [
|
||||
@@ -53,6 +61,9 @@ class PaddleWebhookController extends Controller
|
||||
]);
|
||||
|
||||
$statusCode = $handled ? Response::HTTP_OK : Response::HTTP_ACCEPTED;
|
||||
$handled
|
||||
? $this->recorder->markProcessed($webhookEvent, ['handled' => true])
|
||||
: $this->recorder->markIgnored($webhookEvent, ['handled' => false]);
|
||||
|
||||
return response()->json([
|
||||
'status' => $handled ? 'processed' : 'ignored',
|
||||
@@ -68,6 +79,10 @@ class PaddleWebhookController extends Controller
|
||||
|
||||
$this->logDev('Paddle webhook error payload', $this->reducePayload($request->json()->all()));
|
||||
|
||||
if (isset($webhookEvent)) {
|
||||
$this->recorder->markFailed($webhookEvent, $exception->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'error'], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\DataExportScope;
|
||||
use App\Models\DataExport;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -45,6 +46,7 @@ class ProfileController extends Controller
|
||||
->all();
|
||||
|
||||
$recentExports = $user->dataExports()
|
||||
->where('scope', DataExportScope::USER->value)
|
||||
->latest()
|
||||
->limit(5)
|
||||
->get()
|
||||
@@ -61,6 +63,7 @@ class ProfileController extends Controller
|
||||
]);
|
||||
|
||||
$pendingExport = $user->dataExports()
|
||||
->where('scope', DataExportScope::USER->value)
|
||||
->whereIn('status', [
|
||||
DataExport::STATUS_PENDING,
|
||||
DataExport::STATUS_PROCESSING,
|
||||
@@ -68,6 +71,7 @@ class ProfileController extends Controller
|
||||
->exists();
|
||||
|
||||
$lastReadyExport = $user->dataExports()
|
||||
->where('scope', DataExportScope::USER->value)
|
||||
->where('status', DataExport::STATUS_READY)
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\DataExportScope;
|
||||
use App\Jobs\GenerateDataExport;
|
||||
use App\Models\DataExport;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -17,6 +18,7 @@ class ProfileDataExportController extends Controller
|
||||
abort_unless($user, 403);
|
||||
|
||||
$hasRecentExport = $user->dataExports()
|
||||
->where('scope', DataExportScope::USER->value)
|
||||
->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING])
|
||||
->exists();
|
||||
|
||||
@@ -25,6 +27,7 @@ class ProfileDataExportController extends Controller
|
||||
}
|
||||
|
||||
$recentReadyExport = $user->dataExports()
|
||||
->where('scope', DataExportScope::USER->value)
|
||||
->where('status', DataExport::STATUS_READY)
|
||||
->where('created_at', '>=', now()->subDay())
|
||||
->exists();
|
||||
@@ -36,6 +39,8 @@ class ProfileDataExportController extends Controller
|
||||
$export = $user->dataExports()->create([
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'status' => DataExport::STATUS_PENDING,
|
||||
'scope' => DataExportScope::USER->value,
|
||||
'include_media' => false,
|
||||
]);
|
||||
|
||||
GenerateDataExport::dispatch($export->id);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ProcessRevenueCatWebhook;
|
||||
use App\Services\Integrations\IntegrationWebhookRecorder;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -11,6 +12,8 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RevenueCatWebhookController extends Controller
|
||||
{
|
||||
public function __construct(private readonly IntegrationWebhookRecorder $recorder) {}
|
||||
|
||||
public function handle(Request $request): JsonResponse
|
||||
{
|
||||
$secret = (string) config('services.revenuecat.webhook', '');
|
||||
@@ -61,9 +64,18 @@ class RevenueCatWebhookController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
$eventId = (string) $request->header('X-Event-Id', '');
|
||||
$eventType = data_get($decoded, 'event.type');
|
||||
$webhookEvent = $this->recorder->recordReceived(
|
||||
'revenuecat',
|
||||
$eventId !== '' ? $eventId : null,
|
||||
is_string($eventType) && $eventType !== '' ? $eventType : null,
|
||||
);
|
||||
|
||||
ProcessRevenueCatWebhook::dispatch(
|
||||
$decoded,
|
||||
(string) $request->header('X-Event-Id', '')
|
||||
$eventId,
|
||||
$webhookEvent->id,
|
||||
);
|
||||
|
||||
return response()->json(['status' => 'accepted'], 202);
|
||||
|
||||
32
app/Http/Controllers/SuperAdmin/DataExportController.php
Normal file
32
app/Http/Controllers/SuperAdmin/DataExportController.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SuperAdmin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DataExport;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class DataExportController extends Controller
|
||||
{
|
||||
public function download(DataExport $export): StreamedResponse
|
||||
{
|
||||
if (! $export->isReady() || $export->hasExpired() || ! $export->path) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$disk = 'local';
|
||||
|
||||
if (! Storage::disk($disk)->exists($export->path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return Storage::disk($disk)->download(
|
||||
$export->path,
|
||||
sprintf('fotospiel-data-export-%s.zip', $export->created_at?->format('Ymd') ?? now()->format('Ymd')),
|
||||
[
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class TenantAdminAuthController extends Controller
|
||||
$user = Auth::user();
|
||||
|
||||
// Allow only tenant_admin and super_admin
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'super_admin'])) {
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||
return view('admin');
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class TenantAdminGoogleController extends Controller
|
||||
/** @var User|null $user */
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||
return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.');
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class TestCheckoutController extends Controller
|
||||
{
|
||||
public function latest(Request $request): JsonResponse
|
||||
{
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
abort_unless(config('e2e.testing_enabled'), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => ['nullable', 'string', 'email'],
|
||||
@@ -66,7 +66,7 @@ class TestCheckoutController extends Controller
|
||||
CheckoutWebhookService $webhooks,
|
||||
CheckoutSession $session
|
||||
): JsonResponse {
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
abort_unless(config('e2e.testing_enabled'), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'event_type' => ['nullable', 'string'],
|
||||
|
||||
@@ -16,7 +16,7 @@ class TestCouponController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
abort_unless(config('e2e.testing_enabled'), 404);
|
||||
|
||||
$payload = $request->input('coupons');
|
||||
$definitions = collect(is_array($payload) ? $payload : [])
|
||||
|
||||
@@ -15,7 +15,7 @@ class TestEventController extends Controller
|
||||
{
|
||||
public function joinToken(Request $request, EventJoinTokenService $tokens): JsonResponse
|
||||
{
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
abort_unless(config('e2e.testing_enabled'), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'event_id' => ['nullable', 'integer'],
|
||||
|
||||
@@ -20,7 +20,7 @@ class TestGuestEventController extends Controller
|
||||
{
|
||||
public function store(Request $request, EventJoinTokenService $joinTokens): JsonResponse
|
||||
{
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
abort_unless(config('e2e.testing_enabled'), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'slug' => ['nullable', 'string', 'max:100'],
|
||||
|
||||
@@ -10,7 +10,7 @@ class TestMailboxController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
abort_unless(config('e2e.testing_enabled'), 404);
|
||||
|
||||
return response()->json([
|
||||
'data' => Mailbox::all(),
|
||||
@@ -19,7 +19,7 @@ class TestMailboxController extends Controller
|
||||
|
||||
public function destroy(): JsonResponse
|
||||
{
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
abort_unless(config('e2e.testing_enabled'), 404);
|
||||
|
||||
Mailbox::flush();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\CheckoutRoutes;
|
||||
use Illuminate\Auth\Middleware\Authenticate as Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -14,7 +15,10 @@ class Authenticate extends Middleware
|
||||
}
|
||||
|
||||
if ($request->routeIs('buy.packages') && $request->route('packageId')) {
|
||||
return route('register', ['package_id' => $request->route('packageId')]);
|
||||
return CheckoutRoutes::wizardUrl(
|
||||
$request->route('packageId'),
|
||||
$request->route('locale')
|
||||
);
|
||||
}
|
||||
|
||||
return route('login');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Packages\PackageLimitEvaluator;
|
||||
use App\Support\ApiError;
|
||||
use Closure;
|
||||
@@ -26,7 +27,7 @@ class CreditCheckMiddleware
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->requiresCredits($request)) {
|
||||
if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
|
||||
$violation = $this->limitEvaluator->assessEventCreation($tenant);
|
||||
|
||||
if ($violation !== null) {
|
||||
@@ -43,6 +44,24 @@ class CreditCheckMiddleware
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function shouldBypassCreditCheck(Request $request, Tenant $tenant): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->isSuperAdmin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->tenant_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $user->tenant_id === (int) $tenant->id;
|
||||
}
|
||||
|
||||
private function requiresCredits(Request $request): bool
|
||||
{
|
||||
return $request->isMethod('post')
|
||||
|
||||
24
app/Http/Middleware/EnsureE2ETestingAccess.php
Normal file
24
app/Http/Middleware/EnsureE2ETestingAccess.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureE2ETestingAccess
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! config('e2e.testing_enabled')) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$token = config('e2e.testing_token');
|
||||
if ($token && $request->header('X-Testing-Token') !== $token) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user