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/app/Http/Controllers/AttendeeLogController.php b/app/Http/Controllers/AttendeeLogController.php index f365373a..719fe157 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,42 @@ 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', '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, + ]; + + // 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,42 +142,123 @@ 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 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); + } + + // 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, - ]); + // 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, + ]); + } + + // 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, where an override can still apply. + 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}."]); + } + } + } + + // 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)); + + $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 ($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, + $canOverride, + ); + 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}."]); + ? response()->json(['error' => $error, 'can_override' => $canOverride], 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); } - // 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); - 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."]); - } + $overrideReason = $overriddenBy ? (trim($request->validated('override_reason') ?? '') ?: null) : null; + $attendeeLog->users()->attach($user, [ + 'type' => $type, + 'overridden_by_id' => $overriddenBy?->id, + 'override_reason' => $overrideReason, + ]); - $attendeeLog->users()->attach($user, ['type' => $type]); + 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}."); + } + + /** + * 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]); } /** @@ -167,6 +280,91 @@ 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, + 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) { + // 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; + $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..cb59700a 100644 --- a/app/Http/Requests/AttendeeLogStoreRequest.php +++ b/app/Http/Requests/AttendeeLogStoreRequest.php @@ -30,6 +30,11 @@ 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', + 'gatekeepers_can_override' => 'sometimes|boolean', ]; } } diff --git a/app/Http/Requests/AttendeeLogUpdateRequest.php b/app/Http/Requests/AttendeeLogUpdateRequest.php index f9fdd02e..a92faa33 100644 --- a/app/Http/Requests/AttendeeLogUpdateRequest.php +++ b/app/Http/Requests/AttendeeLogUpdateRequest.php @@ -31,6 +31,11 @@ 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', + '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 f8df9f7e..2b57df28 100644 --- a/app/Models/AttendeeLog.php +++ b/app/Models/AttendeeLog.php @@ -16,6 +16,10 @@ /** * @property string $name * @property string $event_id + * @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 @@ -53,11 +57,28 @@ class AttendeeLog extends Model implements HasDisplayName { protected $fillable = [ 'name', + '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 { return LogOptions::defaults() - ->logOnly(['name', 'event_id']) + ->logOnly([ + 'name', + 'event_id', + 'allowed_registration_levels', + 'allow_staff', + 'min_volunteer_hours', + 'gatekeepers_can_override', + ]) ->logOnlyDirty() ->submitEmptyLogs(); } @@ -82,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(); } @@ -122,6 +143,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/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_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/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 27f80c3a..3868025c 100644 --- a/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue +++ b/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue @@ -16,51 +16,99 @@ key press after the badge number.

-
- - - + Only allows attendees that meet any of: +
    +
  • {{ requirement }}
  • +
+ + +
+ + + + + + + + - - - - - - - - {{ form.errors.badge_id }} - - + + + + {{ form.errors.badge_id }} + + + + +
+ + {{ denial }} + + + + A manager or admin can let them in anyway. +
+
diff --git a/resources/js/Components/AttendeeLog/AttendeeLogRequirementsPanel.vue b/resources/js/Components/AttendeeLog/AttendeeLogRequirementsPanel.vue new file mode 100644 index 00000000..14bf1bbb --- /dev/null +++ b/resources/js/Components/AttendeeLog/AttendeeLogRequirementsPanel.vue @@ -0,0 +1,305 @@ + + + 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 @@ > - + + +
- + + +
- + @@ -46,21 +59,27 @@ 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'; 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, overriders, canOverrideRequirements } = defineProps<{ attendeeLog: AttendeeLog; event: Event; exportTypes?: Record; + registrationLevels?: RegistrationLevel[] | null; + overriders?: Overriders; + canOverrideRequirements?: boolean; }>(); -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/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 dd0bc25e..5a8dbbe4 100644 --- a/resources/js/data/AttendeeLog.ts +++ b/resources/js/data/AttendeeLog.ts @@ -5,6 +5,10 @@ export default interface AttendeeLog { id: AttendeeLogId; name: string; event_id: EventId; + 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/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..a355bcae --- /dev/null +++ b/tests/Feature/AttendeeLogEntryRequirementsTest.php @@ -0,0 +1,627 @@ + "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('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]); + + $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 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')->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]) + ->assertForbidden() + ->assertJsonPath( + 'error', + "Denied: Foxy (#{$volunteer->badge_id}) couldn't have their registration level checked with ConCat.", + ); + + $this->actingAs($this->admin) + ->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 () { + $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('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]) + ->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(); +}); + +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('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 () { + $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(); +}); diff --git a/wiki/Architecture.md b/wiki/Architecture.md index 8a07a687..cc5ec01d 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. | @@ -109,6 +109,34 @@ 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. +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. + +Denial messages include exact volunteer hours only for users that can override them. + ## Telegram Bot ### Account Linking