Fix guest demo UX and enforce guest limits
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-21 21:35:40 +01:00
parent 50cc4e76df
commit 2f9a700e00
28 changed files with 812 additions and 118 deletions

View File

@@ -4,6 +4,7 @@ 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;
@@ -63,7 +64,7 @@ class EventJoinTokenService
return $joinToken;
}
public function incrementUsage(EventJoinToken $joinToken): void
public function incrementUsage(EventJoinToken $joinToken, ?string $deviceId = null, ?string $ipAddress = null): void
{
$joinToken->increment('usage_count');
@@ -78,6 +79,12 @@ class EventJoinTokenService
$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();
@@ -87,6 +94,28 @@ class EventJoinTokenService
}
}
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);
@@ -132,4 +161,35 @@ class EventJoinTokenService
{
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;
}
}

View File

@@ -233,10 +233,13 @@ class PackageLimitEvaluator
config('package-limits.photo_thresholds', [])
);
$guestSummary = $this->buildUsageSummary(
(int) $eventPackage->used_guests,
$limits['max_guests'],
config('package-limits.guest_thresholds', [])
$guestSummary = $this->applyGuestGrace(
$this->buildUsageSummary(
(int) $eventPackage->used_guests,
$limits['max_guests'],
config('package-limits.guest_thresholds', [])
),
(int) $eventPackage->used_guests
);
$gallerySummary = $this->buildGallerySummary(
@@ -429,4 +432,37 @@ class PackageLimitEvaluator
return $value;
}
/**
* @param array{limit: ?int, used: int, remaining: ?int, percentage: ?float, state: string, threshold_reached: ?float, next_threshold: ?float, thresholds: array} $summary
* @return array{limit: ?int, used: int, remaining: ?int, percentage: ?float, state: string, threshold_reached: ?float, next_threshold: ?float, thresholds: array}
*/
private function applyGuestGrace(array $summary, int $used): array
{
$limit = $summary['limit'] ?? null;
if ($limit === null || $limit <= 0) {
return $summary;
}
$grace = (int) config('package-limits.guest_grace', 10);
$hardLimit = $limit + max(0, $grace);
if ($used >= $hardLimit) {
$summary['state'] = 'limit_reached';
$summary['threshold_reached'] = 1.0;
$summary['next_threshold'] = null;
$summary['remaining'] = 0;
return $summary;
}
if ($used >= $limit) {
$summary['state'] = 'warning';
$summary['threshold_reached'] = 1.0;
$summary['next_threshold'] = null;
$summary['remaining'] = 0;
}
return $summary;
}
}

View File

@@ -58,6 +58,8 @@ class PackageUsageTracker
}
$newUsed = $eventPackage->used_guests;
$grace = (int) config('package-limits.guest_grace', 10);
$hardLimit = $limit + max(0, $grace);
$thresholds = collect(config('package-limits.guest_thresholds', []))
->filter(fn (float $value) => $value > 0 && $value < 1)
@@ -80,8 +82,8 @@ class PackageUsageTracker
}
}
if ($newUsed >= $limit && ($previousUsed < $limit)) {
$this->dispatcher->dispatch(new EventPackageGuestLimitReached($eventPackage, $limit));
if ($newUsed >= $hardLimit && ($previousUsed < $hardLimit)) {
$this->dispatcher->dispatch(new EventPackageGuestLimitReached($eventPackage, $hardLimit));
}
}
}