Compare commits
121 Commits
8b445ae998
...
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 |
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
fotospiel-app-38f
|
fotospiel-app-29r
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -13,6 +13,8 @@ fotospiel-tenant-app
|
|||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/vendor
|
/vendor
|
||||||
|
/clients/photobooth-uploader/**/bin
|
||||||
|
/clients/photobooth-uploader/**/obj
|
||||||
.env
|
.env
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.env.production
|
||||||
@@ -23,11 +25,9 @@ Homestead.yaml
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
/auth.json
|
/auth.json
|
||||||
/.fleet
|
|
||||||
/.idea
|
|
||||||
/.nova
|
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
|
||||||
tools/git-askpass.ps1
|
|
||||||
podman-compose.dev.yml
|
|
||||||
test-results
|
test-results
|
||||||
|
GEMINI.md
|
||||||
|
.beads/.sync.lock
|
||||||
|
.beads/daemon-error
|
||||||
|
.beads/sync_base.jsonl
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,9 +203,20 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
|
|
||||||
$this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel');
|
$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(
|
$event = $this->upsertEvent(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
package: $packages['starter'],
|
package: $packages['standard'],
|
||||||
eventType: $eventTypes['wedding'] ?? null,
|
eventType: $eventTypes['wedding'] ?? null,
|
||||||
attributes: [
|
attributes: [
|
||||||
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],
|
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],
|
||||||
|
|||||||
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';
|
||||||
|
}
|
||||||
@@ -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})";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use Filament\Schemas\Schema;
|
|||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class RedemptionsRelationManager extends RelationManager
|
class RedemptionsRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
@@ -25,6 +26,30 @@ class RedemptionsRelationManager extends RelationManager
|
|||||||
TextColumn::make('tenant.name')
|
TextColumn::make('tenant.name')
|
||||||
->label(__('Tenant'))
|
->label(__('Tenant'))
|
||||||
->searchable(),
|
->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')
|
TextColumn::make('user.name')
|
||||||
->label(__('User'))
|
->label(__('User'))
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
@@ -69,4 +94,30 @@ class RedemptionsRelationManager extends RelationManager
|
|||||||
->recordActions([])
|
->recordActions([])
|
||||||
->toolbarActions([]);
|
->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;
|
namespace App\Filament\Resources\Coupons\Schemas;
|
||||||
|
|
||||||
|
use App\Enums\CouponStatus;
|
||||||
|
use App\Enums\CouponType;
|
||||||
use Filament\Infolists\Components\KeyValueEntry;
|
use Filament\Infolists\Components\KeyValueEntry;
|
||||||
use Filament\Infolists\Components\Section;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@@ -22,11 +24,11 @@ class CouponInfolist
|
|||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
->label(__('Status'))
|
->label(__('Status'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
|
||||||
TextEntry::make('type')
|
TextEntry::make('type')
|
||||||
->label(__('Discount type'))
|
->label(__('Discount type'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
|
||||||
TextEntry::make('amount')
|
TextEntry::make('amount')
|
||||||
->label(__('Amount'))
|
->label(__('Amount'))
|
||||||
->formatStateUsing(fn ($state, $record) => $record?->type?->value === 'percentage'
|
->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')
|
TextColumn::make('type')
|
||||||
->label(__('Type'))
|
->label(__('Type'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn ($state) => Str::headline($state))
|
->formatStateUsing(fn ($state) => static::formatEnumState($state))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('amount')
|
TextColumn::make('amount')
|
||||||
->label(__('Amount'))
|
->label(__('Amount'))
|
||||||
@@ -59,7 +59,7 @@ class CouponsTable
|
|||||||
->label(__('Status'))
|
->label(__('Status'))
|
||||||
->badge()
|
->badge()
|
||||||
->sortable()
|
->sortable()
|
||||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
|
||||||
TextColumn::make('starts_at')
|
TextColumn::make('starts_at')
|
||||||
->label(__('Starts'))
|
->label(__('Starts'))
|
||||||
->date()
|
->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 '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\DataExportResource\Tables;
|
namespace App\Filament\Resources\DataExportResource\Tables;
|
||||||
|
|
||||||
|
use App\Enums\DataExportScope;
|
||||||
|
use App\Jobs\GenerateDataExport;
|
||||||
use App\Models\DataExport;
|
use App\Models\DataExport;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
use Filament\Tables\Columns\IconColumn;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
@@ -12,6 +15,15 @@ use Illuminate\Support\Number;
|
|||||||
|
|
||||||
class DataExportTable
|
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
|
public static function configure(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@@ -29,7 +41,7 @@ class DataExportTable
|
|||||||
TextColumn::make('scope')
|
TextColumn::make('scope')
|
||||||
->label(__('admin.data_exports.fields.scope'))
|
->label(__('admin.data_exports.fields.scope'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (?string $state) => $state ? __('admin.data_exports.scope.'.$state) : '—'),
|
->formatStateUsing(fn (DataExportScope|string|null $state): string => self::formatScope($state)),
|
||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
->label(__('admin.data_exports.fields.status'))
|
->label(__('admin.data_exports.fields.status'))
|
||||||
->badge()
|
->badge()
|
||||||
@@ -38,6 +50,7 @@ class DataExportTable
|
|||||||
DataExport::STATUS_READY => 'success',
|
DataExport::STATUS_READY => 'success',
|
||||||
DataExport::STATUS_FAILED => 'danger',
|
DataExport::STATUS_FAILED => 'danger',
|
||||||
DataExport::STATUS_PROCESSING => 'warning',
|
DataExport::STATUS_PROCESSING => 'warning',
|
||||||
|
DataExport::STATUS_CANCELED => 'gray',
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
}),
|
}),
|
||||||
IconColumn::make('include_media')
|
IconColumn::make('include_media')
|
||||||
@@ -71,6 +84,7 @@ class DataExportTable
|
|||||||
DataExport::STATUS_PROCESSING => __('admin.data_exports.status.processing'),
|
DataExport::STATUS_PROCESSING => __('admin.data_exports.status.processing'),
|
||||||
DataExport::STATUS_READY => __('admin.data_exports.status.ready'),
|
DataExport::STATUS_READY => __('admin.data_exports.status.ready'),
|
||||||
DataExport::STATUS_FAILED => __('admin.data_exports.status.failed'),
|
DataExport::STATUS_FAILED => __('admin.data_exports.status.failed'),
|
||||||
|
DataExport::STATUS_CANCELED => __('admin.data_exports.status.canceled'),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
@@ -80,6 +94,45 @@ class DataExportTable
|
|||||||
->url(fn (DataExport $record) => route('superadmin.data-exports.download', $record))
|
->url(fn (DataExport $record) => route('superadmin.data-exports.download', $record))
|
||||||
->openUrlInNewTab()
|
->openUrlInNewTab()
|
||||||
->visible(fn (DataExport $record): bool => $record->isReady() && ! $record->hasExpired()),
|
->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([]);
|
->bulkActions([]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ class PackageResource extends Resource
|
|||||||
->colors([
|
->colors([
|
||||||
'success' => 'synced',
|
'success' => 'synced',
|
||||||
'warning' => 'syncing',
|
'warning' => 'syncing',
|
||||||
'info' => 'dry-run',
|
'info' => ['dry-run', 'linked', 'pulled'],
|
||||||
'danger' => ['failed', 'pull-failed'],
|
'danger' => ['failed', 'pull-failed'],
|
||||||
])
|
])
|
||||||
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
|
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
|
||||||
@@ -316,6 +316,42 @@ class PackageResource extends Resource
|
|||||||
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.')
|
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.')
|
||||||
->send();
|
->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')
|
Actions\Action::make('pullPaddle')
|
||||||
->label('Status von Paddle holen')
|
->label('Status von Paddle holen')
|
||||||
->icon('heroicon-o-cloud-arrow-down')
|
->icon('heroicon-o-cloud-arrow-down')
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class Login extends BaseLogin implements HasForms
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
|
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
|
||||||
if ($user->role !== 'super_admin') {
|
if (! $user->isSuperAdmin()) {
|
||||||
$authGuard->logout();
|
$authGuard->logout();
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
|
|||||||
@@ -45,14 +45,16 @@ class GuestPolicySettingsPage extends Page
|
|||||||
|
|
||||||
public int $join_token_failure_decay_minutes = 5;
|
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_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_download_decay_minutes = 1;
|
||||||
|
|
||||||
|
public int $join_token_ttl_hours = 168;
|
||||||
|
|
||||||
public int $share_link_ttl_hours = 48;
|
public int $share_link_ttl_hours = 48;
|
||||||
|
|
||||||
public ?int $guest_notification_ttl_hours = null;
|
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->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_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_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_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_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->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);
|
||||||
$this->guest_notification_ttl_hours = $settings->guest_notification_ttl_hours;
|
$this->guest_notification_ttl_hours = $settings->guest_notification_ttl_hours;
|
||||||
}
|
}
|
||||||
@@ -130,6 +133,11 @@ class GuestPolicySettingsPage extends Page
|
|||||||
->columns(2),
|
->columns(2),
|
||||||
Section::make(__('admin.guest_policy.sections.retention'))
|
Section::make(__('admin.guest_policy.sections.retention'))
|
||||||
->schema([
|
->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')
|
Forms\Components\TextInput::make('share_link_ttl_hours')
|
||||||
->label(__('admin.guest_policy.fields.share_link_ttl_hours'))
|
->label(__('admin.guest_policy.fields.share_link_ttl_hours'))
|
||||||
->numeric()
|
->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_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_limit = (int) $this->join_token_download_limit;
|
||||||
$settings->join_token_download_decay_minutes = (int) $this->join_token_download_decay_minutes;
|
$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->share_link_ttl_hours = (int) $this->share_link_ttl_hours;
|
||||||
$settings->guest_notification_ttl_hours = $this->guest_notification_ttl_hours;
|
$settings->guest_notification_ttl_hours = $this->guest_notification_ttl_hours;
|
||||||
$settings->save();
|
$settings->save();
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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\GuestNotificationDeliveryStatus;
|
||||||
use App\Enums\GuestNotificationState;
|
use App\Enums\GuestNotificationState;
|
||||||
use App\Enums\GuestNotificationType;
|
use App\Enums\GuestNotificationType;
|
||||||
|
use App\Enums\PhotoLiveStatus;
|
||||||
use App\Events\GuestPhotoUploaded;
|
use App\Events\GuestPhotoUploaded;
|
||||||
use App\Jobs\ProcessPhotoSecurityScan;
|
use App\Jobs\ProcessPhotoSecurityScan;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
@@ -1899,6 +1900,8 @@ class EventPublicController extends BaseController
|
|||||||
$branding = $this->buildGalleryBranding($event);
|
$branding = $this->buildGalleryBranding($event);
|
||||||
$settings = $this->normalizeSettings($event->settings ?? []);
|
$settings = $this->normalizeSettings($event->settings ?? []);
|
||||||
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
|
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
|
||||||
|
$liveShowSettings = Arr::get($settings, 'live_show', []);
|
||||||
|
$liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : [];
|
||||||
$event->loadMissing('photoboothSetting');
|
$event->loadMissing('photoboothSetting');
|
||||||
$policy = $this->guestPolicy();
|
$policy = $this->guestPolicy();
|
||||||
|
|
||||||
@@ -1921,6 +1924,9 @@ class EventPublicController extends BaseController
|
|||||||
'photobooth_enabled' => (bool) ($event->photoboothSetting?->enabled),
|
'photobooth_enabled' => (bool) ($event->photoboothSetting?->enabled),
|
||||||
'branding' => $branding,
|
'branding' => $branding,
|
||||||
'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility),
|
'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,
|
'engagement_mode' => $engagementMode,
|
||||||
])->header('Cache-Control', 'no-store');
|
])->header('Cache-Control', 'no-store');
|
||||||
}
|
}
|
||||||
@@ -2987,6 +2993,7 @@ class EventPublicController extends BaseController
|
|||||||
'emotion_slug' => ['nullable', 'string'],
|
'emotion_slug' => ['nullable', 'string'],
|
||||||
'task_id' => ['nullable', 'integer'],
|
'task_id' => ['nullable', 'integer'],
|
||||||
'guest_name' => ['nullable', 'string', 'max:255'],
|
'guest_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'live_show_opt_in' => ['nullable', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$file = $validated['photo'];
|
$file = $validated['photo'];
|
||||||
@@ -3022,6 +3029,26 @@ class EventPublicController extends BaseController
|
|||||||
$url = $this->resolveDiskUrl($disk, $watermarkedPath);
|
$url = $this->resolveDiskUrl($disk, $watermarkedPath);
|
||||||
$thumbUrl = $this->resolveDiskUrl($disk, $watermarkedThumb);
|
$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([
|
$photoId = DB::table('photos')->insertGetId([
|
||||||
'event_id' => $eventId,
|
'event_id' => $eventId,
|
||||||
'tenant_id' => $tenantModel->id,
|
'tenant_id' => $tenantModel->id,
|
||||||
@@ -3033,6 +3060,12 @@ class EventPublicController extends BaseController
|
|||||||
'likes_count' => 0,
|
'likes_count' => 0,
|
||||||
'ingest_source' => Photo::SOURCE_GUEST_PWA,
|
'ingest_source' => Photo::SOURCE_GUEST_PWA,
|
||||||
'status' => $autoApproveUploads ? 'approved' : 'pending',
|
'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
|
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
|
||||||
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
|
'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;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Checkout\CheckoutSessionStatusRequest;
|
||||||
|
use App\Models\CheckoutSession;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
|
use App\Services\Checkout\CheckoutSessionService;
|
||||||
use App\Services\Paddle\PaddleCheckoutService;
|
use App\Services\Paddle\PaddleCheckoutService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -14,7 +17,10 @@ use Illuminate\Validation\ValidationException;
|
|||||||
|
|
||||||
class PackageController extends Controller
|
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
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -165,23 +171,82 @@ class PackageController extends Controller
|
|||||||
|
|
||||||
$package = Package::findOrFail($request->integer('package_id'));
|
$package = Package::findOrFail($request->integer('package_id'));
|
||||||
$tenant = $request->attributes->get('tenant');
|
$tenant = $request->attributes->get('tenant');
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
throw ValidationException::withMessages(['user' => 'User context missing.']);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $package->paddle_price_id) {
|
if (! $package->paddle_price_id) {
|
||||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
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 = [
|
$payload = [
|
||||||
'success_url' => $request->input('success_url'),
|
'success_url' => $request->input('success_url'),
|
||||||
'return_url' => $request->input('return_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);
|
$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
|
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'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
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\PackagePurchase;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -88,12 +89,15 @@ class EventController extends Controller
|
|||||||
$tenant = Tenant::findOrFail($tenantId);
|
$tenant = Tenant::findOrFail($tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$actor = $request->user();
|
||||||
|
$isSuperAdmin = $actor instanceof User && $actor->isSuperAdmin();
|
||||||
|
|
||||||
// Package check is now handled by middleware
|
// Package check is now handled by middleware
|
||||||
|
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$tenantId = $tenant->id;
|
$tenantId = $tenant->id;
|
||||||
|
|
||||||
$requestedPackageId = $validated['package_id'] ?? null;
|
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
|
||||||
unset($validated['package_id']);
|
unset($validated['package_id']);
|
||||||
|
|
||||||
$tenantPackage = $tenant->tenantPackages()
|
$tenantPackage = $tenant->tenantPackages()
|
||||||
@@ -108,6 +112,10 @@ class EventController extends Controller
|
|||||||
$package = Package::query()->find($requestedPackageId);
|
$package = Package::query()->find($requestedPackageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $package && $isSuperAdmin) {
|
||||||
|
$package = $this->resolveOwnerPackage();
|
||||||
|
}
|
||||||
|
|
||||||
if (! $package && $tenantPackage) {
|
if (! $package && $tenantPackage) {
|
||||||
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
||||||
}
|
}
|
||||||
@@ -121,7 +129,7 @@ class EventController extends Controller
|
|||||||
$requiresWaiver = $package->isEndcustomer();
|
$requiresWaiver = $package->isEndcustomer();
|
||||||
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
||||||
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : 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')) {
|
if ($needsWaiver && ! $request->boolean('accepted_waiver')) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -182,7 +190,7 @@ class EventController extends Controller
|
|||||||
|
|
||||||
$eventData = Arr::only($eventData, $allowed);
|
$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);
|
$event = Event::create($eventData);
|
||||||
|
|
||||||
EventPackage::create([
|
EventPackage::create([
|
||||||
@@ -193,7 +201,7 @@ class EventController extends Controller
|
|||||||
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
|
'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);
|
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
||||||
|
|
||||||
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
||||||
@@ -229,6 +237,15 @@ class EventController extends Controller
|
|||||||
->first();
|
->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
|
private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void
|
||||||
{
|
{
|
||||||
$timestamp = now();
|
$timestamp = now();
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class EventMemberController extends Controller
|
|||||||
$user->password = Hash::make(Str::random(32));
|
$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([
|
throw ValidationException::withMessages([
|
||||||
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
|
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
|
||||||
]);
|
]);
|
||||||
@@ -143,9 +143,9 @@ class EventMemberController extends Controller
|
|||||||
|
|
||||||
$user->tenant_id = $tenant->id;
|
$user->tenant_id = $tenant->id;
|
||||||
|
|
||||||
if ($role === 'tenant_admin' && $user->role !== 'super_admin') {
|
if ($role === 'tenant_admin' && ! $user->isSuperAdmin()) {
|
||||||
$user->role = 'tenant_admin';
|
$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';
|
$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'),
|
'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'),
|
||||||
'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'),
|
'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'),
|
||||||
'selected_packages' => Arr::get($settings, 'onboarding.selected_packages'),
|
'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'),
|
'dismissed_at' => Arr::get($settings, 'onboarding.dismissed_at'),
|
||||||
'completed_at' => Arr::get($settings, 'onboarding.completed_at'),
|
'completed_at' => Arr::get($settings, 'onboarding.completed_at'),
|
||||||
'branding_completed' => (bool) ($status['palette'] ?? false),
|
'branding_completed' => (bool) ($status['palette'] ?? false),
|
||||||
@@ -86,6 +88,11 @@ class OnboardingController extends Controller
|
|||||||
Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String());
|
Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String());
|
||||||
break;
|
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':
|
case 'dismissed':
|
||||||
Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String());
|
Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String());
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -525,13 +525,13 @@ class PhotoController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Only tenant admins can moderate
|
// 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(
|
return ApiError::response(
|
||||||
'insufficient_scope',
|
'insufficient_scope',
|
||||||
'Insufficient Scopes',
|
'Insufficient Scopes',
|
||||||
'You are not allowed to moderate photos for this event.',
|
'You are not allowed to moderate photos for this event.',
|
||||||
Response::HTTP_FORBIDDEN,
|
Response::HTTP_FORBIDDEN,
|
||||||
['required_scope' => 'tenant:write']
|
['required_scope' => 'tenant-admin']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,6 +823,11 @@ class PhotoController extends Controller
|
|||||||
|
|
||||||
private function tokenHasScope(Request $request, string $scope): bool
|
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'] ?? []);
|
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
|
||||||
|
|
||||||
if (! is_array($scopes)) {
|
if (! is_array($scopes)) {
|
||||||
|
|||||||
@@ -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;
|
$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';
|
$abilities[] = 'tenant-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user->role === 'super_admin') {
|
if ($user->isSuperAdmin()) {
|
||||||
$abilities[] = 'super-admin';
|
$abilities[] = 'super-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ class TenantAdminTokenController extends Controller
|
|||||||
|
|
||||||
private function ensureUserCanAccessPanel(User $user): void
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use App\Models\User;
|
|||||||
use App\Notifications\TenantFeedbackSubmitted;
|
use App\Notifications\TenantFeedbackSubmitted;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class TenantFeedbackController extends Controller
|
class TenantFeedbackController extends Controller
|
||||||
{
|
{
|
||||||
@@ -56,7 +56,7 @@ class TenantFeedbackController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$recipients = User::query()
|
$recipients = User::query()
|
||||||
->where('role', 'super_admin')
|
->whereIn('role', ['super_admin', 'superadmin'])
|
||||||
->whereNotNull('email')
|
->whereNotNull('email')
|
||||||
->get();
|
->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;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\EventPackage;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -29,23 +30,108 @@ class TenantPackageController extends Controller
|
|||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$packages->each(function ($package) {
|
$usageEventPackage = $this->resolveUsageEventPackage($tenant->id);
|
||||||
$pkg = $package->package;
|
|
||||||
$package->remaining_events = $pkg->max_events_per_year - $package->used_events;
|
$packages->each(function (TenantPackage $package) use ($usageEventPackage): void {
|
||||||
$package->package_limits = array_merge(
|
$eventPackage = $package->active ? $usageEventPackage : null;
|
||||||
$pkg->limits,
|
$this->hydratePackageSnapshot($package, $eventPackage);
|
||||||
[
|
|
||||||
'branding_allowed' => $pkg->branding_allowed,
|
|
||||||
'watermark_allowed' => $pkg->watermark_allowed,
|
|
||||||
'features' => $pkg->features,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$activePackage = $tenant->activeResellerPackage?->load('package');
|
||||||
|
|
||||||
|
if ($activePackage instanceof TenantPackage) {
|
||||||
|
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $packages,
|
'data' => $packages,
|
||||||
'active_package' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->load('package') : null,
|
'active_package' => $activePackage,
|
||||||
'message' => 'Tenant packages loaded successfully.',
|
'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
|
// Super admins go to Filament superadmin panel
|
||||||
if ($user && $user->role === 'super_admin') {
|
if ($user && $user->isSuperAdmin()) {
|
||||||
return '/admin';
|
return '/super-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tenant admins go to their PWA dashboard
|
// Tenant admins go to their PWA dashboard
|
||||||
|
|||||||
@@ -3,153 +3,47 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Tenant;
|
use App\Support\LocaleConfig;
|
||||||
use App\Models\User;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
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
|
class RegisteredUserController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Show the registration page.
|
* 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 $this->redirectToPackages($request);
|
||||||
|
|
||||||
return Inertia::render('auth/register', [
|
|
||||||
'package' => $package,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an incoming registration 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);
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
$validated = $request->validate([
|
'message' => 'Registration is only available during checkout.',
|
||||||
'username' => ['required', 'string', 'max:255', 'unique:'.User::class],
|
], 410);
|
||||||
'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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::create([
|
return $this->redirectToPackages($request);
|
||||||
'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',
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (! $user->tenant_id) {
|
private function redirectToPackages(Request $request): RedirectResponse
|
||||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
{
|
||||||
|
$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));
|
return redirect()->route('packages', $routeParams);
|
||||||
|
|
||||||
// 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'));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use App\Services\Checkout\CheckoutAssignmentService;
|
|||||||
use App\Services\Checkout\CheckoutSessionService;
|
use App\Services\Checkout\CheckoutSessionService;
|
||||||
use App\Services\Paddle\Exceptions\PaddleException;
|
use App\Services\Paddle\Exceptions\PaddleException;
|
||||||
use App\Services\Paddle\PaddleTransactionService;
|
use App\Services\Paddle\PaddleTransactionService;
|
||||||
|
use App\Support\CheckoutRequestContext;
|
||||||
use App\Support\CheckoutRoutes;
|
use App\Support\CheckoutRoutes;
|
||||||
use App\Support\Concerns\PresentsPackages;
|
use App\Support\Concerns\PresentsPackages;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -81,12 +82,12 @@ class CheckoutController extends Controller
|
|||||||
// User erstellen
|
// User erstellen
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'email' => $validated['email'],
|
'email' => $validated['email'],
|
||||||
'username' => $validated['username'],
|
'username' => Str::lower($validated['email']),
|
||||||
'first_name' => $validated['first_name'],
|
'first_name' => $validated['first_name'],
|
||||||
'last_name' => $validated['last_name'],
|
'last_name' => $validated['last_name'],
|
||||||
'name' => trim($validated['first_name'].' '.$validated['last_name']),
|
'name' => trim($validated['first_name'].' '.$validated['last_name']),
|
||||||
'address' => $validated['address'],
|
'address' => $validated['address'] ?? null,
|
||||||
'phone' => $validated['phone'],
|
'phone' => $validated['phone'] ?? null,
|
||||||
'preferred_locale' => $validated['locale'] ?? null,
|
'preferred_locale' => $validated['locale'] ?? null,
|
||||||
'role' => 'user',
|
'role' => 'user',
|
||||||
'password' => Hash::make($validated['password']),
|
'password' => Hash::make($validated['password']),
|
||||||
@@ -226,10 +227,13 @@ class CheckoutController extends Controller
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$session = $sessions->createOrResume($user, $package, [
|
$session = $sessions->createOrResume($user, $package, array_merge(
|
||||||
'tenant' => $user->tenant,
|
CheckoutRequestContext::fromRequest($request),
|
||||||
'locale' => $validated['locale'] ?? null,
|
[
|
||||||
]);
|
'tenant' => $user->tenant,
|
||||||
|
'locale' => $validated['locale'] ?? null,
|
||||||
|
]
|
||||||
|
));
|
||||||
|
|
||||||
$sessions->selectProvider($session, CheckoutSession::PROVIDER_FREE);
|
$sessions->selectProvider($session, CheckoutSession::PROVIDER_FREE);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ use App\Services\Checkout\CheckoutSessionService;
|
|||||||
use App\Services\Coupons\CouponService;
|
use App\Services\Coupons\CouponService;
|
||||||
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
||||||
use App\Services\Paddle\PaddleCheckoutService;
|
use App\Services\Paddle\PaddleCheckoutService;
|
||||||
|
use App\Support\CheckoutRequestContext;
|
||||||
|
use App\Support\CheckoutRoutes;
|
||||||
use App\Support\Concerns\PresentsPackages;
|
use App\Support\Concerns\PresentsPackages;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -150,7 +152,7 @@ class MarketingController extends Controller
|
|||||||
$couponCode = $this->rememberCouponFromRequest($request, $package);
|
$couponCode = $this->rememberCouponFromRequest($request, $package);
|
||||||
|
|
||||||
if (! Auth::check()) {
|
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'));
|
->with('message', __('marketing.packages.register_required'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,9 +205,12 @@ class MarketingController extends Controller
|
|||||||
->with('error', __('marketing.packages.paddle_not_configured'));
|
->with('error', __('marketing.packages.paddle_not_configured'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$session = $this->checkoutSessions->createOrResume($user, $package, [
|
$session = $this->checkoutSessions->createOrResume($user, $package, array_merge(
|
||||||
'tenant' => $tenant,
|
CheckoutRequestContext::fromRequest($request),
|
||||||
]);
|
[
|
||||||
|
'tenant' => $tenant,
|
||||||
|
]
|
||||||
|
));
|
||||||
|
|
||||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\Package;
|
|||||||
use App\Services\Checkout\CheckoutSessionService;
|
use App\Services\Checkout\CheckoutSessionService;
|
||||||
use App\Services\Coupons\CouponService;
|
use App\Services\Coupons\CouponService;
|
||||||
use App\Services\Paddle\PaddleCheckoutService;
|
use App\Services\Paddle\PaddleCheckoutService;
|
||||||
|
use App\Support\CheckoutRequestContext;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Str;
|
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.']);
|
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$session = $this->sessions->createOrResume($user, $package, [
|
$session = $this->sessions->createOrResume($user, $package, array_merge(
|
||||||
'tenant' => $tenant,
|
CheckoutRequestContext::fromRequest($request),
|
||||||
]);
|
[
|
||||||
|
'tenant' => $tenant,
|
||||||
|
]
|
||||||
|
));
|
||||||
|
|
||||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class TenantAdminAuthController extends Controller
|
|||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
// Allow only tenant_admin and super_admin
|
// 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');
|
return view('admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class TenantAdminGoogleController extends Controller
|
|||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
$user = User::query()->where('email', $email)->first();
|
$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.');
|
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
|
public function latest(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
abort_unless(config('e2e.testing_enabled'), 404);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'email' => ['nullable', 'string', 'email'],
|
'email' => ['nullable', 'string', 'email'],
|
||||||
@@ -66,7 +66,7 @@ class TestCheckoutController extends Controller
|
|||||||
CheckoutWebhookService $webhooks,
|
CheckoutWebhookService $webhooks,
|
||||||
CheckoutSession $session
|
CheckoutSession $session
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
abort_unless(config('e2e.testing_enabled'), 404);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'event_type' => ['nullable', 'string'],
|
'event_type' => ['nullable', 'string'],
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class TestCouponController extends Controller
|
|||||||
{
|
{
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
abort_unless(config('e2e.testing_enabled'), 404);
|
||||||
|
|
||||||
$payload = $request->input('coupons');
|
$payload = $request->input('coupons');
|
||||||
$definitions = collect(is_array($payload) ? $payload : [])
|
$definitions = collect(is_array($payload) ? $payload : [])
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class TestEventController extends Controller
|
|||||||
{
|
{
|
||||||
public function joinToken(Request $request, EventJoinTokenService $tokens): JsonResponse
|
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([
|
$validated = $request->validate([
|
||||||
'event_id' => ['nullable', 'integer'],
|
'event_id' => ['nullable', 'integer'],
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class TestGuestEventController extends Controller
|
|||||||
{
|
{
|
||||||
public function store(Request $request, EventJoinTokenService $joinTokens): JsonResponse
|
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([
|
$validated = $request->validate([
|
||||||
'slug' => ['nullable', 'string', 'max:100'],
|
'slug' => ['nullable', 'string', 'max:100'],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class TestMailboxController extends Controller
|
|||||||
{
|
{
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
abort_unless(config('e2e.testing_enabled'), 404);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => Mailbox::all(),
|
'data' => Mailbox::all(),
|
||||||
@@ -19,7 +19,7 @@ class TestMailboxController extends Controller
|
|||||||
|
|
||||||
public function destroy(): JsonResponse
|
public function destroy(): JsonResponse
|
||||||
{
|
{
|
||||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
abort_unless(config('e2e.testing_enabled'), 404);
|
||||||
|
|
||||||
Mailbox::flush();
|
Mailbox::flush();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Support\CheckoutRoutes;
|
||||||
use Illuminate\Auth\Middleware\Authenticate as Middleware;
|
use Illuminate\Auth\Middleware\Authenticate as Middleware;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
@@ -14,7 +15,10 @@ class Authenticate extends Middleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($request->routeIs('buy.packages') && $request->route('packageId')) {
|
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');
|
return route('login');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Packages\PackageLimitEvaluator;
|
use App\Services\Packages\PackageLimitEvaluator;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Closure;
|
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);
|
$violation = $this->limitEvaluator->assessEventCreation($tenant);
|
||||||
|
|
||||||
if ($violation !== null) {
|
if ($violation !== null) {
|
||||||
@@ -43,6 +44,24 @@ class CreditCheckMiddleware
|
|||||||
return $next($request);
|
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
|
private function requiresCredits(Request $request): bool
|
||||||
{
|
{
|
||||||
return $request->isMethod('post')
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ class EnsureTenantAdminToken
|
|||||||
/** @var Tenant|null $tenant */
|
/** @var Tenant|null $tenant */
|
||||||
$tenant = $user->tenant;
|
$tenant = $user->tenant;
|
||||||
|
|
||||||
if (! $tenant && $user->role === 'super_admin') {
|
if (! $tenant && $user->isSuperAdmin()) {
|
||||||
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
||||||
|
|
||||||
if ($requestedTenantId !== null) {
|
if ($requestedTenantId !== null) {
|
||||||
@@ -50,14 +50,14 @@ class EnsureTenantAdminToken
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $tenant && $user->role !== 'super_admin') {
|
if (! $tenant && ! $user->isSuperAdmin()) {
|
||||||
return $this->forbiddenResponse('Tenant context missing for user.');
|
return $this->forbiddenResponse('Tenant context missing for user.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tenant) {
|
if ($tenant) {
|
||||||
$request->attributes->set('tenant_id', $tenant->id);
|
$request->attributes->set('tenant_id', $tenant->id);
|
||||||
$request->attributes->set('tenant', $tenant);
|
$request->attributes->set('tenant', $tenant);
|
||||||
} elseif ($user->role === 'super_admin') {
|
} elseif ($user->isSuperAdmin()) {
|
||||||
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
||||||
if ($requestedTenantId !== null) {
|
if ($requestedTenantId !== null) {
|
||||||
$request->attributes->set('tenant_id', $requestedTenantId);
|
$request->attributes->set('tenant_id', $requestedTenantId);
|
||||||
@@ -96,7 +96,7 @@ class EnsureTenantAdminToken
|
|||||||
*/
|
*/
|
||||||
protected function allowedRoles(): array
|
protected function allowedRoles(): array
|
||||||
{
|
{
|
||||||
return ['tenant_admin', 'super_admin', 'admin'];
|
return ['tenant_admin', 'super_admin', 'superadmin', 'admin'];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function forbiddenRoleMessage(): string
|
protected function forbiddenRoleMessage(): string
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class EnsureTenantCollaboratorToken extends EnsureTenantAdminToken
|
|||||||
{
|
{
|
||||||
protected function allowedRoles(): array
|
protected function allowedRoles(): array
|
||||||
{
|
{
|
||||||
return ['tenant_admin', 'super_admin', 'admin', 'member'];
|
return ['tenant_admin', 'super_admin', 'superadmin', 'admin', 'member'];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function forbiddenRoleMessage(): string
|
protected function forbiddenRoleMessage(): string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Packages\PackageLimitEvaluator;
|
use App\Services\Packages\PackageLimitEvaluator;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Closure;
|
use Closure;
|
||||||
@@ -26,7 +27,7 @@ class PackageMiddleware
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->requiresPackageCheck($request)) {
|
if ($this->requiresPackageCheck($request) && ! $this->shouldBypassPackageCheck($request, $tenant)) {
|
||||||
$violation = $this->detectViolation($request, $tenant);
|
$violation = $this->detectViolation($request, $tenant);
|
||||||
|
|
||||||
if ($violation !== null) {
|
if ($violation !== null) {
|
||||||
@@ -43,6 +44,24 @@ class PackageMiddleware
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldBypassPackageCheck(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 requiresPackageCheck(Request $request): bool
|
private function requiresPackageCheck(Request $request): bool
|
||||||
{
|
{
|
||||||
return $request->isMethod('post') && (
|
return $request->isMethod('post') && (
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ use Closure;
|
|||||||
use Illuminate\Auth\Middleware\RedirectIfAuthenticated as BaseMiddleware;
|
use Illuminate\Auth\Middleware\RedirectIfAuthenticated as BaseMiddleware;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class RedirectIfAuthenticated extends BaseMiddleware
|
class RedirectIfAuthenticated extends BaseMiddleware
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Handle an incoming request.
|
* Handle an incoming request.
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next, ...$guards)
|
public function handle(Request $request, Closure $next, string ...$guards): Response
|
||||||
{
|
{
|
||||||
$guards = $guards === [] ? [null] : $guards;
|
$guards = $guards === [] ? [null] : $guards;
|
||||||
|
|
||||||
@@ -111,8 +112,8 @@ class RedirectIfAuthenticated extends BaseMiddleware
|
|||||||
return '/event-admin/dashboard';
|
return '/event-admin/dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user && $user->role === 'super_admin') {
|
if ($user && $user->isSuperAdmin()) {
|
||||||
return '/admin';
|
return '/super-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user && $user->role === 'user') {
|
if ($user && $user->role === 'user') {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ namespace App\Http\Middleware;
|
|||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class SuperAdminAuth
|
class SuperAdminAuth
|
||||||
{
|
{
|
||||||
@@ -21,15 +21,15 @@ class SuperAdminAuth
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Auth::check()) {
|
if (! Auth::check()) {
|
||||||
abort(403, 'Nicht angemeldet.');
|
abort(403, 'Nicht angemeldet.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
Log::info('SuperAdminAuth: User ID ' . $user->id . ', role: ' . $user->role);
|
Log::info('SuperAdminAuth: User ID '.$user->id.', role: '.$user->role);
|
||||||
|
|
||||||
if ($user->role !== 'super_admin') {
|
if (! $user->isSuperAdmin()) {
|
||||||
abort(403, 'Zugriff nur für SuperAdmin. User ID: ' . $user->id . ', Role: ' . $user->role);
|
abort(403, 'Zugriff nur für SuperAdmin. User ID: '.$user->id.', Role: '.$user->role);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
|
|||||||
28
app/Http/Requests/Auth/TenantAdminForgotPasswordRequest.php
Normal file
28
app/Http/Requests/Auth/TenantAdminForgotPasswordRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class TenantAdminForgotPasswordRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Requests/Auth/TenantAdminResetPasswordRequest.php
Normal file
31
app/Http/Requests/Auth/TenantAdminResetPasswordRequest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rules;
|
||||||
|
|
||||||
|
class TenantAdminResetPasswordRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'token' => ['required', 'string'],
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ class TenantAdminTokenRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'login' => ['required', 'string'],
|
'login' => ['required', 'email'],
|
||||||
'password' => ['required', 'string'],
|
'password' => ['required', 'string'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -27,10 +27,6 @@ class TenantAdminTokenRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
$login = $this->string('login')->trim()->value();
|
$login = $this->string('login')->trim()->value();
|
||||||
|
|
||||||
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
|
return ['email' => $login, 'password' => $this->string('password')->value()];
|
||||||
return ['email' => $login, 'password' => $this->string('password')->value()];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['username' => $login, 'password' => $this->string('password')->value()];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Requests\Checkout;
|
namespace App\Http\Requests\Checkout;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
class CheckoutRegisterRequest extends FormRequest
|
class CheckoutRegisterRequest extends FormRequest
|
||||||
@@ -23,13 +24,18 @@ class CheckoutRegisterRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
'email' => [
|
||||||
'username' => ['required', 'string', 'max:255', 'unique:users,username'],
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'email'),
|
||||||
|
Rule::unique('users', 'username'),
|
||||||
|
],
|
||||||
'password' => ['required', 'confirmed', Password::defaults()],
|
'password' => ['required', 'confirmed', Password::defaults()],
|
||||||
'first_name' => ['required', 'string', 'max:255'],
|
'first_name' => ['required', 'string', 'max:255'],
|
||||||
'last_name' => ['required', 'string', 'max:255'],
|
'last_name' => ['required', 'string', 'max:255'],
|
||||||
'address' => ['required', 'string', 'max:500'],
|
'address' => ['nullable', 'string', 'max:500'],
|
||||||
'phone' => ['required', 'string', 'max:255'],
|
'phone' => ['nullable', 'string', 'max:255'],
|
||||||
'package_id' => ['required', 'exists:packages,id'],
|
'package_id' => ['required', 'exists:packages,id'],
|
||||||
'terms' => ['required', 'accepted'],
|
'terms' => ['required', 'accepted'],
|
||||||
'privacy_consent' => ['required', 'accepted'],
|
'privacy_consent' => ['required', 'accepted'],
|
||||||
@@ -44,7 +50,6 @@ class CheckoutRegisterRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'email.unique' => 'Diese E-Mail-Adresse wird bereits verwendet.',
|
'email.unique' => 'Diese E-Mail-Adresse wird bereits verwendet.',
|
||||||
'username.unique' => 'Dieser Benutzername ist bereits vergeben.',
|
|
||||||
'password.confirmed' => 'Die Passwortbestätigung stimmt nicht überein.',
|
'password.confirmed' => 'Die Passwortbestätigung stimmt nicht überein.',
|
||||||
'package_id.exists' => 'Das ausgewählte Paket ist ungültig.',
|
'package_id.exists' => 'Das ausgewählte Paket ist ungültig.',
|
||||||
'terms.accepted' => 'Bitte akzeptiere die Nutzungsbedingungen.',
|
'terms.accepted' => 'Bitte akzeptiere die Nutzungsbedingungen.',
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Photobooth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PhotoboothConnectRedeemRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'code' => ['required', 'string', 'size:6', 'regex:/^\d{6}$/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$code = preg_replace('/\D+/', '', (string) $this->input('code'));
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'code' => $code,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ class EventStoreRequest extends FormRequest
|
|||||||
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
||||||
'location' => ['nullable', 'string', 'max:255'],
|
'location' => ['nullable', 'string', 'max:255'],
|
||||||
'event_type_id' => ['required', 'exists:event_types,id'],
|
'event_type_id' => ['required', 'exists:event_types,id'],
|
||||||
|
'package_id' => ['nullable', 'integer', 'exists:packages,id'],
|
||||||
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
|
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
|
||||||
'public_url' => ['nullable', 'url', 'max:500'],
|
'public_url' => ['nullable', 'url', 'max:500'],
|
||||||
'custom_domain' => ['nullable', 'string', 'max:255'],
|
'custom_domain' => ['nullable', 'string', 'max:255'],
|
||||||
@@ -46,6 +47,22 @@ class EventStoreRequest extends FormRequest
|
|||||||
'settings.branding.*' => ['nullable'],
|
'settings.branding.*' => ['nullable'],
|
||||||
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
|
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
|
||||||
'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])],
|
'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])],
|
||||||
|
'settings.live_show' => ['nullable', 'array'],
|
||||||
|
'settings.live_show.moderation_mode' => ['nullable', Rule::in(['off', 'manual', 'trusted_only'])],
|
||||||
|
'settings.live_show.retention_window_hours' => ['nullable', 'integer', 'min:1', 'max:72'],
|
||||||
|
'settings.live_show.playback_mode' => ['nullable', Rule::in(['newest_first', 'balanced', 'curated'])],
|
||||||
|
'settings.live_show.pace_mode' => ['nullable', Rule::in(['auto', 'fixed'])],
|
||||||
|
'settings.live_show.fixed_interval_seconds' => ['nullable', 'integer', 'min:3', 'max:20'],
|
||||||
|
'settings.live_show.layout_mode' => ['nullable', Rule::in(['single', 'split', 'grid_burst'])],
|
||||||
|
'settings.live_show.effect_preset' => ['nullable', Rule::in([
|
||||||
|
'film_cut',
|
||||||
|
'shutter_flash',
|
||||||
|
'polaroid_toss',
|
||||||
|
'parallax_glide',
|
||||||
|
'light_effects',
|
||||||
|
])],
|
||||||
|
'settings.live_show.effect_intensity' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||||
|
'settings.live_show.background_mode' => ['nullable', Rule::in(['blur_last', 'gradient', 'solid', 'brand'])],
|
||||||
'settings.watermark' => ['nullable', 'array'],
|
'settings.watermark' => ['nullable', 'array'],
|
||||||
'settings.watermark.mode' => ['nullable', Rule::in(['base', 'custom', 'off'])],
|
'settings.watermark.mode' => ['nullable', Rule::in(['base', 'custom', 'off'])],
|
||||||
'settings.watermark.asset' => ['nullable', 'string', 'max:500'],
|
'settings.watermark.asset' => ['nullable', 'string', 'max:500'],
|
||||||
|
|||||||
28
app/Http/Requests/Tenant/LiveShowApproveRequest.php
Normal file
28
app/Http/Requests/Tenant/LiveShowApproveRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Tenant;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class LiveShowApproveRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'priority' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Http/Requests/Tenant/LiveShowQueueRequest.php
Normal file
34
app/Http/Requests/Tenant/LiveShowQueueRequest.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Tenant;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class LiveShowQueueRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'live_status' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::in(['pending', 'approved', 'rejected', 'none', 'expired', 'all']),
|
||||||
|
],
|
||||||
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Requests/Tenant/LiveShowRejectRequest.php
Normal file
28
app/Http/Requests/Tenant/LiveShowRejectRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Tenant;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class LiveShowRejectRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reason' => ['nullable', 'string', 'max:64'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Tenant;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PhotoboothConnectCodeStoreRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:120'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,11 @@ class PhotoResource extends JsonResource
|
|||||||
'is_featured' => (bool) ($this->is_featured ?? false),
|
'is_featured' => (bool) ($this->is_featured ?? false),
|
||||||
'status' => $showSensitive ? $this->status : 'approved',
|
'status' => $showSensitive ? $this->status : 'approved',
|
||||||
'moderation_notes' => $showSensitive ? $this->moderation_notes : null,
|
'moderation_notes' => $showSensitive ? $this->moderation_notes : null,
|
||||||
|
'live_status' => $showSensitive ? $this->live_status?->value ?? $this->live_status : null,
|
||||||
|
'live_approved_at' => $showSensitive ? $this->live_approved_at?->toISOString() : null,
|
||||||
|
'live_reviewed_at' => $showSensitive ? $this->live_reviewed_at?->toISOString() : null,
|
||||||
|
'live_rejection_reason' => $showSensitive ? $this->live_rejection_reason : null,
|
||||||
|
'live_priority' => $showSensitive ? (int) ($this->live_priority ?? 0) : null,
|
||||||
'likes_count' => (int) ($this->likes_count ?? $this->likes()->count()),
|
'likes_count' => (int) ($this->likes_count ?? $this->likes()->count()),
|
||||||
'is_liked' => false,
|
'is_liked' => false,
|
||||||
'uploaded_at' => $this->created_at->toISOString(),
|
'uploaded_at' => $this->created_at->toISOString(),
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ class GenerateDataExport implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($export->status !== DataExport::STATUS_PENDING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $export->user) {
|
if (! $export->user) {
|
||||||
$export->update([
|
$export->update([
|
||||||
'status' => DataExport::STATUS_FAILED,
|
'status' => DataExport::STATUS_FAILED,
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ class PullPackageFromPaddle implements ShouldQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! $package->paddle_product_id && ! $package->paddle_price_id) {
|
if (! $package->paddle_product_id && ! $package->paddle_price_id) {
|
||||||
Log::warning('Paddle pull skipped for package without linkage', ['package_id' => $package->id]);
|
Log::channel('paddle-sync')->warning('Paddle pull skipped for package without linkage', [
|
||||||
|
'package_id' => $package->id,
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -52,9 +54,11 @@ class PullPackageFromPaddle implements ShouldQueue
|
|||||||
'paddle_snapshot' => $snapshot,
|
'paddle_snapshot' => $snapshot,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
Log::info('Paddle package pull completed', ['package_id' => $package->id]);
|
Log::channel('paddle-sync')->info('Paddle package pull completed', [
|
||||||
|
'package_id' => $package->id,
|
||||||
|
]);
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Log::error('Paddle package pull failed', [
|
Log::channel('paddle-sync')->error('Paddle package pull failed', [
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
'exception' => $exception,
|
'exception' => $exception,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class SyncCouponToPaddle implements ShouldQueue
|
|||||||
'paddle_last_synced_at' => now(),
|
'paddle_last_synced_at' => now(),
|
||||||
])->save();
|
])->save();
|
||||||
} catch (PaddleException $exception) {
|
} catch (PaddleException $exception) {
|
||||||
Log::error('Failed syncing coupon to Paddle', [
|
Log::channel('paddle-sync')->error('Failed syncing coupon to Paddle', [
|
||||||
'coupon_id' => $this->coupon->id,
|
'coupon_id' => $this->coupon->id,
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
'status' => $exception->status(),
|
'status' => $exception->status(),
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue
|
|||||||
]),
|
]),
|
||||||
])->save();
|
])->save();
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Log::error('Paddle addon sync failed', [
|
Log::channel('paddle-sync')->error('Paddle addon sync failed', [
|
||||||
'addon_id' => $addon->id,
|
'addon_id' => $addon->id,
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
'exception' => $exception,
|
'exception' => $exception,
|
||||||
@@ -160,7 +160,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue
|
|||||||
]),
|
]),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
Log::info('Paddle addon dry-run snapshot generated', [
|
Log::channel('paddle-sync')->info('Paddle addon dry-run snapshot generated', [
|
||||||
'addon_id' => $addon->id,
|
'addon_id' => $addon->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class SyncPackageToPaddle implements ShouldQueue
|
|||||||
],
|
],
|
||||||
])->save();
|
])->save();
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Log::error('Paddle package sync failed', [
|
Log::channel('paddle-sync')->error('Paddle package sync failed', [
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
'exception' => $exception,
|
'exception' => $exception,
|
||||||
@@ -131,7 +131,7 @@ class SyncPackageToPaddle implements ShouldQueue
|
|||||||
],
|
],
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
Log::info('Paddle package dry-run snapshot generated', [
|
Log::channel('paddle-sync')->info('Paddle package dry-run snapshot generated', [
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,19 @@ namespace App\Listeners\GuestNotifications;
|
|||||||
use App\Enums\GuestNotificationAudience;
|
use App\Enums\GuestNotificationAudience;
|
||||||
use App\Enums\GuestNotificationType;
|
use App\Enums\GuestNotificationType;
|
||||||
use App\Events\GuestPhotoUploaded;
|
use App\Events\GuestPhotoUploaded;
|
||||||
|
use App\Models\GuestNotification;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Services\GuestNotificationService;
|
use App\Services\GuestNotificationService;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class SendPhotoUploadedNotification
|
class SendPhotoUploadedNotification
|
||||||
{
|
{
|
||||||
|
private const DEDUPE_WINDOW_SECONDS = 30;
|
||||||
|
|
||||||
|
private const GROUP_WINDOW_MINUTES = 10;
|
||||||
|
|
||||||
|
private const MAX_GROUP_PHOTOS = 6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int[] $milestones
|
* @param int[] $milestones
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +33,20 @@ class SendPhotoUploadedNotification
|
|||||||
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
|
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
|
||||||
: 'Es gibt neue Fotos!';
|
: 'Es gibt neue Fotos!';
|
||||||
|
|
||||||
$this->notifications->createNotification(
|
$recent = $this->findRecentPhotoNotification($event->event->id);
|
||||||
|
if ($recent) {
|
||||||
|
if ($this->shouldSkipDuplicate($recent, $event->photoId, $title)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = $this->updateGroupedNotification($recent, $event->photoId);
|
||||||
|
$this->markUploaderRead($notification, $event->guestIdentifier);
|
||||||
|
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = $this->notifications->createNotification(
|
||||||
$event->event,
|
$event->event,
|
||||||
GuestNotificationType::PHOTO_ACTIVITY,
|
GuestNotificationType::PHOTO_ACTIVITY,
|
||||||
$title,
|
$title,
|
||||||
@@ -34,11 +55,15 @@ class SendPhotoUploadedNotification
|
|||||||
'audience_scope' => GuestNotificationAudience::ALL,
|
'audience_scope' => GuestNotificationAudience::ALL,
|
||||||
'payload' => [
|
'payload' => [
|
||||||
'photo_id' => $event->photoId,
|
'photo_id' => $event->photoId,
|
||||||
|
'photo_ids' => [$event->photoId],
|
||||||
|
'count' => 1,
|
||||||
],
|
],
|
||||||
'expires_at' => now()->addHours(3),
|
'expires_at' => now()->addHours(3),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->markUploaderRead($notification, $event->guestIdentifier);
|
||||||
|
|
||||||
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,4 +112,94 @@ class SendPhotoUploadedNotification
|
|||||||
|
|
||||||
return $guestIdentifier;
|
return $guestIdentifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function findRecentPhotoNotification(int $eventId): ?GuestNotification
|
||||||
|
{
|
||||||
|
$cutoff = Carbon::now()->subMinutes(self::GROUP_WINDOW_MINUTES);
|
||||||
|
|
||||||
|
return GuestNotification::query()
|
||||||
|
->where('event_id', $eventId)
|
||||||
|
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||||
|
->active()
|
||||||
|
->notExpired()
|
||||||
|
->where('created_at', '>=', $cutoff)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldSkipDuplicate(GuestNotification $notification, int $photoId, string $title): bool
|
||||||
|
{
|
||||||
|
$payload = $notification->payload;
|
||||||
|
if (is_array($payload)) {
|
||||||
|
$payloadIds = array_filter(
|
||||||
|
array_map(
|
||||||
|
fn ($value) => is_numeric($value) ? (int) $value : null,
|
||||||
|
(array) ($payload['photo_ids'] ?? [])
|
||||||
|
),
|
||||||
|
fn ($value) => $value !== null && $value > 0
|
||||||
|
);
|
||||||
|
if (in_array($photoId, $payloadIds, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (is_numeric($payload['photo_id'] ?? null) && (int) $payload['photo_id'] === $photoId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = Carbon::now()->subSeconds(self::DEDUPE_WINDOW_SECONDS);
|
||||||
|
if ($notification->created_at instanceof Carbon && $notification->created_at->greaterThanOrEqualTo($cutoff)) {
|
||||||
|
return $notification->title === $title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateGroupedNotification(GuestNotification $notification, int $photoId): GuestNotification
|
||||||
|
{
|
||||||
|
$payload = is_array($notification->payload) ? $notification->payload : [];
|
||||||
|
$photoIds = array_filter(
|
||||||
|
array_map(
|
||||||
|
fn ($value) => is_numeric($value) ? (int) $value : null,
|
||||||
|
(array) ($payload['photo_ids'] ?? [])
|
||||||
|
),
|
||||||
|
fn ($value) => $value !== null && $value > 0
|
||||||
|
);
|
||||||
|
$photoIds[] = $photoId;
|
||||||
|
$photoIds = array_values(array_unique($photoIds));
|
||||||
|
$photoIds = array_slice($photoIds, 0, self::MAX_GROUP_PHOTOS);
|
||||||
|
|
||||||
|
$existingCount = is_numeric($payload['count'] ?? null)
|
||||||
|
? max(1, (int) $payload['count'])
|
||||||
|
: max(1, count($photoIds) - 1);
|
||||||
|
$newCount = $existingCount + 1;
|
||||||
|
|
||||||
|
$notification->forceFill([
|
||||||
|
'title' => $this->buildGroupedTitle($newCount),
|
||||||
|
'payload' => [
|
||||||
|
'count' => $newCount,
|
||||||
|
'photo_ids' => $photoIds,
|
||||||
|
],
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildGroupedTitle(int $count): string
|
||||||
|
{
|
||||||
|
if ($count <= 1) {
|
||||||
|
return 'Es gibt neue Fotos!';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('Es gibt %d neue Fotos!', $count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markUploaderRead(GuestNotification $notification, string $guestIdentifier): void
|
||||||
|
{
|
||||||
|
$guestIdentifier = trim($guestIdentifier);
|
||||||
|
if ($guestIdentifier === '' || $guestIdentifier === 'anonymous') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->notifications->markAsRead($notification, $guestIdentifier);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ class Welcome extends Mailable
|
|||||||
public function envelope(): Envelope
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return new Envelope(
|
return new Envelope(
|
||||||
subject: __('emails.welcome.subject', ['name' => $this->user->fullName]),
|
subject: __('emails.welcome.subject', [
|
||||||
|
'name' => $this->user->fullName,
|
||||||
|
'app_name' => config('app.name'),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ class CouponRedemption extends Model
|
|||||||
'paddle_transaction_id',
|
'paddle_transaction_id',
|
||||||
'status',
|
'status',
|
||||||
'failure_reason',
|
'failure_reason',
|
||||||
|
'ip_address',
|
||||||
|
'device_id',
|
||||||
|
'user_agent',
|
||||||
'amount_discounted',
|
'amount_discounted',
|
||||||
'currency',
|
'currency',
|
||||||
'metadata',
|
'metadata',
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class DataExport extends Model
|
|||||||
|
|
||||||
public const STATUS_FAILED = 'failed';
|
public const STATUS_FAILED = 'failed';
|
||||||
|
|
||||||
|
public const STATUS_CANCELED = 'canceled';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id',
|
'user_id',
|
||||||
'tenant_id',
|
'tenant_id',
|
||||||
@@ -58,6 +60,38 @@ class DataExport extends Model
|
|||||||
return $this->status === self::STATUS_READY;
|
return $this->status === self::STATUS_READY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function canRetry(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->status, [self::STATUS_FAILED, self::STATUS_CANCELED], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canCancel(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->status, [self::STATUS_PENDING, self::STATUS_PROCESSING], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetForRetry(): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'status' => self::STATUS_PENDING,
|
||||||
|
'error_message' => null,
|
||||||
|
'path' => null,
|
||||||
|
'size_bytes' => null,
|
||||||
|
'expires_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markCanceled(?string $reason = null): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'status' => self::STATUS_CANCELED,
|
||||||
|
'error_message' => $reason ?: 'Canceled by superadmin.',
|
||||||
|
'path' => null,
|
||||||
|
'size_bytes' => null,
|
||||||
|
'expires_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function hasExpired(): bool
|
public function hasExpired(): bool
|
||||||
{
|
{
|
||||||
return $this->expires_at !== null && $this->expires_at->isPast();
|
return $this->expires_at !== null && $this->expires_at->isPast();
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class Event extends Model
|
|||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'name' => 'array',
|
'name' => 'array',
|
||||||
'description' => 'array',
|
'description' => 'array',
|
||||||
|
'live_show_token_rotated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
@@ -152,6 +153,29 @@ class Event extends Model
|
|||||||
return $this->eventPackage->canUploadPhoto();
|
return $this->eventPackage->canUploadPhoto();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function ensureLiveShowToken(): string
|
||||||
|
{
|
||||||
|
if (is_string($this->live_show_token) && $this->live_show_token !== '') {
|
||||||
|
return $this->live_show_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->rotateLiveShowToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rotateLiveShowToken(): string
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
} while (self::query()->where('live_show_token', $token)->exists());
|
||||||
|
|
||||||
|
$this->forceFill([
|
||||||
|
'live_show_token' => $token,
|
||||||
|
'live_show_token_rotated_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
public function getSettingsAttribute($value): array
|
public function getSettingsAttribute($value): array
|
||||||
{
|
{
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class GuestPolicySetting extends Model
|
|||||||
'join_token_access_decay_minutes' => 'integer',
|
'join_token_access_decay_minutes' => 'integer',
|
||||||
'join_token_download_limit' => 'integer',
|
'join_token_download_limit' => 'integer',
|
||||||
'join_token_download_decay_minutes' => 'integer',
|
'join_token_download_decay_minutes' => 'integer',
|
||||||
|
'join_token_ttl_hours' => 'integer',
|
||||||
'share_link_ttl_hours' => 'integer',
|
'share_link_ttl_hours' => 'integer',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -40,10 +41,11 @@ class GuestPolicySetting extends Model
|
|||||||
'per_device_upload_limit' => 50,
|
'per_device_upload_limit' => 50,
|
||||||
'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10),
|
'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10),
|
||||||
'join_token_failure_decay_minutes' => (int) config('join_tokens.failure_decay_minutes', 5),
|
'join_token_failure_decay_minutes' => (int) config('join_tokens.failure_decay_minutes', 5),
|
||||||
'join_token_access_limit' => (int) config('join_tokens.access_limit', 120),
|
'join_token_access_limit' => (int) config('join_tokens.access_limit', 300),
|
||||||
'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1),
|
'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1),
|
||||||
'join_token_download_limit' => (int) config('join_tokens.download_limit', 60),
|
'join_token_download_limit' => (int) config('join_tokens.download_limit', 120),
|
||||||
'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1),
|
'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1),
|
||||||
|
'join_token_ttl_hours' => 168,
|
||||||
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),
|
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),
|
||||||
'guest_notification_ttl_hours' => null,
|
'guest_notification_ttl_hours' => null,
|
||||||
];
|
];
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user