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:
+
+
+
@@ -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 }}
+
+
+
+
+
+
+ {{ denial }}
+
+
+
+
+
+
+
+
+ The reason contains a long number, which is likely a scanned badge. Remove it before
+ allowing them in.
+
+
+ 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 @@
+
+
+
+
+
+ Who can let in denied attendees anyway
+
+
+ Overrides are recorded with who approved them and shown in the attendee list and export.
+
+
+
+
(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 @@
>
-
+
+
+ {{ attendee.badge_name }}
+
+
+ {{ overrideSummary(attendee) }}
+
+
+
@@ -60,6 +68,7 @@ import { toRef } from 'vue';
import { useUser } from '@/lib/user';
import type AttendeeLog from '@/data/AttendeeLog';
import type Attendee from '@/data/Attendee';
+import type { Overriders } from '@/data/Attendee';
import AttendeeActionButtons from './AttendeeActionButtons.vue';
import SkeletonTable from '../Common/SkeletonTable.vue';
@@ -70,15 +79,29 @@ const {
gatekeeper = false,
readOnly = false,
skeleton = false,
+ overriders,
} = defineProps<{
attendeeLog: AttendeeLog;
attendees?: Attendee[];
gatekeeper?: boolean;
readOnly?: boolean;
skeleton?: boolean;
+ overriders?: Overriders;
}>();
const { isManager } = useUser();
+/**
+ * Describes who let an attendee in despite the log's entry requirements, and why. It's shown as text rather than a
+ * tooltip so that it's available to keyboard, screen reader, and touch users.
+ */
+function overrideSummary(attendee: Attendee): string {
+ const overrider = overriders?.[attendee.pivot.overridden_by_id!];
+ const by = overrider
+ ? `Allowed by ${overrider.badge_name ?? 'Unknown'} (#${overrider.badge_id})`
+ : 'Allowed by override';
+ return attendee.pivot.override_reason ? `${by}: ${attendee.pivot.override_reason}` : by;
+}
+
const hasActions = toRef(() => !readOnly && (!gatekeeper || isManager.value));
diff --git a/resources/js/Pages/AttendeeLogDetails.vue b/resources/js/Pages/AttendeeLogDetails.vue
index 3affaea5..dfaf2f39 100644
--- a/resources/js/Pages/AttendeeLogDetails.vue
+++ b/resources/js/Pages/AttendeeLogDetails.vue
@@ -21,7 +21,12 @@
-
+
@@ -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 }}
-
+
();
const route = useRoute();
@@ -159,11 +162,13 @@ const successSound = new Audio(successSoundFile);
const success2Sound = new Audio(success2SoundFile);
const alertSound = new Audio(alertSoundFile);
-const denial = computed(() => form.errors.requirements);
+// Denials of banned attendees use their own error key, since only managers and admins can override them
+const denial = computed(() => form.errors.banned ?? form.errors.requirements);
+const canOverrideDenial = computed(() => (form.errors.banned ? canOverrideBanned : canOverride));
// 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'];
+const reloadProps = ['attendeeLog', 'overriders', 'canOverrideRequirements', 'canOverrideBanned', 'flash'];
function create() {
const badgeId = form.badge_id;
@@ -215,7 +220,11 @@ function override() {
successSound.play();
},
onError() {
- form.setError('requirements', Object.values(overrideForm.errors)[0] ?? 'Override failed.');
+ // Keep the failure under the same key as the denial so the right override permission still applies
+ form.setError(
+ form.errors.banned ? 'banned' : 'requirements',
+ Object.values(overrideForm.errors)[0] ?? 'Override failed.',
+ );
},
onFinish() {
// @ts-expect-error
diff --git a/resources/js/Pages/AttendeeLogDetails.vue b/resources/js/Pages/AttendeeLogDetails.vue
index dfaf2f39..49e75b37 100644
--- a/resources/js/Pages/AttendeeLogDetails.vue
+++ b/resources/js/Pages/AttendeeLogDetails.vue
@@ -25,6 +25,7 @@
:attendee-log
:read-only
:can-override="canOverrideRequirements"
+ :can-override-banned="canOverrideBanned"
class="grow basis-1"
/>
@@ -70,13 +71,14 @@ import AttendeeLogRequirementsPanel from '@/Components/AttendeeLog/AttendeeLogRe
import FullContentHeightPanel from '@/Components/Common/FullContentHeightPanel.vue';
import BreadcrumbsPage from '@/Components/App/BreadcrumbsPage.vue';
-const { attendeeLog, event, registrationLevels, overriders, canOverrideRequirements } = defineProps<{
+const { attendeeLog, event, registrationLevels, overriders, canOverrideRequirements, canOverrideBanned } = defineProps<{
attendeeLog: AttendeeLog;
event: Event;
exportTypes?: Record;
registrationLevels?: RegistrationLevel[] | null;
overriders?: Overriders;
canOverrideRequirements?: boolean;
+ canOverrideBanned?: boolean;
}>();
const { isAdmin, isManager } = useUser();
diff --git a/tests/Feature/AttendeeLogEntryRequirementsTest.php b/tests/Feature/AttendeeLogEntryRequirementsTest.php
index 08272913..0b24cdee 100644
--- a/tests/Feature/AttendeeLogEntryRequirementsTest.php
+++ b/tests/Feature/AttendeeLogEntryRequirementsTest.php
@@ -567,6 +567,78 @@ function makeGatekeeper(AttendeeLog $log): User {
return $gatekeeper;
}
+it('denies banned users from logs without entry requirements', function () {
+ $banned = User::factory()->create(['role' => Role::Banned, 'badge_name' => 'Trouble']);
+
+ $this->actingAs($this->admin)
+ ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $banned->badge_id])
+ ->assertForbidden()
+ ->assertJsonPath('banned', true)
+ ->assertJsonPath('can_override', true)
+ ->assertJsonPath(
+ 'error',
+ "Denied: Trouble (#{$banned->badge_id}) is banned. A manager or admin must approve banned attendees.",
+ );
+
+ expect($this->log->hasAttendee($banned))->toBeFalse();
+});
+
+it('lists the ban alongside any requirements the banned user also fails', function () {
+ $banned = volunteerWithHours($this->log->event_id, 2, ['role' => Role::Banned, 'badge_name' => 'Trouble']);
+ $this->log->update(['allow_staff' => true, 'min_volunteer_hours' => 12]);
+
+ $this->actingAs($this->admin)
+ ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $banned->badge_id])
+ ->assertForbidden()
+ ->assertJsonPath(
+ 'error',
+ "Denied: Trouble (#{$banned->badge_id}) is banned, isn't staff, and has 2 of 12 required volunteer hours. "
+ . 'A manager or admin must approve banned attendees.',
+ );
+});
+
+it('denies banned users even when they meet the entry requirements', function () {
+ $banned = volunteerWithHours($this->log->event_id, 13, ['role' => Role::Banned, 'badge_name' => 'Trouble']);
+ $this->log->update(['min_volunteer_hours' => 12]);
+
+ $this->actingAs($this->admin)
+ ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $banned->badge_id])
+ ->assertForbidden()
+ ->assertJsonPath(
+ 'error',
+ "Denied: Trouble (#{$banned->badge_id}) is banned. A manager or admin must approve banned attendees.",
+ );
+});
+
+it('lets admins override bans', function () {
+ $banned = User::factory()->create(['role' => Role::Banned]);
+
+ $this->actingAs($this->admin)
+ ->putJson(route('attendee-logs.users.store', $this->log), [
+ 'badge_id' => $banned->badge_id,
+ 'override' => true,
+ 'override_reason' => 'Ban lifted at the door',
+ ])
+ ->assertOk()
+ ->assertJsonPath('overridden', true);
+
+ expect($this->log->attendees()->first()->pivot->override_reason)->toBe('Ban lifted at the door');
+});
+
+it('lets managers override bans on logs for the active event', function () {
+ $manager = User::factory()->create(['role' => Role::Manager]);
+ \App\Models\Setting::set('active-event', $this->log->event_id);
+ \App\Models\Setting::set('lockdown', false);
+ $banned = User::factory()->create(['role' => Role::Banned]);
+
+ $this->actingAs($manager)
+ ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $banned->badge_id, 'override' => true])
+ ->assertOk()
+ ->assertJsonPath('overridden', true);
+
+ expect($this->log->attendees()->first()->pivot->overridden_by_id)->toBe($manager->id);
+});
+
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']);
@@ -581,16 +653,60 @@ function makeGatekeeper(AttendeeLog $log): User {
->get(route('attendee-logs.show', $this->log), $headers)
->assertOk()
->assertJsonPath('props.canOverrideRequirements', false)
+ ->assertJsonPath('props.canOverrideBanned', 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);
+ ->assertJsonPath('props.canOverrideRequirements', true)
+ ->assertJsonPath('props.canOverrideBanned', false);
$this->actingAs($this->admin)
->get(route('attendee-logs.show', $this->log), $headers)
- ->assertJsonPath('props.canOverrideRequirements', true);
+ ->assertJsonPath('props.canOverrideRequirements', true)
+ ->assertJsonPath('props.canOverrideBanned', true);
+});
+
+it('does not let gatekeepers override bans even when they can override requirements', function () {
+ $gatekeeper = makeGatekeeper($this->log);
+ $banned = User::factory()->create(['role' => Role::Banned]);
+ $this->log->update(['min_volunteer_hours' => 12, 'gatekeepers_can_override' => true]);
+
+ $this->actingAs($gatekeeper)
+ ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $banned->badge_id])
+ ->assertForbidden()
+ ->assertJsonPath('banned', true)
+ ->assertJsonPath('can_override', false);
+
+ $this->actingAs($gatekeeper)
+ ->from(route('attendee-logs.show', $this->log))
+ ->put(route('attendee-logs.users.store', $this->log), ['badge_id' => $banned->badge_id, 'override' => true])
+ ->assertSessionHasErrors(['banned' => 'Only managers and admins can let banned attendees in.'])
+ ->assertSessionDoesntHaveErrors('requirements');
+
+ expect($this->log->hasAttendee($banned))->toBeFalse();
+});
+
+it('hides exact volunteer hours from gatekeepers that cannot override', function () {
+ $gatekeeper = makeGatekeeper($this->log);
+ $volunteer = volunteerWithHours($this->log->event_id, 9.5, ['badge_name' => 'Foxy']);
+ $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(
+ 'error',
+ "Denied: Foxy (#{$volunteer->badge_id}) hasn't reached the required 12 volunteer hours.",
+ );
+
+ $this->log->update(['gatekeepers_can_override' => true]);
+
+ $this->actingAs($gatekeeper)
+ ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $volunteer->badge_id])
+ ->assertForbidden()
+ ->assertJsonPath('error', "Denied: Foxy (#{$volunteer->badge_id}) has 9.5 of 12 required volunteer hours.");
});
it('lets admins set and clear the allowed levels', function () {
From c5c48ed08efbeb21e8640b070c31c3fc460ae5f4 Mon Sep 17 00:00:00 2001
From: tmolick
Date: Fri, 2 Oct 2026 15:20:50 -0700
Subject: [PATCH 4/5] Document attendee log entry requirements and overrides
Describe entry requirements, overrides, and how banned users are handled
in the architecture wiki, and list both features in the README.
---
README.md | 2 ++
wiki/Architecture.md | 34 ++++++++++++++++++++++++++++++++--
2 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 962b9867..0fa4ae03 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,8 @@ semi-comprehensive list of the current features implemented.
* Appoint any volunteer to be a gatekeeper (able to log attendees)
* Quick & painless logging of attendees as they enter the door
* Basic support for barcode scanners (for scanning badges)
+ * Optional entry requirements (registration level, staff role, or minimum volunteer hours)
+ * Overrides with an audit trail of who approved them and why
- Reporting
* Volunteer hours
* Department summary (hours, volunteer count, shifts)
diff --git a/wiki/Architecture.md b/wiki/Architecture.md
index 8a07a687..d315253c 100644
--- a/wiki/Architecture.md
+++ b/wiki/Architecture.md
@@ -46,7 +46,7 @@ All models and their relationships are listed below, alongside a brief descripti
| Name | Table | Description |
| ----------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Activity | activities | Used for tracking events and changes to models for audit logging purposes. Belongs to a User via both subject and causer. |
-| AttendeeLog | attendee_logs | A log to enter users into. Used for tracking attendance to a panel or other type of event. Has many Users, with `type` (`attendee` or `gatekeeper`) on the pivot table. Belongs to an Event. |
+| AttendeeLog | attendee_logs | A log to enter users into. Used for tracking attendance to a panel or other type of event. Has optional entry requirements. Has many Users, with `type` (`attendee` or `gatekeeper`) and override details on the pivot table. Belongs to an Event. |
| Department | departments | An organizational unit for staff/volunteers of a convention. Belongs to an Event. |
| Event | events | A single convention/other type of event that time is tracked for. |
| Kiosk | kiosks | A device that has been authorized to allow volunteers to enter time on. These devices keep a cookie with the session key to identify themselves. |
@@ -68,7 +68,7 @@ Users have a single assigned Role value, which is just an enum (Banned, Attendee
- Managers can authorize/deauthorize Kiosks, view and manage volunteers' time entries, manage attendee log attendees and gatekeepers, and create users with a badge ID.
- Admins can do anything, but especially are responsible for general entity CRUD operations.
- Attendees are just users created for the purpose of being an entry in an attendee log. They are automatically "promoted" to Volunteer if they ever log in.
-- Banned users are prevented from interacting with the application entirely beyond signing in.
+- Banned users are prevented from interacting with the application entirely beyond signing in. They're also denied entry to attendee logs unless a manager or admin overrides it.
## Time Tracking Details
@@ -109,6 +109,36 @@ Attendee logs are an entity used to track attendees for a scheduled event such a
They can have any number of users entered into them by badge ID, and they don't even require the users entered to be valid volunteers or staff.
Any number of Gatekeepers can also be added to them, who will all be able to view and manage the attendees that are logged, regardless of their own role.
+### Entry Requirements
+
+Admins can restrict who can be logged as an attendee.
+Each requirement is optional, and an attendee is allowed in if they meet **any** of the requirements that are set:
+
+- **Registration level:** the attendee's ConCat registration product matches one of the selected levels (by product name or ID, case-insensitive).
+ The list of levels to choose from is built from all ConCat registrations and cached for six hours; admins can reload it from the log page.
+- **Staff or above:** the attendee's Tracker role is Staff, Lead, Manager, or Admin.
+- **Minimum volunteer hours:** the attendee has earned at least the set number of hours (including bonuses) for the log's event.
+ The minimum is 1 hour or more, with up to two decimal places.
+
+A log without any requirements allows everyone.
+Banned users are always denied, regardless of the log's requirements.
+Gatekeepers aren't subject to entry requirements.
+
+Requirements based on Tracker data (role and hours) are checked first, so ConCat is only contacted when a registration level must be checked or when the badge doesn't belong to a known user yet.
+If ConCat can't be reached or has no registration for a badge, unknown badges can't be logged.
+Known users are denied on logs that check registration levels, with a message that says whether they have no registration or ConCat couldn't be reached.
+
+### Overrides
+
+A denied attendee can be let in anyway with an override, which records the user that approved it and an optional reason.
+Overrides are shown in the attendee list and included in the attendee log export.
+
+- Managers and admins can always override denials.
+- Gatekeepers can override denials if the log is configured to allow it.
+- Only managers and admins can override the denial of a banned user.
+
+Denial messages include exact volunteer hours only for users that can override them.
+
## Telegram Bot
### Account Linking
From 910dc112983309e5e14668cf484a7a15028c79f8 Mon Sep 17 00:00:00 2001
From: tmolick
Date: Fri, 2 Oct 2026 20:44:20 -0700
Subject: [PATCH 5/5] Remove banned user check from attendee logs
Tracker's banned role is a volunteer ban, and a user banned from
volunteering can still be a registered attendee, so attendee logs no
longer check it. Banned users are logged the same way as on main.
Denial messages still show exact volunteer hours only to users who can
override them.
---
.../Controllers/AttendeeLogController.php | 93 +++++++----------
.../AttendeeLog/AttendeeCreatePanel.vue | 17 +---
resources/js/Pages/AttendeeLogDetails.vue | 4 +-
.../AttendeeLogEntryRequirementsTest.php | 99 +------------------
wiki/Architecture.md | 4 +-
5 files changed, 41 insertions(+), 176 deletions(-)
diff --git a/app/Http/Controllers/AttendeeLogController.php b/app/Http/Controllers/AttendeeLogController.php
index e952e6d5..719fe157 100644
--- a/app/Http/Controllers/AttendeeLogController.php
+++ b/app/Http/Controllers/AttendeeLogController.php
@@ -67,7 +67,6 @@ 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,
];
@@ -190,17 +189,10 @@ 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 ($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 ($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 ($request->boolean('override') && $canOverride) {
$overriddenBy = $request->user();
@@ -208,27 +200,20 @@ 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.
- 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(
+ $error = $request->boolean('override')
+ ? 'Only managers and admins can let attendees into this log anyway.'
+ : $this->buildEntryDeniedMessage(
$attendeeLog,
$badgeId,
$user,
$registration,
$registrationMissing,
- $isBanned,
- $failsRequirements,
- $canOverrideRequirements,
+ $canOverride,
);
- }
- // Banned denials use their own error key since fewer people can override them
return $request->expectsJson()
- ? response()->json(['error' => $error, 'banned' => $isBanned, 'can_override' => $canOverride], 403)
- : redirect()->back()->withErrors([$isBanned ? 'banned' : 'requirements' => $error]);
+ ? response()->json(['error' => $error, 'can_override' => $canOverride], 403)
+ : redirect()->back()->withErrors(['requirements' => $error]);
}
}
@@ -304,56 +289,44 @@ 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 = [];
- 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";
- }
+ $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
- && $failsRequirements
- && $attendeeLog->isRestrictedByRegistrationLevel()
- && !$levelUnknown;
+ $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 ');
- $message = "Denied: {$who} {$joined}.";
- if ($isBanned) $message .= ' A manager or admin must approve banned attendees.';
- return $message;
+ return "Denied: {$who} {$joined}.";
}
/**
diff --git a/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue b/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue
index c0acf158..3868025c 100644
--- a/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue
+++ b/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue
@@ -68,7 +68,7 @@
{{ denial }}
-
+
();
const route = useRoute();
@@ -162,13 +159,11 @@ const successSound = new Audio(successSoundFile);
const success2Sound = new Audio(success2SoundFile);
const alertSound = new Audio(alertSoundFile);
-// Denials of banned attendees use their own error key, since only managers and admins can override them
-const denial = computed(() => form.errors.banned ?? form.errors.requirements);
-const canOverrideDenial = computed(() => (form.errors.banned ? canOverrideBanned : canOverride));
+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', 'canOverrideBanned', 'flash'];
+const reloadProps = ['attendeeLog', 'overriders', 'canOverrideRequirements', 'flash'];
function create() {
const badgeId = form.badge_id;
@@ -220,11 +215,7 @@ function override() {
successSound.play();
},
onError() {
- // Keep the failure under the same key as the denial so the right override permission still applies
- form.setError(
- form.errors.banned ? 'banned' : 'requirements',
- Object.values(overrideForm.errors)[0] ?? 'Override failed.',
- );
+ form.setError('requirements', Object.values(overrideForm.errors)[0] ?? 'Override failed.');
},
onFinish() {
// @ts-expect-error
diff --git a/resources/js/Pages/AttendeeLogDetails.vue b/resources/js/Pages/AttendeeLogDetails.vue
index 49e75b37..dfaf2f39 100644
--- a/resources/js/Pages/AttendeeLogDetails.vue
+++ b/resources/js/Pages/AttendeeLogDetails.vue
@@ -25,7 +25,6 @@
:attendee-log
:read-only
:can-override="canOverrideRequirements"
- :can-override-banned="canOverrideBanned"
class="grow basis-1"
/>
@@ -71,14 +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, overriders, canOverrideRequirements, canOverrideBanned } = defineProps<{
+const { attendeeLog, event, registrationLevels, overriders, canOverrideRequirements } = defineProps<{
attendeeLog: AttendeeLog;
event: Event;
exportTypes?: Record;
registrationLevels?: RegistrationLevel[] | null;
overriders?: Overriders;
canOverrideRequirements?: boolean;
- canOverrideBanned?: boolean;
}>();
const { isAdmin, isManager } = useUser();
diff --git a/tests/Feature/AttendeeLogEntryRequirementsTest.php b/tests/Feature/AttendeeLogEntryRequirementsTest.php
index 0b24cdee..a355bcae 100644
--- a/tests/Feature/AttendeeLogEntryRequirementsTest.php
+++ b/tests/Feature/AttendeeLogEntryRequirementsTest.php
@@ -567,78 +567,6 @@ function makeGatekeeper(AttendeeLog $log): User {
return $gatekeeper;
}
-it('denies banned users from logs without entry requirements', function () {
- $banned = User::factory()->create(['role' => Role::Banned, 'badge_name' => 'Trouble']);
-
- $this->actingAs($this->admin)
- ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $banned->badge_id])
- ->assertForbidden()
- ->assertJsonPath('banned', true)
- ->assertJsonPath('can_override', true)
- ->assertJsonPath(
- 'error',
- "Denied: Trouble (#{$banned->badge_id}) is banned. A manager or admin must approve banned attendees.",
- );
-
- expect($this->log->hasAttendee($banned))->toBeFalse();
-});
-
-it('lists the ban alongside any requirements the banned user also fails', function () {
- $banned = volunteerWithHours($this->log->event_id, 2, ['role' => Role::Banned, 'badge_name' => 'Trouble']);
- $this->log->update(['allow_staff' => true, 'min_volunteer_hours' => 12]);
-
- $this->actingAs($this->admin)
- ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $banned->badge_id])
- ->assertForbidden()
- ->assertJsonPath(
- 'error',
- "Denied: Trouble (#{$banned->badge_id}) is banned, isn't staff, and has 2 of 12 required volunteer hours. "
- . 'A manager or admin must approve banned attendees.',
- );
-});
-
-it('denies banned users even when they meet the entry requirements', function () {
- $banned = volunteerWithHours($this->log->event_id, 13, ['role' => Role::Banned, 'badge_name' => 'Trouble']);
- $this->log->update(['min_volunteer_hours' => 12]);
-
- $this->actingAs($this->admin)
- ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $banned->badge_id])
- ->assertForbidden()
- ->assertJsonPath(
- 'error',
- "Denied: Trouble (#{$banned->badge_id}) is banned. A manager or admin must approve banned attendees.",
- );
-});
-
-it('lets admins override bans', function () {
- $banned = User::factory()->create(['role' => Role::Banned]);
-
- $this->actingAs($this->admin)
- ->putJson(route('attendee-logs.users.store', $this->log), [
- 'badge_id' => $banned->badge_id,
- 'override' => true,
- 'override_reason' => 'Ban lifted at the door',
- ])
- ->assertOk()
- ->assertJsonPath('overridden', true);
-
- expect($this->log->attendees()->first()->pivot->override_reason)->toBe('Ban lifted at the door');
-});
-
-it('lets managers override bans on logs for the active event', function () {
- $manager = User::factory()->create(['role' => Role::Manager]);
- \App\Models\Setting::set('active-event', $this->log->event_id);
- \App\Models\Setting::set('lockdown', false);
- $banned = User::factory()->create(['role' => Role::Banned]);
-
- $this->actingAs($manager)
- ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $banned->badge_id, 'override' => true])
- ->assertOk()
- ->assertJsonPath('overridden', true);
-
- expect($this->log->attendees()->first()->pivot->overridden_by_id)->toBe($manager->id);
-});
-
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']);
@@ -653,39 +581,16 @@ function makeGatekeeper(AttendeeLog $log): User {
->get(route('attendee-logs.show', $this->log), $headers)
->assertOk()
->assertJsonPath('props.canOverrideRequirements', false)
- ->assertJsonPath('props.canOverrideBanned', 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)
- ->assertJsonPath('props.canOverrideBanned', false);
+ ->assertJsonPath('props.canOverrideRequirements', true);
$this->actingAs($this->admin)
->get(route('attendee-logs.show', $this->log), $headers)
- ->assertJsonPath('props.canOverrideRequirements', true)
- ->assertJsonPath('props.canOverrideBanned', true);
-});
-
-it('does not let gatekeepers override bans even when they can override requirements', function () {
- $gatekeeper = makeGatekeeper($this->log);
- $banned = User::factory()->create(['role' => Role::Banned]);
- $this->log->update(['min_volunteer_hours' => 12, 'gatekeepers_can_override' => true]);
-
- $this->actingAs($gatekeeper)
- ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $banned->badge_id])
- ->assertForbidden()
- ->assertJsonPath('banned', true)
- ->assertJsonPath('can_override', false);
-
- $this->actingAs($gatekeeper)
- ->from(route('attendee-logs.show', $this->log))
- ->put(route('attendee-logs.users.store', $this->log), ['badge_id' => $banned->badge_id, 'override' => true])
- ->assertSessionHasErrors(['banned' => 'Only managers and admins can let banned attendees in.'])
- ->assertSessionDoesntHaveErrors('requirements');
-
- expect($this->log->hasAttendee($banned))->toBeFalse();
+ ->assertJsonPath('props.canOverrideRequirements', true);
});
it('hides exact volunteer hours from gatekeepers that cannot override', function () {
diff --git a/wiki/Architecture.md b/wiki/Architecture.md
index d315253c..cc5ec01d 100644
--- a/wiki/Architecture.md
+++ b/wiki/Architecture.md
@@ -68,7 +68,7 @@ Users have a single assigned Role value, which is just an enum (Banned, Attendee
- Managers can authorize/deauthorize Kiosks, view and manage volunteers' time entries, manage attendee log attendees and gatekeepers, and create users with a badge ID.
- Admins can do anything, but especially are responsible for general entity CRUD operations.
- Attendees are just users created for the purpose of being an entry in an attendee log. They are automatically "promoted" to Volunteer if they ever log in.
-- Banned users are prevented from interacting with the application entirely beyond signing in. They're also denied entry to attendee logs unless a manager or admin overrides it.
+- Banned users are prevented from interacting with the application entirely beyond signing in.
## Time Tracking Details
@@ -121,7 +121,6 @@ Each requirement is optional, and an attendee is allowed in if they meet **any**
The minimum is 1 hour or more, with up to two decimal places.
A log without any requirements allows everyone.
-Banned users are always denied, regardless of the log's requirements.
Gatekeepers aren't subject to entry requirements.
Requirements based on Tracker data (role and hours) are checked first, so ConCat is only contacted when a registration level must be checked or when the badge doesn't belong to a known user yet.
@@ -135,7 +134,6 @@ Overrides are shown in the attendee list and included in the attendee log export
- Managers and admins can always override denials.
- Gatekeepers can override denials if the log is configured to allow it.
-- Only managers and admins can override the denial of a banned user.
Denial messages include exact volunteer hours only for users that can override them.