Files
fotospiel-app/app/Services/EventJoinTokenService.php
Codex Agent 2f9a700e00
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Fix guest demo UX and enforce guest limits
2026-01-21 21:35:40 +01:00

196 lines
6.4 KiB
PHP

<?php
namespace App\Services;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Models\EventJoinTokenEvent;
use App\Models\GuestPolicySetting;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class EventJoinTokenService
{
public function createToken(Event $event, array $attributes = []): EventJoinToken
{
return DB::transaction(function () use ($event, $attributes) {
$tokenValue = $this->generateUniqueToken();
$payload = [
'event_id' => $event->id,
'token' => $tokenValue,
'label' => Arr::get($attributes, 'label'),
'usage_limit' => Arr::get($attributes, 'usage_limit'),
'metadata' => Arr::get($attributes, 'metadata', []),
];
if ($expiresAt = Arr::get($attributes, 'expires_at')) {
$payload['expires_at'] = $expiresAt instanceof Carbon
? $expiresAt
: Carbon::parse($expiresAt);
} else {
$ttlHours = (int) (GuestPolicySetting::current()->join_token_ttl_hours ?? 0);
if ($ttlHours > 0) {
$payload['expires_at'] = now()->addHours($ttlHours);
}
}
if ($createdBy = Arr::get($attributes, 'created_by')) {
$payload['created_by'] = $createdBy;
}
return tap(EventJoinToken::create($payload), function (EventJoinToken $model) use ($tokenValue) {
$model->setAttribute('plain_token', $tokenValue);
});
});
}
public function revoke(EventJoinToken $joinToken, ?string $reason = null): EventJoinToken
{
unset($joinToken->plain_token);
$joinToken->revoked_at = now();
if ($reason) {
$metadata = $joinToken->metadata ?? [];
$metadata['revoked_reason'] = $reason;
$joinToken->metadata = $metadata;
}
$joinToken->save();
return $joinToken;
}
public function incrementUsage(EventJoinToken $joinToken, ?string $deviceId = null, ?string $ipAddress = null): void
{
$joinToken->increment('usage_count');
$event = $joinToken->event()
->with(['eventPackage.package', 'eventPackages.package', 'tenant'])
->first();
if ($event && $event->tenant) {
$usageTracker = app(\App\Services\Packages\PackageUsageTracker::class);
$limitEvaluator = app(\App\Services\Packages\PackageLimitEvaluator::class);
$eventPackage = $limitEvaluator->resolveEventPackageForPhotoUpload($event->tenant, $event->id, $event);
if ($eventPackage && $eventPackage->package?->max_guests !== null) {
$normalizedDeviceId = $this->normalizeDeviceId($deviceId);
$normalizedIp = is_string($ipAddress) && trim($ipAddress) !== '' ? $ipAddress : null;
if (! $this->shouldCountGuest($event->id, $normalizedDeviceId, $normalizedIp)) {
return;
}
$previous = (int) $eventPackage->used_guests;
$eventPackage->increment('used_guests');
$eventPackage->refresh();
$usageTracker->recordGuestUsage($eventPackage, $previous, 1);
}
}
}
public function hasSeenGuest(int $eventId, ?string $deviceId, ?string $ipAddress): bool
{
$normalizedDeviceId = $this->normalizeDeviceId($deviceId);
$normalizedIp = is_string($ipAddress) && trim($ipAddress) !== '' ? $ipAddress : null;
if (! $normalizedDeviceId && ! $normalizedIp) {
return false;
}
$query = EventJoinTokenEvent::query()
->where('event_id', $eventId)
->where('event_type', 'access_granted');
if ($normalizedDeviceId) {
$query->where('device_id', $normalizedDeviceId);
} else {
$query->where('ip_address', $normalizedIp);
}
return $query->exists();
}
public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken
{
$hash = $this->hashToken($token);
return EventJoinToken::query()
->where(function ($query) use ($hash, $token) {
$query->where('token_hash', $hash)
->orWhere(function ($inner) use ($token) {
$inner->whereNull('token_hash')
->where('token', $token);
});
})
->when(! $includeInactive, function ($query) {
$query->whereNull('revoked_at')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->where(function ($query) {
$query->whereNull('usage_limit')
->orWhereColumn('usage_limit', '>', 'usage_count');
});
})
->first();
}
public function findActiveToken(string $token): ?EventJoinToken
{
return $this->findToken($token);
}
protected function generateUniqueToken(int $length = 48): string
{
do {
$token = Str::random($length);
$hash = $this->hashToken($token);
} while (EventJoinToken::where('token_hash', $hash)->exists());
return $token;
}
protected function hashToken(string $token): string
{
return hash('sha256', $token);
}
private function normalizeDeviceId(?string $deviceId): ?string
{
if (! is_string($deviceId) || trim($deviceId) === '') {
return null;
}
$cleaned = preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId) ?? '';
$cleaned = substr($cleaned, 0, 64);
return $cleaned !== '' ? $cleaned : null;
}
private function shouldCountGuest(int $eventId, ?string $deviceId, ?string $ipAddress): bool
{
if (! $deviceId && ! $ipAddress) {
return false;
}
$query = EventJoinTokenEvent::query()
->where('event_id', $eventId)
->where('event_type', 'access_granted');
if ($deviceId) {
$query->where('device_id', $deviceId);
} else {
$query->where('ip_address', $ipAddress);
}
return $query->count() <= 1;
}
}