From c2d552b30e94053e7b08ce41c1610de851ce1701 Mon Sep 17 00:00:00 2001 From: tmolick Date: Fri, 2 Oct 2026 15:10:59 -0700 Subject: [PATCH 1/5] Add entry requirements to attendee logs Attendee logs can now restrict who gets logged as an attendee. An attendee is allowed in if they meet any of the requirements that are set: - a ConCat registration level, matched by product name or ID - a Tracker role of Staff or above - a minimum number of earned volunteer hours for the log's event Admins configure the requirements on the log page. The registration levels to choose from are built from ConCat registrations and cached for six hours, with a button to reload them. Requirements based on Tracker data are checked first so ConCat is only contacted when necessary. --- .../Controllers/AttendeeLogController.php | 197 +++++++-- app/Http/Requests/AttendeeLogStoreRequest.php | 4 + .../Requests/AttendeeLogUpdateRequest.php | 4 + app/Models/AttendeeLog.php | 72 +++- ...dd_entry_requirements_to_attendee_logs.php | 27 ++ .../AttendeeLog/AttendeeCreatePanel.vue | 28 +- .../AttendeeLogRequirementsPanel.vue | 267 ++++++++++++ resources/js/Pages/AttendeeLogDetails.vue | 9 +- resources/js/data/AttendeeLog.ts | 3 + resources/js/data/RegistrationLevel.ts | 5 + routes/web.php | 2 + .../AttendeeLogEntryRequirementsTest.php | 381 ++++++++++++++++++ 12 files changed, 965 insertions(+), 34 deletions(-) create mode 100644 database/migrations/2026_09_30_000000_add_entry_requirements_to_attendee_logs.php create mode 100644 resources/js/Components/AttendeeLog/AttendeeLogRequirementsPanel.vue create mode 100644 resources/js/data/RegistrationLevel.ts create mode 100644 tests/Feature/AttendeeLogEntryRequirementsTest.php diff --git a/app/Http/Controllers/AttendeeLogController.php b/app/Http/Controllers/AttendeeLogController.php index f365373a..12d85ce3 100644 --- a/app/Http/Controllers/AttendeeLogController.php +++ b/app/Http/Controllers/AttendeeLogController.php @@ -13,10 +13,13 @@ use App\Models\Setting; use App\Models\User; use App\Reports\Report; +use GuzzleHttp\Exception\ClientException; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Inertia\Inertia; @@ -24,6 +27,8 @@ use Throwable; class AttendeeLogController extends Controller { + protected const REGISTRATION_LEVELS_CACHE_KEY = 'concat:registration-levels'; + /** * List all attendee logs */ @@ -49,15 +54,35 @@ public function index(Request $request, ?Event $event = null): JsonResponse|Iner public function show(Request $request, AttendeeLog $attendeeLog): JsonResponse|InertiaResponse { $this->authorize('view', $attendeeLog); + if ($request->expectsJson()) return response()->json(['attendee_log' => $attendeeLog]); + + $props = [ + 'attendeeLog' => $attendeeLog->load(['users' => function ($query) { + $query->select('id', 'badge_id', 'badge_name')->withPivot('type', 'created_at'); + }]), + 'event' => fn () => $attendeeLog->event, + 'exportTypes' => fn () => Report::EXPORT_FILE_TYPES, + ]; + + // Only users that can change the allowed registration levels need the list of levels to pick from + if ($request->user()->can('update', $attendeeLog)) { + $props['registrationLevels'] = Inertia::defer(fn () => $this->getRegistrationLevels()); + } + + return Inertia::render('AttendeeLogDetails', $props); + } + + /** + * Clear the cached list of registration levels so the next request retrieves it from ConCat + */ + public function refreshRegistrationLevels(Request $request): JsonResponse|RedirectResponse { + $this->authorize('create', AttendeeLog::class); + + Cache::forget(static::REGISTRATION_LEVELS_CACHE_KEY); + return $request->expectsJson() - ? response()->json(['attendee_log' => $attendeeLog]) - : Inertia::render('AttendeeLogDetails', [ - 'attendeeLog' => $attendeeLog->load(['users' => function ($query) { - $query->select('id', 'badge_id', 'badge_name')->withPivot('type', 'created_at'); - }]), - 'event' => fn () => $attendeeLog->event, - 'exportTypes' => fn () => Report::EXPORT_FILE_TYPES, - ]); + ? response()->json(['registration_levels' => $this->getRegistrationLevels()]) + : redirect()->back(); } /** @@ -110,31 +135,65 @@ public function storeUser(AttendeeLogUserStoreRequest $request, AttendeeLog $att $badgeId = $request->validated('badge_id'); $user = User::whereBadgeId($badgeId)->first(); - // If there isn't a user in the DB, then we retrieve registration details for the badge ID from ConCat - // and create a user with that information. - if (!$user) { + // Check for an existing entry first so that attendees already in the log aren't checked again + if ($user && $attendeeLog->users()->whereUserId($user->id)->wherePivot('type', $type)->exists()) { + return $this->alreadyPresentResponse($request, $type, $user); + } + + // Attendees must meet at least one of the log's entry requirements. The requirements based on Tracker data + // (staff role and volunteer hours) are checked first so that ConCat is only contacted when necessary. + $checkRequirements = $type === 'attendee' && $attendeeLog->hasEntryRequirements(); + $allowedByTrackerData = $checkRequirements && $user && $attendeeLog->allowsUserByTrackerData($user); + $needsRegistration = !$user + || ($checkRequirements && !$allowedByTrackerData && $attendeeLog->isRestrictedByRegistrationLevel()); + + // The ConCat registration is needed to create a user that isn't in the DB yet, or to check a registration level + $registration = null; + $registrationMissing = false; + if ($needsRegistration) { try { ConCat::authorize(); $registration = ConCat::getRegistration($badgeId); } catch (Throwable $err) { - Log::warning('Failed to look up ConCat registration for attendee log entry', [ - 'badge_id' => $badgeId, - 'error' => $err, - ]); - return $request->expectsJson() - ? response()->json(['error' => "No registered attendee found with badge #{$badgeId}."], 404) - : redirect()->back()->withErrors(['badge_id' => "No registered attendee found with badge #{$badgeId}."]); - } + // A 404 means the badge has no registration, as opposed to ConCat being unreachable + $registrationMissing = $err instanceof ClientException && $err->getResponse()->getStatusCode() === 404; + if (!$registrationMissing) { + Log::warning('Failed to look up ConCat registration for attendee log entry', [ + 'badge_id' => $badgeId, + 'error' => $err, + ]); + } - $user = User::createFromConCatRegistration($registration, Role::Attendee); + // Without a registration, a badge that isn't in the DB can't be logged at all. An existing user only + // needed it for the level check, so they're denied below instead. + if (!$user) { + return $request->expectsJson() + ? response()->json(['error' => "No registered attendee found with badge #{$badgeId}."], 404) + : redirect()->back()->withErrors(['badge_id' => "No registered attendee found with badge #{$badgeId}."]); + } + } } - // Make sure the user isn't already present in the log - if ($attendeeLog->users()->whereUserId($user->id)->wherePivot('type', $type)->exists()) { - $typeName = Str::title($type); + // Users that are about to be created can't have any volunteer hours or a staff role yet, so the registration + // level is the only requirement left that could let them in + $failsRequirements = $checkRequirements + && !$allowedByTrackerData + && !($registration + && $attendeeLog->isRestrictedByRegistrationLevel() + && $attendeeLog->allowsRegistration($registration)); + + if ($failsRequirements) { + $error = $this->buildEntryDeniedMessage($attendeeLog, $badgeId, $user, $registration, $registrationMissing); return $request->expectsJson() - ? response()->json(['error' => "{$typeName} {$user->audit_name} is already present in the log."], 422) - : redirect()->back()->withErrors(['badge_id' => "{$typeName} {$user->audit_name} is already present in the log."]); + ? response()->json(['error' => $error], 403) + : redirect()->back()->withErrors(['requirements' => $error]); + } + + if (!$user) { + $user = User::createFromConCatRegistration($registration, Role::Attendee); + } elseif ($attendeeLog->users()->whereUserId($user->id)->wherePivot('type', $type)->exists()) { + // Check again in case the user was added to the log while the requirements were being checked + return $this->alreadyPresentResponse($request, $type, $user); } $attendeeLog->users()->attach($user, ['type' => $type]); @@ -148,6 +207,16 @@ public function storeUser(AttendeeLogUserStoreRequest $request, AttendeeLog $att : redirect()->back()->withSuccess("Added {$type} {$user->audit_name} to the log."); } + /** + * Builds the response for a user that's already present in an attendee log + */ + protected function alreadyPresentResponse(Request $request, string $type, User $user): JsonResponse|RedirectResponse { + $error = Str::title($type) . " {$user->audit_name} is already present in the log."; + return $request->expectsJson() + ? response()->json(['error' => $error], 422) + : redirect()->back()->withErrors(['badge_id' => $error]); + } + /** * Remove a user from an attendee log */ @@ -167,6 +236,84 @@ public function destroyUser(Request $request, AttendeeLog $attendeeLog, Attendee : redirect()->back()->withSuccess("Removed {$type->value} {$user->audit_name} from the log."); } + /** + * Builds a message explaining which of an attendee log's entry requirements an attendee failed to meet + */ + protected function buildEntryDeniedMessage( + AttendeeLog $attendeeLog, + int $badgeId, + ?User $user, + ?\stdClass $registration, + bool $registrationMissing, + ): string { + $badgeName = $registration?->badgeName ?? $user?->badge_name; + $who = $badgeName ? "{$badgeName} (#{$badgeId})" : "Badge #{$badgeId}"; + + $reasons = []; + $levelUnknown = $attendeeLog->isRestrictedByRegistrationLevel() && !$registration; + if ($levelUnknown && $registrationMissing) { + $reasons[] = "doesn't have a ConCat registration"; + } elseif ($levelUnknown) { + $reasons[] = "couldn't have their registration level checked with ConCat"; + } elseif ($attendeeLog->isRestrictedByRegistrationLevel()) { + $level = $registration->productDisplayName ?? $registration->productName ?? 'unknown'; + $reasons[] = "is registered as {$level}"; + } + if ($attendeeLog->allow_staff) $reasons[] = "isn't staff"; + if ($attendeeLog->min_volunteer_hours !== null) { + $hours = $user ? $attendeeLog->getVolunteerHours($user) : 0; + $reasons[] = sprintf( + 'has %s of %s required volunteer hours', + static::formatHours(floor($hours * 10) / 10), + static::formatHours($attendeeLog->min_volunteer_hours), + ); + } + + // A registration level on its own doesn't explain the denial, so state that the level isn't allowed + $onlyLevel = count($reasons) === 1 && $attendeeLog->isRestrictedByRegistrationLevel() && !$levelUnknown; + $joined = $onlyLevel + ? "{$reasons[0]}, which isn't allowed in this log" + : Arr::join($reasons, ', ', count($reasons) > 2 ? ', and ' : ' and '); + + return "Denied: {$who} {$joined}."; + } + + /** + * Formats an amount of hours without unnecessary trailing zeroes (12, 9.5, 11.25) + */ + protected static function formatHours(float $hours): string { + return rtrim(rtrim(number_format($hours, 2, '.', ''), '0'), '.'); + } + + /** + * Gets the distinct registration levels (products) from all ConCat registrations. + * The list is cached because building it requires paging through every registration. + * Returns null if ConCat can't be reached. Failures aren't cached. + * + * @return array|null + */ + protected function getRegistrationLevels(): ?array { + try { + return Cache::remember(static::REGISTRATION_LEVELS_CACHE_KEY, now()->addHours(6), function () { + ConCat::authorize(); + return collect(ConCat::searchRegistrations(['limit' => 100])) + ->filter(fn ($registration) => isset($registration->productId, $registration->productName)) + ->unique('productId') + ->map(fn ($registration) => [ + 'id' => (string) $registration->productId, + 'name' => $registration->productName, + 'display_name' => $registration->productDisplayName ?? null, + ]) + ->sortBy(fn ($level) => mb_strtolower($level['display_name'] ?? $level['name'])) + ->values() + ->all(); + }); + } catch (Throwable $err) { + Log::warning('Failed to retrieve registration levels from ConCat', ['error' => $err]); + return null; + } + } + /** * Gets an event's attendee logs that are visible to the user * diff --git a/app/Http/Requests/AttendeeLogStoreRequest.php b/app/Http/Requests/AttendeeLogStoreRequest.php index 15eae421..5bdeca91 100644 --- a/app/Http/Requests/AttendeeLogStoreRequest.php +++ b/app/Http/Requests/AttendeeLogStoreRequest.php @@ -30,6 +30,10 @@ public function rules(): array { ->where(fn (Builder $query) => $query->where('event_id', $this->route('event')->id)) ->withoutTrashed(), ], + 'allowed_registration_levels' => 'sometimes|nullable|array|max:50', + 'allowed_registration_levels.*' => 'required|string|max:128|distinct:ignore_case', + 'allow_staff' => 'sometimes|boolean', + 'min_volunteer_hours' => 'sometimes|nullable|numeric|decimal:0,2|min:1|max:999', ]; } } diff --git a/app/Http/Requests/AttendeeLogUpdateRequest.php b/app/Http/Requests/AttendeeLogUpdateRequest.php index f9fdd02e..305a28ad 100644 --- a/app/Http/Requests/AttendeeLogUpdateRequest.php +++ b/app/Http/Requests/AttendeeLogUpdateRequest.php @@ -31,6 +31,10 @@ public function rules(): array { ->ignore($this->route('attendeeLog')) ->withoutTrashed(), ], + 'allowed_registration_levels' => 'sometimes|nullable|array|max:50', + 'allowed_registration_levels.*' => 'required|string|max:128|distinct:ignore_case', + 'allow_staff' => 'sometimes|boolean', + 'min_volunteer_hours' => 'sometimes|nullable|numeric|decimal:0,2|min:1|max:999', ]; } } diff --git a/app/Models/AttendeeLog.php b/app/Models/AttendeeLog.php index f8df9f7e..57216a46 100644 --- a/app/Models/AttendeeLog.php +++ b/app/Models/AttendeeLog.php @@ -16,6 +16,9 @@ /** * @property string $name * @property string $event_id + * @property string[]|null $allowed_registration_levels + * @property bool $allow_staff + * @property float|null $min_volunteer_hours * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property-read \App\Models\Event|null $event @@ -53,11 +56,25 @@ class AttendeeLog extends Model implements HasDisplayName { protected $fillable = [ 'name', + 'allowed_registration_levels', + 'allow_staff', + 'min_volunteer_hours', + ]; + protected $casts = [ + 'allowed_registration_levels' => 'array', + 'allow_staff' => 'boolean', + 'min_volunteer_hours' => 'float', ]; public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() - ->logOnly(['name', 'event_id']) + ->logOnly([ + 'name', + 'event_id', + 'allowed_registration_levels', + 'allow_staff', + 'min_volunteer_hours', + ]) ->logOnlyDirty() ->submitEmptyLogs(); } @@ -122,6 +139,59 @@ public function hasAttendee(User|string $user): bool { return $this->attendees()->whereUserId($user->id ?? $user)->exists(); } + /** + * Checks whether this attendee log has any entry requirements for attendees. + * Attendees pass if they meet any one of the requirements that are set. + */ + public function hasEntryRequirements(): bool { + return $this->isRestrictedByRegistrationLevel() || $this->allow_staff || $this->min_volunteer_hours !== null; + } + + /** + * Checks whether a user meets any of the entry requirements that only rely on Tracker data (staff role and + * volunteer hours), so their ConCat registration doesn't need to be retrieved to check them + */ + public function allowsUserByTrackerData(User $user): bool { + if ($this->allow_staff && $user->isStaff()) return true; + if ($this->min_volunteer_hours !== null && $this->getVolunteerHours($user) >= $this->min_volunteer_hours) { + return true; + } + return false; + } + + /** + * Gets the volunteer hours a user has earned (including bonuses) for this attendee log's event + */ + public function getVolunteerHours(User $user): float { + return $user->getEarnedTime($this->event) / 3600; + } + + /** + * Checks whether this attendee log only accepts attendees with specific registration levels + */ + public function isRestrictedByRegistrationLevel(): bool { + return !empty($this->allowed_registration_levels); + } + + /** + * Checks whether a ConCat registration is allowed into this attendee log. + * A registration is allowed if its product ID or product name matches an allowed level (case-insensitive). + * Unrestricted logs allow every registration. + */ + public function allowsRegistration(\stdClass $registration): bool { + if (!$this->isRestrictedByRegistrationLevel()) return true; + + $candidates = array_map( + fn ($value) => mb_strtolower(trim((string) $value)), + array_filter([$registration->productId ?? null, $registration->productName ?? null]), + ); + foreach ($this->allowed_registration_levels as $level) { + if (in_array(mb_strtolower(trim($level)), $candidates, true)) return true; + } + + return false; + } + /** * Checks whether this attendee log has a specific user as a gatekeeper */ diff --git a/database/migrations/2026_09_30_000000_add_entry_requirements_to_attendee_logs.php b/database/migrations/2026_09_30_000000_add_entry_requirements_to_attendee_logs.php new file mode 100644 index 00000000..6e30e103 --- /dev/null +++ b/database/migrations/2026_09_30_000000_add_entry_requirements_to_attendee_logs.php @@ -0,0 +1,27 @@ +json('allowed_registration_levels')->nullable()->after('event_id'); + $table->boolean('allow_staff')->default(false)->after('allowed_registration_levels'); + $table->decimal('min_volunteer_hours', 5, 2)->nullable()->after('allow_staff'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void { + Schema::table('attendee_logs', function (Blueprint $table) { + $table->dropColumn(['allowed_registration_levels', 'allow_staff', 'min_volunteer_hours']); + }); + } +}; diff --git a/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue b/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue index 27f80c3a..1c2eeba2 100644 --- a/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue +++ b/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue @@ -16,6 +16,13 @@ key press after the badge number.

+ + Only allows attendees that meet any of: +
    +
  • {{ requirement }}
  • +
+
+
@@ -24,7 +31,7 @@ ref="input" name="badge_id" :id="badgeNumberId" - :invalid="Boolean(form.errors.badge_id)" + :invalid="form.hasErrors" inputmode="numeric" required :autofocus="!gatekeeper" @@ -34,9 +41,7 @@ - {{ form.errors.badge_id }} + {{ form.errors.badge_id ?? form.errors.requirements }} @@ -55,7 +60,7 @@ diff --git a/resources/js/Pages/AttendeeLogDetails.vue b/resources/js/Pages/AttendeeLogDetails.vue index 83fcdc90..3affaea5 100644 --- a/resources/js/Pages/AttendeeLogDetails.vue +++ b/resources/js/Pages/AttendeeLogDetails.vue @@ -24,6 +24,8 @@ + +
@@ -46,21 +48,24 @@ import { useUser } from '@/lib/user'; import { useReadOnly } from '@/lib/readonly'; import type AttendeeLog from '@/data/AttendeeLog'; import type Event from '@/data/Event'; +import type RegistrationLevel from '@/data/RegistrationLevel'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { faEye } from '@fortawesome/free-solid-svg-icons'; import AttendeesTable from '@/Components/AttendeeLog/AttendeesTable.vue'; import AttendeeCreatePanel from '@/Components/AttendeeLog/AttendeeCreatePanel.vue'; +import AttendeeLogRequirementsPanel from '@/Components/AttendeeLog/AttendeeLogRequirementsPanel.vue'; import FullContentHeightPanel from '@/Components/Common/FullContentHeightPanel.vue'; import BreadcrumbsPage from '@/Components/App/BreadcrumbsPage.vue'; -const { attendeeLog, event } = defineProps<{ +const { attendeeLog, event, registrationLevels } = defineProps<{ attendeeLog: AttendeeLog; event: Event; exportTypes?: Record; + registrationLevels?: RegistrationLevel[] | null; }>(); -const { isManager } = useUser(); +const { isAdmin, isManager } = useUser(); const isEventReadOnly = useReadOnly(); const attendees = computed(() => attendeeLog.users!.filter((u) => u.pivot.type === 'attendee')); diff --git a/resources/js/data/AttendeeLog.ts b/resources/js/data/AttendeeLog.ts index dd0bc25e..3276e26d 100644 --- a/resources/js/data/AttendeeLog.ts +++ b/resources/js/data/AttendeeLog.ts @@ -5,6 +5,9 @@ export default interface AttendeeLog { id: AttendeeLogId; name: string; event_id: EventId; + allowed_registration_levels: string[] | null; + allow_staff: boolean; + min_volunteer_hours: number | null; users?: Attendee[]; users_count?: number; attendees_count?: number; diff --git a/resources/js/data/RegistrationLevel.ts b/resources/js/data/RegistrationLevel.ts new file mode 100644 index 00000000..6f933665 --- /dev/null +++ b/resources/js/data/RegistrationLevel.ts @@ -0,0 +1,5 @@ +export default interface RegistrationLevel { + id: string; + name: string; + display_name: string | null; +} diff --git a/routes/web.php b/routes/web.php index b41132c2..0c9113f9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -54,6 +54,8 @@ Route::controller(\App\Http\Controllers\AttendeeLogController::class)->group(function () { Route::get('/attendee-logs', 'index')->name('attendee-logs.index'); + Route::post('/attendee-logs/registration-levels/refresh', 'refreshRegistrationLevels') + ->name('attendee-logs.registration-levels.refresh'); Route::put('/attendee-logs/{attendeeLog}/users', 'storeUser')->name('attendee-logs.users.store'); Route::delete('/attendee-logs/{attendeeLog}/{type}/{user}', 'destroyUser')->name('attendee-logs.users.destroy'); }); diff --git a/tests/Feature/AttendeeLogEntryRequirementsTest.php b/tests/Feature/AttendeeLogEntryRequirementsTest.php new file mode 100644 index 00000000..18ae1a79 --- /dev/null +++ b/tests/Feature/AttendeeLogEntryRequirementsTest.php @@ -0,0 +1,381 @@ + "Badge {$badgeId}", + 'status' => 'paid', + 'productId' => $productId, + 'productName' => $productName, + 'productDisplayName' => null, + 'user' => (object) [ + 'id' => $badgeId, + 'username' => "user{$badgeId}", + 'firstName' => 'Test', + 'lastName' => 'User', + ], + ]; +} + +/** + * Gets the current Inertia asset version so partial reload requests aren't rejected + */ +function inertiaVersion(): string { + return (string) app(\App\Http\Middleware\HandleInertiaRequests::class)->version(request()); +} + +beforeEach(function () { + $this->admin = User::factory()->create(['role' => Role::Admin]); + $event = Event::factory()->create(); + $this->log = new AttendeeLog(['name' => 'Sponsor Lounge']); + $this->log->event_id = $event->id; + $this->log->save(); +}); + +it('allows any registration when the log has no allowed levels', function () { + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('getRegistration')->once()->with(1234)->andReturn(fakeRegistration(1234, 'Attendee')); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => 1234]) + ->assertOk(); + + expect($this->log->attendees()->count())->toBe(1); +}); + +it('allows a registration whose product name matches, ignoring case', function () { + $this->log->update(['allowed_registration_levels' => ['sponsor', 'Super Sponsor']]); + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('getRegistration')->once()->andReturn(fakeRegistration(1234, 'Sponsor')); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => 1234]) + ->assertOk(); + + expect($this->log->attendees()->count())->toBe(1); +}); + +it('allows a registration whose product ID matches', function () { + $this->log->update(['allowed_registration_levels' => ['prod-sponsor']]); + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('getRegistration')->once()->andReturn(fakeRegistration(1234, 'Sponsor', 'prod-sponsor')); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => 1234]) + ->assertOk(); +}); + +it('denies a registration with a level that is not allowed', function () { + $this->log->update(['allowed_registration_levels' => ['Sponsor']]); + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('getRegistration')->once()->andReturn(fakeRegistration(1234, 'Attendee')); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => 1234]) + ->assertForbidden() + ->assertJsonPath('error', "Denied: Badge 1234 (#1234) is registered as Attendee, which isn't allowed in this log."); + + expect($this->log->attendees()->count())->toBe(0); + expect(User::whereBadgeId(1234)->exists())->toBeFalse(); +}); + +it('checks the level of users that already exist locally', function () { + User::factory()->create(['badge_id' => 1234, 'role' => Role::Attendee]); + $this->log->update(['allowed_registration_levels' => ['Sponsor']]); + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('getRegistration')->once()->andReturn(fakeRegistration(1234, 'Attendee')); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => 1234]) + ->assertForbidden(); +}); + +it('does not check the level of gatekeepers', function () { + User::factory()->create(['badge_id' => 1234]); + $this->log->update(['allowed_registration_levels' => ['Sponsor']]); + ConCat::shouldReceive('getRegistration')->never(); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => 1234, 'type' => 'gatekeeper']) + ->assertOk(); +}); + +it('lists distinct registration levels from ConCat and caches them', function () { + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('searchRegistrations')->once()->andReturn([ + fakeRegistration(1, 'Sponsor', 'prod-sponsor'), + fakeRegistration(2, 'Attendee', 'prod-attendee'), + fakeRegistration(3, 'Sponsor', 'prod-sponsor'), + ]); + + foreach ([1, 2] as $_) { + $this->actingAs($this->admin) + ->get(route('attendee-logs.show', $this->log), [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => inertiaVersion(), + 'X-Inertia-Partial-Component' => 'AttendeeLogDetails', + 'X-Inertia-Partial-Data' => 'registrationLevels', + ]) + ->assertOk() + ->assertJsonPath('props.registrationLevels', [ + ['id' => 'prod-attendee', 'name' => 'Attendee', 'display_name' => null], + ['id' => 'prod-sponsor', 'name' => 'Sponsor', 'display_name' => null], + ]); + } +}); + +it('does not give registration levels to gatekeepers', function () { + $gatekeeper = User::factory()->create(['role' => Role::Volunteer]); + $this->log->users()->attach($gatekeeper, ['type' => 'gatekeeper']); + \App\Models\Setting::set('active-event', $this->log->event_id); + \App\Models\Setting::set('lockdown', false); + ConCat::shouldReceive('searchRegistrations')->never(); + $headers = ['X-Inertia' => 'true', 'X-Inertia-Version' => inertiaVersion()]; + + // The prop isn't offered as a deferred prop to gatekeepers, while admins are offered it + $this->actingAs($gatekeeper) + ->get(route('attendee-logs.show', $this->log), $headers) + ->assertOk() + ->assertJsonMissingPath('deferredProps'); + $this->actingAs($this->admin) + ->get(route('attendee-logs.show', $this->log), $headers) + ->assertOk() + ->assertJsonPath('deferredProps.default', ['registrationLevels']); +}); + +it('only lets admins refresh the registration levels, clearing the cache', function () { + $manager = User::factory()->create(['role' => Role::Manager]); + Cache::put('concat:registration-levels', [['id' => 'old', 'name' => 'Old', 'display_name' => null]]); + + $this->actingAs($manager) + ->post(route('attendee-logs.registration-levels.refresh')) + ->assertForbidden(); + expect(Cache::has('concat:registration-levels'))->toBeTrue(); + + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('searchRegistrations')->once()->andReturn([fakeRegistration(1, 'Sponsor', 'prod-sponsor')]); + + $this->actingAs($this->admin) + ->postJson(route('attendee-logs.registration-levels.refresh')) + ->assertOk() + ->assertJsonPath('registration_levels.0.name', 'Sponsor'); +}); + +it('does not cache a failure to load the registration levels', function () { + ConCat::shouldReceive('authorize')->twice(); + // ConCat fails on the first request and works on the second + $calls = 0; + ConCat::shouldReceive('searchRegistrations') + ->twice() + ->andReturnUsing(function () use (&$calls) { + if (++$calls === 1) throw new RuntimeException('ConCat is down'); + return [fakeRegistration(1, 'Sponsor', 'prod-sponsor')]; + }); + + $this->actingAs($this->admin) + ->postJson(route('attendee-logs.registration-levels.refresh')) + ->assertOk() + ->assertJsonPath('registration_levels', null); + expect(Cache::has('concat:registration-levels'))->toBeFalse(); + + $this->actingAs($this->admin) + ->postJson(route('attendee-logs.registration-levels.refresh')) + ->assertOk() + ->assertJsonPath('registration_levels.0.name', 'Sponsor'); +}); + +/** + * Gives a user a finished time entry of a specific length for an event + */ +function addHours(User $user, string $eventId, float $hours): void { + $start = now()->subDays(1)->startOfHour(); + TimeEntry::factory()->create([ + 'user_id' => $user->id, + 'event_id' => $eventId, + 'department_id' => Department::factory()->create(['event_id' => $eventId])->id, + 'start' => $start, + 'stop' => $start->avoidMutation()->addMinutes((int) round($hours * 60)), + 'auto' => false, + ]); +} + +/** + * Creates a volunteer with a finished time entry of a specific length for an event + */ +function volunteerWithHours(string $eventId, float $hours, array $attributes = []): User { + $user = User::factory()->create(['role' => Role::Volunteer, ...$attributes]); + addHours($user, $eventId, $hours); + return $user; +} + +it('allows staff without contacting ConCat when staff are allowed', function () { + $staff = User::factory()->create(['role' => Role::Staff]); + $this->log->update(['allow_staff' => true, 'min_volunteer_hours' => 12]); + ConCat::shouldReceive('getRegistration')->never(); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $staff->badge_id]) + ->assertOk(); +}); + +it('allows volunteers with enough hours for the log\'s event', function () { + $volunteer = volunteerWithHours($this->log->event_id, 12.5); + $this->log->update(['allow_staff' => true, 'min_volunteer_hours' => 12]); + ConCat::shouldReceive('getRegistration')->never(); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id]) + ->assertOk(); +}); + +it('denies volunteers without enough hours and explains why', function () { + // Hours from other events don't count towards the log's event + $volunteer = volunteerWithHours($this->log->event_id, 9.5, ['badge_name' => 'Foxy']); + addHours($volunteer, Event::factory()->create()->id, 20); + $this->log->update(['allow_staff' => true, 'min_volunteer_hours' => 12]); + ConCat::shouldReceive('getRegistration')->never(); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id]) + ->assertForbidden() + ->assertJsonPath( + 'error', + "Denied: Foxy (#{$volunteer->badge_id}) isn't staff and has 9.5 of 12 required volunteer hours.", + ); +}); + +it('denies unknown badges on logs requiring staff or hours without creating them', function () { + $this->log->update(['min_volunteer_hours' => 12]); + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('getRegistration')->once()->andReturn(fakeRegistration(1234, 'Attendee')); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => 1234]) + ->assertForbidden() + ->assertJsonPath('error', 'Denied: Badge 1234 (#1234) has 0 of 12 required volunteer hours.'); + + expect(User::whereBadgeId(1234)->exists())->toBeFalse(); +}); + +it('allows volunteers without enough hours through an allowed registration level', function () { + $volunteer = volunteerWithHours($this->log->event_id, 2); + $this->log->update(['allowed_registration_levels' => ['Sponsor'], 'min_volunteer_hours' => 12]); + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('getRegistration')->once()->andReturn(fakeRegistration($volunteer->badge_id, 'Sponsor')); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id]) + ->assertOk(); +}); + +it('puts denials under their own error key for the frontend', function () { + $volunteer = volunteerWithHours($this->log->event_id, 2); + $this->log->update(['min_volunteer_hours' => 12]); + + $this->actingAs($this->admin) + ->from(route('attendee-logs.show', $this->log)) + ->put(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id]) + ->assertRedirect() + ->assertSessionHasErrors('requirements') + ->assertSessionDoesntHaveErrors('badge_id'); +}); + +it('denies existing users whose registration level cannot be checked', function () { + $volunteer = User::factory()->create(['role' => Role::Volunteer, 'badge_name' => 'Foxy']); + $this->log->update(['allowed_registration_levels' => ['Sponsor']]); + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('getRegistration')->once()->andThrow(new RuntimeException('ConCat is down')); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id]) + ->assertForbidden() + ->assertJsonPath( + 'error', + "Denied: Foxy (#{$volunteer->badge_id}) couldn't have their registration level checked with ConCat.", + ); +}); + +it('reports unknown badges as not found when ConCat cannot be reached', function () { + $this->log->update(['allowed_registration_levels' => ['Sponsor']]); + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('getRegistration')->once()->andThrow(new RuntimeException('ConCat is down')); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => 1234]) + ->assertNotFound(); + + expect(User::whereBadgeId(1234)->exists())->toBeFalse(); +}); + +it('tells existing users without a ConCat registration apart from ConCat being unreachable', function () { + $volunteer = User::factory()->create(['role' => Role::Volunteer, 'badge_name' => 'Foxy']); + $this->log->update(['allowed_registration_levels' => ['Sponsor']]); + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('getRegistration')->once()->andThrow(new ClientException( + 'Not Found', + new GuzzleRequest('GET', '/api/v0/users/1/registration'), + new GuzzleResponse(404), + )); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id]) + ->assertForbidden() + ->assertJsonPath('error', "Denied: Foxy (#{$volunteer->badge_id}) doesn't have a ConCat registration."); +}); + +it('rejects a minimum of less than 1 volunteer hour or more than 2 decimal places', function (float $hours) { + $this->actingAs($this->admin) + ->patchJson(route('attendee-logs.update', $this->log), ['min_volunteer_hours' => $hours]) + ->assertUnprocessable() + ->assertJsonValidationErrors('min_volunteer_hours'); +})->with([0, -1, 0.5, 0.99, 12.505]); + +it('accepts a minimum of 1 volunteer hour or more', function (float $hours) { + $this->actingAs($this->admin) + ->patchJson(route('attendee-logs.update', $this->log), ['min_volunteer_hours' => $hours]) + ->assertOk(); + + expect($this->log->fresh()->min_volunteer_hours)->toBe($hours); +})->with([1.0, 12.5, 11.25]); + +it('only lets admins change entry requirements', function () { + $manager = User::factory()->create(['role' => Role::Manager]); + + $this->actingAs($manager) + ->patchJson(route('attendee-logs.update', $this->log), ['allow_staff' => true]) + ->assertForbidden(); + + expect($this->log->fresh()->allow_staff)->toBeFalse(); +}); + +/** + * Creates a gatekeeper for the test log and makes its event active so the gatekeeper can use it + */ +it('lets admins set and clear the allowed levels', function () { + $this->actingAs($this->admin) + ->patchJson(route('attendee-logs.update', $this->log), ['allowed_registration_levels' => ['Sponsor']]) + ->assertOk(); + expect($this->log->fresh()->allowed_registration_levels)->toBe(['Sponsor']); + + $this->actingAs($this->admin) + ->patchJson(route('attendee-logs.update', $this->log), ['allowed_registration_levels' => null]) + ->assertOk(); + expect($this->log->fresh()->allowed_registration_levels)->toBeNull(); +}); From 17fa89b63e8b6a437f28642cf9ea1f4135e3ec37 Mon Sep 17 00:00:00 2001 From: tmolick Date: Fri, 2 Oct 2026 15:18:39 -0700 Subject: [PATCH 2/5] Add overrides for attendee log entry requirements A denied attendee can be let in anyway with an override, which records the user that approved it and an optional reason. Managers and admins can always override denials, and each log can also allow its gatekeepers to. Overrides are shown under the attendee's name in the log and included in the attendee log export. The export escapes text that a spreadsheet would treat as a formula. The override reason field is never focused automatically and ignores Enter, and reasons containing long numbers are rejected, so a badge scanned at the wrong moment can't submit an override. --- .../Controllers/AttendeeLogController.php | 55 +++- app/Http/Requests/AttendeeLogStoreRequest.php | 1 + .../Requests/AttendeeLogUpdateRequest.php | 1 + .../Requests/AttendeeLogUserStoreRequest.php | 2 + app/Models/AttendeeLog.php | 6 +- app/Policies/AttendeeLogPolicy.php | 9 + app/Reports/AttendeeLogReport.php | 35 ++- ...requirement_overrides_to_attendee_logs.php | 35 +++ .../AttendeeLog/AttendeeCreatePanel.vue | 164 +++++++++--- .../AttendeeLogRequirementsPanel.vue | 42 ++- .../Components/AttendeeLog/AttendeesTable.vue | 25 +- resources/js/Pages/AttendeeLogDetails.vue | 20 +- resources/js/data/Attendee.ts | 7 + resources/js/data/AttendeeLog.ts | 1 + .../AttendeeLogEntryRequirementsTest.php | 251 +++++++++++++++++- 15 files changed, 590 insertions(+), 64 deletions(-) create mode 100644 database/migrations/2026_09_30_000001_add_requirement_overrides_to_attendee_logs.php diff --git a/app/Http/Controllers/AttendeeLogController.php b/app/Http/Controllers/AttendeeLogController.php index 12d85ce3..f020cc4a 100644 --- a/app/Http/Controllers/AttendeeLogController.php +++ b/app/Http/Controllers/AttendeeLogController.php @@ -58,8 +58,15 @@ public function show(Request $request, AttendeeLog $attendeeLog): JsonResponse|I $props = [ 'attendeeLog' => $attendeeLog->load(['users' => function ($query) { - $query->select('id', 'badge_id', 'badge_name')->withPivot('type', 'created_at'); + $query->select('id', 'badge_id', 'badge_name') + ->withPivot('type', 'created_at', 'overridden_by_id', 'override_reason'); }]), + // Overriders whose accounts were deleted are still named on the entries they approved + 'overriders' => fn () => User::withTrashed()->whereIn( + 'id', + $attendeeLog->users->pluck('pivot.overridden_by_id')->filter()->unique(), + )->get(['id', 'badge_id', 'badge_name'])->keyBy('id'), + 'canOverrideRequirements' => fn () => $request->user()->can('overrideRequirements', $attendeeLog), 'event' => fn () => $attendeeLog->event, 'exportTypes' => fn () => Report::EXPORT_FILE_TYPES, ]; @@ -135,7 +142,7 @@ public function storeUser(AttendeeLogUserStoreRequest $request, AttendeeLog $att $badgeId = $request->validated('badge_id'); $user = User::whereBadgeId($badgeId)->first(); - // Check for an existing entry first so that attendees already in the log aren't checked again + // Check for an existing entry first so that attendees let in by an override aren't denied when scanned again if ($user && $attendeeLog->users()->whereUserId($user->id)->wherePivot('type', $type)->exists()) { return $this->alreadyPresentResponse($request, $type, $user); } @@ -165,7 +172,7 @@ public function storeUser(AttendeeLogUserStoreRequest $request, AttendeeLog $att } // Without a registration, a badge that isn't in the DB can't be logged at all. An existing user only - // needed it for the level check, so they're denied below instead. + // needed it for the level check, so they're denied below instead, where an override can still apply. if (!$user) { return $request->expectsJson() ? response()->json(['error' => "No registered attendee found with badge #{$badgeId}."], 404) @@ -182,11 +189,25 @@ public function storeUser(AttendeeLogUserStoreRequest $request, AttendeeLog $att && $attendeeLog->isRestrictedByRegistrationLevel() && $attendeeLog->allowsRegistration($registration)); + $overriddenBy = null; if ($failsRequirements) { - $error = $this->buildEntryDeniedMessage($attendeeLog, $badgeId, $user, $registration, $registrationMissing); - return $request->expectsJson() - ? response()->json(['error' => $error], 403) - : redirect()->back()->withErrors(['requirements' => $error]); + // Managers and admins can let the attendee in anyway, as can gatekeepers if the log allows it + $canOverride = $request->user()->can('overrideRequirements', $attendeeLog); + + if ($request->boolean('override') && $canOverride) { + $overriddenBy = $request->user(); + } else { + // A disallowed override is reported as a denial rather than an authorization error so that the + // scanning page can show it inline. This happens when the page still shows an override button + // after the log's override setting changes. + $error = $request->boolean('override') + ? 'Only managers and admins can let attendees into this log anyway.' + : $this->buildEntryDeniedMessage($attendeeLog, $badgeId, $user, $registration, $registrationMissing); + + return $request->expectsJson() + ? response()->json(['error' => $error, 'can_override' => $canOverride], 403) + : redirect()->back()->withErrors(['requirements' => $error]); + } } if (!$user) { @@ -196,15 +217,31 @@ public function storeUser(AttendeeLogUserStoreRequest $request, AttendeeLog $att return $this->alreadyPresentResponse($request, $type, $user); } - $attendeeLog->users()->attach($user, ['type' => $type]); + $overrideReason = $overriddenBy ? (trim($request->validated('override_reason') ?? '') ?: null) : null; + $attendeeLog->users()->attach($user, [ + 'type' => $type, + 'overridden_by_id' => $overriddenBy?->id, + 'override_reason' => $overrideReason, + ]); + + if ($overriddenBy) { + Log::info('Attendee log entry requirements overridden', [ + 'attendee_log' => $attendeeLog->id, + 'user' => $user->id, + 'overridden_by' => $overriddenBy->id, + 'reason' => $overrideReason, + ]); + } + $overrideNote = $overriddenBy ? ' by override' : ''; return $request->expectsJson() ? response()->json([ 'user' => $user->setVisible(['id', 'badge_id', 'badge_name']), 'type' => $type, + 'overridden' => (bool) $overriddenBy, 'logged_at' => now()->timezone(config('tracker.timezone'))->toDayDateTimeString(), ]) - : redirect()->back()->withSuccess("Added {$type} {$user->audit_name} to the log."); + : redirect()->back()->withSuccess("Added {$type} {$user->audit_name} to the log{$overrideNote}."); } /** diff --git a/app/Http/Requests/AttendeeLogStoreRequest.php b/app/Http/Requests/AttendeeLogStoreRequest.php index 5bdeca91..cb59700a 100644 --- a/app/Http/Requests/AttendeeLogStoreRequest.php +++ b/app/Http/Requests/AttendeeLogStoreRequest.php @@ -34,6 +34,7 @@ public function rules(): array { 'allowed_registration_levels.*' => 'required|string|max:128|distinct:ignore_case', 'allow_staff' => 'sometimes|boolean', 'min_volunteer_hours' => 'sometimes|nullable|numeric|decimal:0,2|min:1|max:999', + 'gatekeepers_can_override' => 'sometimes|boolean', ]; } } diff --git a/app/Http/Requests/AttendeeLogUpdateRequest.php b/app/Http/Requests/AttendeeLogUpdateRequest.php index 305a28ad..a92faa33 100644 --- a/app/Http/Requests/AttendeeLogUpdateRequest.php +++ b/app/Http/Requests/AttendeeLogUpdateRequest.php @@ -35,6 +35,7 @@ public function rules(): array { 'allowed_registration_levels.*' => 'required|string|max:128|distinct:ignore_case', 'allow_staff' => 'sometimes|boolean', 'min_volunteer_hours' => 'sometimes|nullable|numeric|decimal:0,2|min:1|max:999', + 'gatekeepers_can_override' => 'sometimes|boolean', ]; } } diff --git a/app/Http/Requests/AttendeeLogUserStoreRequest.php b/app/Http/Requests/AttendeeLogUserStoreRequest.php index 3a733408..37e64288 100644 --- a/app/Http/Requests/AttendeeLogUserStoreRequest.php +++ b/app/Http/Requests/AttendeeLogUserStoreRequest.php @@ -28,6 +28,8 @@ public function rules(): array { 'nullable', Rule::enum(AttendeeType::class), ], + 'override' => 'sometimes|boolean', + 'override_reason' => 'sometimes|nullable|string|max:255', ]; } } diff --git a/app/Models/AttendeeLog.php b/app/Models/AttendeeLog.php index 57216a46..2b57df28 100644 --- a/app/Models/AttendeeLog.php +++ b/app/Models/AttendeeLog.php @@ -19,6 +19,7 @@ * @property string[]|null $allowed_registration_levels * @property bool $allow_staff * @property float|null $min_volunteer_hours + * @property bool $gatekeepers_can_override * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property-read \App\Models\Event|null $event @@ -59,11 +60,13 @@ class AttendeeLog extends Model implements HasDisplayName { 'allowed_registration_levels', 'allow_staff', 'min_volunteer_hours', + 'gatekeepers_can_override', ]; protected $casts = [ 'allowed_registration_levels' => 'array', 'allow_staff' => 'boolean', 'min_volunteer_hours' => 'float', + 'gatekeepers_can_override' => 'boolean', ]; public function getActivitylogOptions(): LogOptions { @@ -74,6 +77,7 @@ public function getActivitylogOptions(): LogOptions { 'allowed_registration_levels', 'allow_staff', 'min_volunteer_hours', + 'gatekeepers_can_override', ]) ->logOnlyDirty() ->submitEmptyLogs(); @@ -99,7 +103,7 @@ public function event(): BelongsTo { */ public function users(): BelongsToMany { return $this->belongsToMany(User::class) - ->withPivot('type') + ->withPivot('type', 'overridden_by_id', 'override_reason') ->withTimestamps() ->withTrashed(); } diff --git a/app/Policies/AttendeeLogPolicy.php b/app/Policies/AttendeeLogPolicy.php index e4abdd7d..844f4d3f 100644 --- a/app/Policies/AttendeeLogPolicy.php +++ b/app/Policies/AttendeeLogPolicy.php @@ -82,4 +82,13 @@ public function manageAttendees(User $user, AttendeeLog $attendeeLog): bool { return $user->isAdmin() || ($isActive && ($user->isManager() || $attendeeLog->hasGatekeeper($user))); } + + /** + * Determine whether the user can let attendees into the log that don't meet its entry requirements. + * Anyone that can manage the log's gatekeepers can always do so, and gatekeepers can if the log allows it. + */ + public function overrideRequirements(User $user, AttendeeLog $attendeeLog): bool { + return $this->manageGatekeepers($user, $attendeeLog) + || ($attendeeLog->gatekeepers_can_override && $this->manageAttendees($user, $attendeeLog)); + } } diff --git a/app/Reports/AttendeeLogReport.php b/app/Reports/AttendeeLogReport.php index 5272ec29..4b4c7da7 100644 --- a/app/Reports/AttendeeLogReport.php +++ b/app/Reports/AttendeeLogReport.php @@ -26,6 +26,13 @@ class AttendeeLogReport extends EventReport implements FromQuery, ShouldAutoSize public AttendeeLog $attendeeLog; + /** + * Display names of users that overrode entry requirements for attendees in the log, keyed by user ID + * + * @var array|null + */ + protected ?array $overriderNames = null; + public function __construct(Event $event, string $attendeeLogId) { parent::__construct($event); $this->attendeeLog = AttendeeLog::findOrFail($attendeeLogId); @@ -41,8 +48,10 @@ public function map($user, $excelDates = true): array { return [ $user->badge_id, - $user->display_name, + static::escapeFormula($user->display_name), $excelDates ? Date::dateTimeToExcel($arrival) : $arrival, + static::escapeFormula($this->getOverriderName($user->pivot->overridden_by_id)), + static::escapeFormula($user->pivot->override_reason), ]; } @@ -51,9 +60,33 @@ public function headings(): array { 'Badge Number', 'Name', 'Arrival', + 'Overridden By', + 'Override Reason', ]; } + /** + * Gets the display name of a user that overrode entry requirements for an attendee + */ + protected function getOverriderName(?string $userId): ?string { + if (!$userId) return null; + $this->overriderNames ??= User::withTrashed()->whereIn( + 'id', + $this->attendeeLog->attendees()->wherePivotNotNull('overridden_by_id')->pluck('overridden_by_id'), + )->get()->mapWithKeys(fn (User $overrider) => [$overrider->id => $overrider->display_name])->all(); + return $this->overriderNames[$userId] ?? null; + } + + /** + * Prefixes text that a spreadsheet would treat as a formula (starting with =, +, -, @, a tab, or a carriage + * return) with an apostrophe, so that names and reasons entered by users are always shown as plain text. This + * applies to every export format, including CSV. + */ + protected static function escapeFormula(?string $value): ?string { + if ($value === null || !preg_match('/^[=+\-@\t\r]/', $value)) return $value; + return "'{$value}"; + } + public function columnFormats(): array { return [ 'A' => NumberFormat::FORMAT_NUMBER, diff --git a/database/migrations/2026_09_30_000001_add_requirement_overrides_to_attendee_logs.php b/database/migrations/2026_09_30_000001_add_requirement_overrides_to_attendee_logs.php new file mode 100644 index 00000000..6d600821 --- /dev/null +++ b/database/migrations/2026_09_30_000001_add_requirement_overrides_to_attendee_logs.php @@ -0,0 +1,35 @@ +boolean('gatekeepers_can_override')->default(false)->after('min_volunteer_hours'); + }); + + Schema::table('attendee_log_user', function (Blueprint $table) { + $table->foreignUuid('overridden_by_id')->nullable()->after('type')->constrained('users')->nullOnDelete(); + $table->string('override_reason', 255)->nullable()->after('overridden_by_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void { + Schema::table('attendee_log_user', function (Blueprint $table) { + $table->dropColumn('override_reason'); + $table->dropConstrainedForeignId('overridden_by_id'); + }); + + Schema::table('attendee_logs', function (Blueprint $table) { + $table->dropColumn('gatekeepers_can_override'); + }); + } +}; diff --git a/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue b/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue index 1c2eeba2..3868025c 100644 --- a/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue +++ b/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue @@ -23,38 +23,81 @@ -
- - - + + + + + + + + - - - - - - - - {{ form.errors.badge_id ?? form.errors.requirements }} - -
+ + + + {{ form.errors.badge_id }} + + + + +
+ + {{ denial }} + + + + A manager or admin can let them in anyway. +
+
@@ -65,7 +108,7 @@ import { useForm } from '@inertiajs/vue3'; import { useRoute } from '@/lib/route'; import type AttendeeLog from '@/data/AttendeeLog'; -import { faUserPlus } from '@fortawesome/free-solid-svg-icons'; +import { faUserCheck, faUserPlus } from '@fortawesome/free-solid-svg-icons'; import ResponsiveButton from '../Common/ResponsiveButton.vue'; import FullContentHeightPanel from '../Common/FullContentHeightPanel.vue'; @@ -73,9 +116,15 @@ import successSoundFile from '@/../audio/success.ogg'; import success2SoundFile from '@/../audio/success2.ogg'; import alertSoundFile from '@/../audio/alert.ogg'; -const { attendeeLog, gatekeeper = false } = defineProps<{ +const { + attendeeLog, + gatekeeper = false, + canOverride = false, +} = defineProps<{ attendeeLog: AttendeeLog; gatekeeper?: boolean; + /** Whether the user can let attendees in that don't meet the log's entry requirements */ + canOverride?: boolean; }>(); const route = useRoute(); @@ -94,6 +143,15 @@ const form = useForm({ badge_id: '', type: gatekeeper ? 'gatekeeper' : 'attendee', }); +const overrideForm = useForm({ + badge_id: '', + type: 'attendee', + override: true, + override_reason: '', +}); + +// A run of 4 or more digits in the reason is most likely a badge scanned while the reason field had focus +const reasonLooksLikeBadge = computed(() => /\d{4,}/.test(overrideForm.override_reason)); const input = useTemplateRef('input'); const badgeNumberId = useId(); @@ -101,12 +159,20 @@ const successSound = new Audio(successSoundFile); const success2Sound = new Audio(success2SoundFile); const alertSound = new Audio(alertSoundFile); +const denial = computed(() => form.errors.requirements); + +// The override permission is reloaded with every scan so that changes to the log's settings made while someone is +// scanning are picked up without refreshing the page +const reloadProps = ['attendeeLog', 'overriders', 'canOverrideRequirements', 'flash']; + function create() { + const badgeId = form.badge_id; + form.put(route('attendee-logs.users.store', attendeeLog.id), { replace: true, preserveState: true, preserveScroll: true, - only: ['attendeeLog', 'flash'], + only: reloadProps, onSuccess() { form.reset(); @@ -120,6 +186,10 @@ function create() { // @ts-expect-error input.value!.$el.focus(); + // Remember the denied badge so it can be let in with an override + overrideForm.badge_id = denial.value ? badgeId : ''; + overrideForm.override_reason = ''; + if (gatekeeper) return; if (form.errors.badge_id?.includes('already present')) success2Sound.play(); @@ -127,4 +197,30 @@ function create() { }, }); } + +/** + * Lets the most recently denied attendee in despite the log's entry requirements + */ +function override() { + if (reasonLooksLikeBadge.value) return; + overrideForm.put(route('attendee-logs.users.store', attendeeLog.id), { + replace: true, + preserveState: true, + preserveScroll: true, + only: reloadProps, + + onSuccess() { + form.clearErrors(); + overrideForm.reset(); + successSound.play(); + }, + onError() { + form.setError('requirements', Object.values(overrideForm.errors)[0] ?? 'Override failed.'); + }, + onFinish() { + // @ts-expect-error + input.value!.$el.focus(); + }, + }); +} diff --git a/resources/js/Components/AttendeeLog/AttendeeLogRequirementsPanel.vue b/resources/js/Components/AttendeeLog/AttendeeLogRequirementsPanel.vue index 906a7154..14bf1bbb 100644 --- a/resources/js/Components/AttendeeLog/AttendeeLogRequirementsPanel.vue +++ b/resources/js/Components/AttendeeLog/AttendeeLogRequirementsPanel.vue @@ -106,6 +106,27 @@ + + +
(null); const levelsOn = ref(false); const levels = ref([]); +const gatekeepersCanOverride = ref(false); resetFromLog(); const form = useForm<{ allowed_registration_levels: string[] | null; allow_staff: boolean; min_volunteer_hours: number | null; -}>({ allowed_registration_levels: null, allow_staff: false, min_volunteer_hours: null }); + gatekeepers_can_override: boolean; +}>({ + allowed_registration_levels: null, + allow_staff: false, + min_volunteer_hours: null, + gatekeepers_can_override: false, +}); const levelsError = computed( () => @@ -197,6 +231,7 @@ const payload = computed(() => ({ allowed_registration_levels: levelsOn.value && levels.value.length ? levels.value : null, allow_staff: staffOn.value, min_volunteer_hours: hoursOn.value ? hours.value : null, + gatekeepers_can_override: gatekeepersCanOverride.value, })); const hoursValid = computed(() => hours.value !== null && hours.value >= 1); @@ -208,7 +243,8 @@ const isDirty = computed(() => { return ( current !== saved || payload.value.allow_staff !== attendeeLog.allow_staff || - payload.value.min_volunteer_hours !== attendeeLog.min_volunteer_hours + payload.value.min_volunteer_hours !== attendeeLog.min_volunteer_hours || + payload.value.gatekeepers_can_override !== attendeeLog.gatekeepers_can_override ); }); @@ -220,6 +256,7 @@ watch( attendeeLog.allowed_registration_levels, attendeeLog.allow_staff, attendeeLog.min_volunteer_hours, + attendeeLog.gatekeepers_can_override, ]), resetFromLog, ); @@ -233,6 +270,7 @@ function resetFromLog() { hours.value = attendeeLog.min_volunteer_hours; levelsOn.value = Boolean(attendeeLog.allowed_registration_levels?.length); levels.value = [...(attendeeLog.allowed_registration_levels ?? [])]; + gatekeepersCanOverride.value = attendeeLog.gatekeepers_can_override; } /** diff --git a/resources/js/Components/AttendeeLog/AttendeesTable.vue b/resources/js/Components/AttendeeLog/AttendeesTable.vue index c8455a5d..6f699023 100644 --- a/resources/js/Components/AttendeeLog/AttendeesTable.vue +++ b/resources/js/Components/AttendeeLog/AttendeesTable.vue @@ -15,7 +15,15 @@ > - + + +
- + @@ -30,7 +35,13 @@
- + @@ -48,6 +59,7 @@ import { useUser } from '@/lib/user'; import { useReadOnly } from '@/lib/readonly'; import type AttendeeLog from '@/data/AttendeeLog'; import type Event from '@/data/Event'; +import type { Overriders } from '@/data/Attendee'; import type RegistrationLevel from '@/data/RegistrationLevel'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; @@ -58,11 +70,13 @@ import AttendeeLogRequirementsPanel from '@/Components/AttendeeLog/AttendeeLogRe import FullContentHeightPanel from '@/Components/Common/FullContentHeightPanel.vue'; import BreadcrumbsPage from '@/Components/App/BreadcrumbsPage.vue'; -const { attendeeLog, event, registrationLevels } = defineProps<{ +const { attendeeLog, event, registrationLevels, overriders, canOverrideRequirements } = defineProps<{ attendeeLog: AttendeeLog; event: Event; exportTypes?: Record; registrationLevels?: RegistrationLevel[] | null; + overriders?: Overriders; + canOverrideRequirements?: boolean; }>(); const { isAdmin, isManager } = useUser(); diff --git a/resources/js/data/Attendee.ts b/resources/js/data/Attendee.ts index 3f2eb035..eeb4cb9f 100644 --- a/resources/js/data/Attendee.ts +++ b/resources/js/data/Attendee.ts @@ -7,5 +7,12 @@ export default interface Attendee { pivot: { type: 'attendee' | 'gatekeeper'; created_at: string; + /** User that let the attendee in despite the log's entry requirements */ + overridden_by_id?: UserId | null; + /** Optional explanation given for the override */ + override_reason?: string | null; }; } + +/** Basic details of users that overrode entry requirements, keyed by user ID */ +export type Overriders = Record>; diff --git a/resources/js/data/AttendeeLog.ts b/resources/js/data/AttendeeLog.ts index 3276e26d..5a8dbbe4 100644 --- a/resources/js/data/AttendeeLog.ts +++ b/resources/js/data/AttendeeLog.ts @@ -8,6 +8,7 @@ export default interface AttendeeLog { allowed_registration_levels: string[] | null; allow_staff: boolean; min_volunteer_hours: number | null; + gatekeepers_can_override: boolean; users?: Attendee[]; users_count?: number; attendees_count?: number; diff --git a/tests/Feature/AttendeeLogEntryRequirementsTest.php b/tests/Feature/AttendeeLogEntryRequirementsTest.php index 18ae1a79..08272913 100644 --- a/tests/Feature/AttendeeLogEntryRequirementsTest.php +++ b/tests/Feature/AttendeeLogEntryRequirementsTest.php @@ -7,11 +7,15 @@ use App\Models\Role; use App\Models\TimeEntry; use App\Models\User; +use App\Reports\AttendeeLogReport; use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Psr7\Request as GuzzleRequest; use GuzzleHttp\Psr7\Response as GuzzleResponse; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; +use Maatwebsite\Excel\Facades\Excel; +use PhpOffice\PhpSpreadsheet\Cell\DataType; +use PhpOffice\PhpSpreadsheet\IOFactory; uses(RefreshDatabase::class); @@ -285,6 +289,134 @@ function volunteerWithHours(string $eventId, float $hours, array $attributes = [ ->assertOk(); }); +it('lets admins override entry requirements and records who did it', function () { + $volunteer = volunteerWithHours($this->log->event_id, 11.5); + $this->log->update(['min_volunteer_hours' => 12]); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id]) + ->assertForbidden() + ->assertJsonPath('can_override', true); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id, 'override' => true]) + ->assertOk() + ->assertJsonPath('overridden', true); + + expect($this->log->attendees()->first()->pivot->overridden_by_id)->toBe($this->admin->id); +}); + +it('records an optional reason with overrides', function () { + $withReason = volunteerWithHours($this->log->event_id, 11.5); + $withoutReason = volunteerWithHours($this->log->event_id, 11.5); + $this->log->update(['min_volunteer_hours' => 12]); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), [ + 'badge_id' => $withReason->badge_id, + 'override' => true, + 'override_reason' => ' Worked setup before clocking in ', + ]) + ->assertOk(); + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), [ + 'badge_id' => $withoutReason->badge_id, + 'override' => true, + 'override_reason' => ' ', + ]) + ->assertOk(); + + $reasons = $this->log->attendees()->get()->mapWithKeys(fn ($user) => [$user->id => $user->pivot->override_reason]); + expect($reasons[$withReason->id])->toBe('Worked setup before clocking in') + ->and($reasons[$withoutReason->id])->toBeNull(); +}); + +it('ignores override reasons when the attendee meets the requirements', function () { + $volunteer = volunteerWithHours($this->log->event_id, 13); + $this->log->update(['min_volunteer_hours' => 12]); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), [ + 'badge_id' => $volunteer->badge_id, + 'override' => true, + 'override_reason' => 'Not needed', + ]) + ->assertOk(); + + expect($this->log->attendees()->first()->pivot->override_reason)->toBeNull(); +}); + +it('does not mark entries as overridden when the attendee meets the requirements', function () { + $volunteer = volunteerWithHours($this->log->event_id, 13); + $this->log->update(['min_volunteer_hours' => 12]); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id, 'override' => true]) + ->assertOk() + ->assertJsonPath('overridden', false); + + expect($this->log->attendees()->first()->pivot->overridden_by_id)->toBeNull(); +}); + +it('does not let gatekeepers override entry requirements', function () { + $gatekeeper = User::factory()->create(['role' => Role::Volunteer]); + $this->log->users()->attach($gatekeeper, ['type' => 'gatekeeper']); + \App\Models\Setting::set('active-event', $this->log->event_id); + \App\Models\Setting::set('lockdown', false); + $volunteer = volunteerWithHours($this->log->event_id, 11.5); + $this->log->update(['min_volunteer_hours' => 12]); + + $this->actingAs($gatekeeper) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id]) + ->assertForbidden() + ->assertJsonPath('can_override', false); + + $this->actingAs($gatekeeper) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id, 'override' => true]) + ->assertForbidden() + ->assertJsonPath('error', 'Only managers and admins can let attendees into this log anyway.'); + + // The page gets an inline error rather than an authorization error page + $this->actingAs($gatekeeper) + ->from(route('attendee-logs.show', $this->log)) + ->put(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id, 'override' => true]) + ->assertRedirect(route('attendee-logs.show', $this->log)) + ->assertSessionHasErrors(['requirements' => 'Only managers and admins can let attendees into this log anyway.']); + + expect($this->log->hasAttendee($volunteer))->toBeFalse(); +}); + +it('lets gatekeepers override entry requirements when the log allows it', function () { + $gatekeeper = User::factory()->create(['role' => Role::Volunteer]); + $this->log->users()->attach($gatekeeper, ['type' => 'gatekeeper']); + \App\Models\Setting::set('active-event', $this->log->event_id); + \App\Models\Setting::set('lockdown', false); + $volunteer = volunteerWithHours($this->log->event_id, 11.5); + $this->log->update(['min_volunteer_hours' => 12, 'gatekeepers_can_override' => true]); + + $this->actingAs($gatekeeper) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id]) + ->assertForbidden() + ->assertJsonPath('can_override', true); + + $this->actingAs($gatekeeper) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id, 'override' => true]) + ->assertOk(); + + expect($this->log->attendees()->first()->pivot->overridden_by_id)->toBe($gatekeeper->id); +}); + +it('reports overridden attendees as already present instead of denying them again', function () { + $volunteer = volunteerWithHours($this->log->event_id, 2); + $this->log->update(['min_volunteer_hours' => 12]); + $this->log->users()->attach($volunteer, ['type' => 'attendee', 'overridden_by_id' => $this->admin->id]); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id]) + ->assertUnprocessable() + ->assertJsonPath('error', fn (string $error) => str_contains($error, 'already present')); +}); + it('puts denials under their own error key for the frontend', function () { $volunteer = volunteerWithHours($this->log->event_id, 2); $this->log->update(['min_volunteer_hours' => 12]); @@ -297,11 +429,11 @@ function volunteerWithHours(string $eventId, float $hours, array $attributes = [ ->assertSessionDoesntHaveErrors('badge_id'); }); -it('denies existing users whose registration level cannot be checked', function () { +it('denies existing users whose level cannot be checked, allowing an override', function () { $volunteer = User::factory()->create(['role' => Role::Volunteer, 'badge_name' => 'Foxy']); $this->log->update(['allowed_registration_levels' => ['Sponsor']]); - ConCat::shouldReceive('authorize')->once(); - ConCat::shouldReceive('getRegistration')->once()->andThrow(new RuntimeException('ConCat is down')); + ConCat::shouldReceive('authorize')->twice(); + ConCat::shouldReceive('getRegistration')->twice()->andThrow(new RuntimeException('ConCat is down')); $this->actingAs($this->admin) ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id]) @@ -310,18 +442,11 @@ function volunteerWithHours(string $eventId, float $hours, array $attributes = [ 'error', "Denied: Foxy (#{$volunteer->badge_id}) couldn't have their registration level checked with ConCat.", ); -}); - -it('reports unknown badges as not found when ConCat cannot be reached', function () { - $this->log->update(['allowed_registration_levels' => ['Sponsor']]); - ConCat::shouldReceive('authorize')->once(); - ConCat::shouldReceive('getRegistration')->once()->andThrow(new RuntimeException('ConCat is down')); $this->actingAs($this->admin) - ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => 1234]) - ->assertNotFound(); - - expect(User::whereBadgeId(1234)->exists())->toBeFalse(); + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id, 'override' => true]) + ->assertOk(); + expect($this->log->attendees()->first()->pivot->overridden_by_id)->toBe($this->admin->id); }); it('tells existing users without a ConCat registration apart from ConCat being unreachable', function () { @@ -340,6 +465,33 @@ function volunteerWithHours(string $eventId, float $hours, array $attributes = [ ->assertJsonPath('error', "Denied: Foxy (#{$volunteer->badge_id}) doesn't have a ConCat registration."); }); +it('reports unknown badges as not found when ConCat cannot be reached, even with an override', function () { + $this->log->update(['allowed_registration_levels' => ['Sponsor']]); + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('getRegistration')->once()->andThrow(new RuntimeException('ConCat is down')); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => 1234, 'override' => true]) + ->assertNotFound(); + + expect(User::whereBadgeId(1234)->exists())->toBeFalse(); +}); + +it('creates users from ConCat when overriding entry requirements for unknown badges', function () { + $this->log->update(['min_volunteer_hours' => 12]); + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('getRegistration')->once()->andReturn(fakeRegistration(1234, 'Attendee')); + + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => 1234, 'override' => true]) + ->assertOk() + ->assertJsonPath('overridden', true); + + $user = User::whereBadgeId(1234)->firstOrFail(); + expect($user->role)->toBe(Role::Attendee) + ->and($this->log->attendees()->first()->pivot->overridden_by_id)->toBe($this->admin->id); +}); + it('rejects a minimum of less than 1 volunteer hour or more than 2 decimal places', function (float $hours) { $this->actingAs($this->admin) ->patchJson(route('attendee-logs.update', $this->log), ['min_volunteer_hours' => $hours]) @@ -365,9 +517,82 @@ function volunteerWithHours(string $eventId, float $hours, array $attributes = [ expect($this->log->fresh()->allow_staff)->toBeFalse(); }); +it('exports who overrode entry requirements and why, without evaluating formulas', function (string $writerType) { + // The overrider's account is deleted afterwards, and should still be named + $overrider = User::factory()->create(['role' => Role::Manager]); + $volunteer = volunteerWithHours($this->log->event_id, 2, ['badge_name' => '@Foxy']); + $this->log->users()->attach($volunteer, [ + 'type' => 'attendee', + 'overridden_by_id' => $overrider->id, + 'override_reason' => '=1+1', + ]); + $overrider->delete(); + + $report = new AttendeeLogReport($this->log->event, $this->log->id); + $path = tempnam(sys_get_temp_dir(), 'report') . '.' . strtolower($writerType); + file_put_contents($path, Excel::raw($report, $writerType)); + $sheet = IOFactory::load($path)->getActiveSheet(); + unlink($path); + + expect($sheet->getCell('D1')->getValue())->toBe('Overridden By') + ->and($sheet->getCell('E1')->getValue())->toBe('Override Reason') + ->and($sheet->getCell('D2')->getValue())->toBe($overrider->display_name) + ->and($sheet->getCell('E2')->getValue())->toBe("'=1+1") + ->and($sheet->getCell('E2')->getDataType())->toBe(DataType::TYPE_STRING); +})->with([\Maatwebsite\Excel\Excel::XLSX, \Maatwebsite\Excel\Excel::CSV]); + +it('escapes names that a spreadsheet would treat as formulas', function () { + $volunteer = volunteerWithHours($this->log->event_id, 2); + $this->log->users()->attach($volunteer, ['type' => 'attendee']); + $report = new AttendeeLogReport($this->log->event, $this->log->id); + + foreach (['=SUM(A1)', '+1', '-1', '@Foxy', "\tTab"] as $name) { + $volunteer->forceFill(['badge_name' => $name])->save(); + $row = $report->map($this->log->attendees()->first()); + expect($row[1])->toStartWith("'"); + } + + $volunteer->forceFill(['badge_name' => 'Foxy'])->save(); + expect($report->map($this->log->attendees()->first())[3])->toBeNull(); +}); + /** * Creates a gatekeeper for the test log and makes its event active so the gatekeeper can use it */ +function makeGatekeeper(AttendeeLog $log): User { + $gatekeeper = User::factory()->create(['role' => Role::Volunteer]); + $log->users()->attach($gatekeeper, ['type' => 'gatekeeper']); + \App\Models\Setting::set('active-event', $log->event_id); + \App\Models\Setting::set('lockdown', false); + return $gatekeeper; +} + +it('gives the page the viewer\'s override permissions and the overriders, including deleted ones', function () { + $gatekeeper = makeGatekeeper($this->log); + $overrider = User::factory()->create(['role' => Role::Manager, 'badge_name' => 'Former Manager']); + $this->log->users()->attach(volunteerWithHours($this->log->event_id, 2), [ + 'type' => 'attendee', + 'overridden_by_id' => $overrider->id, + ]); + $overrider->delete(); + $headers = ['X-Inertia' => 'true', 'X-Inertia-Version' => inertiaVersion()]; + + $this->actingAs($gatekeeper) + ->get(route('attendee-logs.show', $this->log), $headers) + ->assertOk() + ->assertJsonPath('props.canOverrideRequirements', false) + ->assertJsonPath("props.overriders.{$overrider->id}.badge_name", 'Former Manager'); + + $this->log->update(['gatekeepers_can_override' => true]); + $this->actingAs($gatekeeper) + ->get(route('attendee-logs.show', $this->log), $headers) + ->assertJsonPath('props.canOverrideRequirements', true); + + $this->actingAs($this->admin) + ->get(route('attendee-logs.show', $this->log), $headers) + ->assertJsonPath('props.canOverrideRequirements', true); +}); + it('lets admins set and clear the allowed levels', function () { $this->actingAs($this->admin) ->patchJson(route('attendee-logs.update', $this->log), ['allowed_registration_levels' => ['Sponsor']]) From cabf95545ba3bf529d49cd57837ca2d289c97a7a Mon Sep 17 00:00:00 2001 From: tmolick Date: Fri, 2 Oct 2026 15:20:26 -0700 Subject: [PATCH 3/5] Deny banned users entry to attendee logs Banned users are now denied on every attendee log, including logs without entry requirements. The denial lists the ban alongside any requirement the user also fails, and only managers and admins can override it, even on logs that let gatekeepers override other denials. Denial messages now show an attendee's exact volunteer hours only to users that can override the denial. Everyone else sees the required number of hours instead. --- .../Controllers/AttendeeLogController.php | 95 ++++++++++---- .../AttendeeLog/AttendeeCreatePanel.vue | 17 ++- resources/js/Pages/AttendeeLogDetails.vue | 4 +- .../AttendeeLogEntryRequirementsTest.php | 120 +++++++++++++++++- 4 files changed, 202 insertions(+), 34 deletions(-) diff --git a/app/Http/Controllers/AttendeeLogController.php b/app/Http/Controllers/AttendeeLogController.php index f020cc4a..e952e6d5 100644 --- a/app/Http/Controllers/AttendeeLogController.php +++ b/app/Http/Controllers/AttendeeLogController.php @@ -67,6 +67,7 @@ public function show(Request $request, AttendeeLog $attendeeLog): JsonResponse|I $attendeeLog->users->pluck('pivot.overridden_by_id')->filter()->unique(), )->get(['id', 'badge_id', 'badge_name'])->keyBy('id'), 'canOverrideRequirements' => fn () => $request->user()->can('overrideRequirements', $attendeeLog), + 'canOverrideBanned' => fn () => $request->user()->can('manageGatekeepers', $attendeeLog), 'event' => fn () => $attendeeLog->event, 'exportTypes' => fn () => Report::EXPORT_FILE_TYPES, ]; @@ -189,10 +190,17 @@ public function storeUser(AttendeeLogUserStoreRequest $request, AttendeeLog $att && $attendeeLog->isRestrictedByRegistrationLevel() && $attendeeLog->allowsRegistration($registration)); + // Banned users are denied from every log, whether or not they meet its requirements + $isBanned = $type === 'attendee' && $user?->isBanned(); + $overriddenBy = null; - if ($failsRequirements) { - // Managers and admins can let the attendee in anyway, as can gatekeepers if the log allows it - $canOverride = $request->user()->can('overrideRequirements', $attendeeLog); + if ($isBanned || $failsRequirements) { + // Managers and admins can let the attendee in anyway, as can gatekeepers if the log allows it. Only + // managers and admins can let banned users in. + $canOverrideRequirements = $request->user()->can('overrideRequirements', $attendeeLog); + $canOverride = $isBanned + ? $request->user()->can('manageGatekeepers', $attendeeLog) + : $canOverrideRequirements; if ($request->boolean('override') && $canOverride) { $overriddenBy = $request->user(); @@ -200,13 +208,27 @@ public function storeUser(AttendeeLogUserStoreRequest $request, AttendeeLog $att // A disallowed override is reported as a denial rather than an authorization error so that the // scanning page can show it inline. This happens when the page still shows an override button // after the log's override setting changes. - $error = $request->boolean('override') - ? 'Only managers and admins can let attendees into this log anyway.' - : $this->buildEntryDeniedMessage($attendeeLog, $badgeId, $user, $registration, $registrationMissing); + if ($request->boolean('override')) { + $error = $isBanned + ? 'Only managers and admins can let banned attendees in.' + : 'Only managers and admins can let attendees into this log anyway.'; + } else { + $error = $this->buildEntryDeniedMessage( + $attendeeLog, + $badgeId, + $user, + $registration, + $registrationMissing, + $isBanned, + $failsRequirements, + $canOverrideRequirements, + ); + } + // Banned denials use their own error key since fewer people can override them return $request->expectsJson() - ? response()->json(['error' => $error, 'can_override' => $canOverride], 403) - : redirect()->back()->withErrors(['requirements' => $error]); + ? response()->json(['error' => $error, 'banned' => $isBanned, 'can_override' => $canOverride], 403) + : redirect()->back()->withErrors([$isBanned ? 'banned' : 'requirements' => $error]); } } @@ -282,37 +304,56 @@ protected function buildEntryDeniedMessage( ?User $user, ?\stdClass $registration, bool $registrationMissing, + bool $isBanned, + bool $failsRequirements, + bool $showExactHours, ): string { $badgeName = $registration?->badgeName ?? $user?->badge_name; $who = $badgeName ? "{$badgeName} (#{$badgeId})" : "Badge #{$badgeId}"; $reasons = []; - $levelUnknown = $attendeeLog->isRestrictedByRegistrationLevel() && !$registration; - if ($levelUnknown && $registrationMissing) { - $reasons[] = "doesn't have a ConCat registration"; - } elseif ($levelUnknown) { - $reasons[] = "couldn't have their registration level checked with ConCat"; - } elseif ($attendeeLog->isRestrictedByRegistrationLevel()) { - $level = $registration->productDisplayName ?? $registration->productName ?? 'unknown'; - $reasons[] = "is registered as {$level}"; - } - if ($attendeeLog->allow_staff) $reasons[] = "isn't staff"; - if ($attendeeLog->min_volunteer_hours !== null) { - $hours = $user ? $attendeeLog->getVolunteerHours($user) : 0; - $reasons[] = sprintf( - 'has %s of %s required volunteer hours', - static::formatHours(floor($hours * 10) / 10), - static::formatHours($attendeeLog->min_volunteer_hours), - ); + if ($isBanned) $reasons[] = 'is banned'; + + $levelUnknown = false; + if ($failsRequirements) { + $levelUnknown = $attendeeLog->isRestrictedByRegistrationLevel() && !$registration; + if ($levelUnknown && $registrationMissing) { + $reasons[] = "doesn't have a ConCat registration"; + } elseif ($levelUnknown) { + $reasons[] = "couldn't have their registration level checked with ConCat"; + } elseif ($attendeeLog->isRestrictedByRegistrationLevel()) { + $level = $registration->productDisplayName ?? $registration->productName ?? 'unknown'; + $reasons[] = "is registered as {$level}"; + } + if ($attendeeLog->allow_staff) $reasons[] = "isn't staff"; + if ($attendeeLog->min_volunteer_hours !== null) { + // Exact hours are only shown to users that can act on them with an override + $required = static::formatHours($attendeeLog->min_volunteer_hours); + if ($showExactHours) { + $hours = $user ? $attendeeLog->getVolunteerHours($user) : 0; + $reasons[] = sprintf( + 'has %s of %s required volunteer hours', + static::formatHours(floor($hours * 10) / 10), + $required, + ); + } else { + $reasons[] = "hasn't reached the required {$required} volunteer hours"; + } + } } // A registration level on its own doesn't explain the denial, so state that the level isn't allowed - $onlyLevel = count($reasons) === 1 && $attendeeLog->isRestrictedByRegistrationLevel() && !$levelUnknown; + $onlyLevel = count($reasons) === 1 + && $failsRequirements + && $attendeeLog->isRestrictedByRegistrationLevel() + && !$levelUnknown; $joined = $onlyLevel ? "{$reasons[0]}, which isn't allowed in this log" : Arr::join($reasons, ', ', count($reasons) > 2 ? ', and ' : ' and '); - return "Denied: {$who} {$joined}."; + $message = "Denied: {$who} {$joined}."; + if ($isBanned) $message .= ' A manager or admin must approve banned attendees.'; + return $message; } /** diff --git a/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue b/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue index 3868025c..c0acf158 100644 --- a/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue +++ b/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue @@ -68,7 +68,7 @@ {{ denial }} -