# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project overview

Laravel 11 (PHP 8.2+, MySQL) ticket/turn queue system for in-person attention. The public reserves a time slot without logging in; admins manage tickets, users, roles and site branding from a Bootstrap-based admin panel (theme: `public/color-admin`). Auth scaffolding is Laravel Breeze; RBAC is Spatie `laravel-permission`.

## Commands

```bash
composer install
npm install
cp .env.example .env && php artisan key:generate
php artisan migrate --seed      # seeds roles/permissions + admin user (needs ADMIN_* env vars, see below)
php artisan storage:link        # required for uploaded logos/backgrounds/photos to resolve
npm run dev                     # or: npm run build
```

The app runs under Laragon at **`http://tickets.test/`** (Laragon's auto vhost), not `http://localhost/tickets/`. The latter serves Apache's raw directory listing of the project root (including `.env`) because `localhost`'s document root is the parent `www/` folder — never use that URL.

### Tests

```bash
php artisan test
vendor/bin/pest
vendor/bin/pest tests/Feature/ProfileTest.php   # single file
vendor/bin/pest --filter=test_name              # single test
```

`tests/Pest.php` applies `RefreshDatabase` to all Feature tests. `phpunit.xml` overrides `DB_DATABASE=tickets_testing` (connection/host/user/password still come from `.env`) so the suite never touches the real MySQL dev database — if that `<env>` line is ever missing, `php artisan test` migrates and wipes whatever database `.env` points at. sqlite (`:memory:`) was tried first but dropped: this machine's CLI `php` doesn't have `pdo_sqlite` enabled (`could not find driver`, even though the Apache/web PHP might), and separately `tickets` has a MySQL/MariaDB-only `ENUM` and a generated column (`hora_reserva_activa`) whose expression isn't guaranteed to behave the same under sqlite. A same-engine disposable database sidesteps both problems.

**One-time setup:** create an empty `tickets_testing` database (same MySQL server as `.env`'s `DB_HOST`, e.g. via Laragon's HeidiSQL/phpMyAdmin or `mysql -u root -e "CREATE DATABASE tickets_testing"`). `RefreshDatabase` migrates it automatically on each run — nothing else to do after that.

### Admin seeder env vars

`AdminSeeder` no longer hardcodes personal data — it reads `ADMIN_NOMBRE`, `ADMIN_APELLIDO`, `ADMIN_EMAIL`, `ADMIN_CI`, `ADMIN_CELULAR`, `ADMIN_GENERO`, `ADMIN_CARGO`, `ADMIN_PASSWORD` from `.env`. Without `ADMIN_EMAIL`/`ADMIN_CI`/`ADMIN_PASSWORD` set, the seeder just prints a warning and skips creating the admin user.

## Architecture

### Routing / authorization split

`routes/web.php` has three zones:
- **Public, no auth**: `/`  (`welcome.blade.php`), `/tickets*` (`TicketPublicController`) — ticket reservation, verification by carnet/QR code. `POST /tickets` and `POST /tickets/verificar` (the two that write data or look up a person by carnet) are behind `throttle:10,1` — 10 req/min per IP — to blunt scripted spam/enumeration against a route that requires no login.
- **Admin, `auth`+`verified`+Spatie `permission:` middleware per route group**: `/admin/usuarios`, `/admin/roles`, `/admin/tickets`, `/admin/settings`. There is no custom "is admin" middleware — all authorization is Spatie permission checks (`permission:ver usuarios`, etc.) defined in `database/seeders/RolePermissionSeeder.php`.
- Admin list views (`TicketController`, `UsuarioController`, `RoleController`) use **Yajra DataTables** server-side processing: the `index()` action returns a `view()` on normal GET and a `DataTables::of(...)->make(true)` JSON response when `$request->ajax()`.

### Soft deletes + restore

`User` and `Role` use `SoftDeletes` with a full restore UI: `index()` ajax query uses `withTrashed()`, an `estado`/`actions` DataTables column branches on `->trashed()` to show either edit/delete or restore(/force-delete for roles) buttons, and `restore($id)` looks the record up via `Model::withTrashed()->findOrFail($id)` (not route-model-binding, which excludes trashed records by default).

`Ticket` also uses `SoftDeletes` now (previously `TicketController::destroy()` was a hard delete — losing carnet/nombre/teléfono/historial permanently on a misclick, with no audit trail). It's intentionally *not* wired to a restore UI the way User/Role are: `TicketController`'s `index()`/`show()`/`updateEstado()`/`destroy()` still use the default (non-trashed) query, so a deleted ticket simply disappears from the admin panel, recoverable only via `Ticket::withTrashed()` outside the UI (Tinker, direct DB access). Add a restore route/button if that's ever needed — follow the Role pattern above.

### Settings system (dynamic branding)

`App\Models\Setting` is a flat `key`/`value` store with `Setting::get()/set()/getCached()`, exposed globally via the `setting($key, $default)` helper (`app/Helpers/SettingsHelper.php`, autoloaded via composer's `files` autoload). `getCached()` caches **only the raw DB value** — the caller's `$default` is applied after reading the cache, never baked into it. (Don't reintroduce `Cache::remember(..., fn() => self::get($key, $default))` — that bug once caused whatever default was requested *first* to get stuck in cache for every future caller of that key.)

Admin-configurable branding lives in `admin/settings/index.blade.php` (tabs: General / Imágenes / Avanzado) backed by `Admin\SettingController`. There are six independently-configurable images, each with its own upload+reset route pair (`update-X`/`reset-X` under `admin.settings.*`) and its own default SVG in `public/images/defaults/`:

| Setting key | Used in | Default asset |
|---|---|---|
| `app_logo` | header, sidebar, login, home nav/footer | `logo-default.svg` |
| `login_background` | `layouts/guest.blade.php` | `login-background-default.svg` |
| `public_background` | `layouts/public.blade.php` (`/tickets`) | `public-background-default.svg` |
| `home_background` | `welcome.blade.php` (`/`) | `public-background-default.svg` |
| `sidebar_cover` | admin sidebar profile card | `sidebar-cover-default.svg` |
| `sidebar_profile_image` | fallback avatar when a user has no `profile_photo_path` | `avatar-default.svg` |

**The resolution pattern to copy when adding a new configurable image:** never pass the theme's/plantilla's asset path as the `setting()` call's default (that couples a storage-relative-path branch to a public-asset path and breaks as soon as the value is ever unset). Instead:
```php
$value = setting('some_key'); // no default
$url = $value
    ? (filter_var($value, FILTER_VALIDATE_URL) ? $value : asset('storage/' . $value))
    : asset('images/defaults/some-default.svg');
```
`SettingController`'s `updateX`/`resetX` pairs delete the old file from the `public` disk and either `Setting::set()` the new path or delete the settings row entirely (never write the literal default path into the DB row — `resetCover()` used to do that, which is what broke things once the row's fake "default" stopped matching any real file check).

### Ticket domain logic (`App\Models\Ticket`)

Business rules: Mon–Fri only (`esDiaHabil`), 15-minute slots 08:00–16:00, max 2 tickets/person/day with a ±1h self-block window, daily capacity from `setting('capacidad_maxima_tickets', 50)`. `generarNumeroTurno($fecha)` uses `lockForUpdate()` and **must** be called inside `DB::transaction()` (see `TicketPublicController::store()`) — the row lock only holds within a transaction, and there's also a DB-level unique index on `(fecha_reserva, numero_turno)` as a backstop.

`Ticket::horariosDisponibles()` (the same-slot availability check) runs *before* that transaction opens, so it's a plain check-then-act with a TOCTOU window: two near-simultaneous requests for the same date+hora can both pass it. The app-level check alone doesn't prevent double-booking a slot under concurrency — the real backstop is the DB: migration `add_unique_active_horario_to_tickets` adds a virtual generated column `hora_reserva_activa` (= `hora_reserva` when `estado` is `pendiente`/`confirmada`, else `NULL`) with a unique index on `(fecha_reserva, hora_reserva_activa)`. Multiple `NULL`s don't collide in a unique index, so cancelled/attended/no-show tickets never block the slot — only two *active* tickets on the same date+hora do, which is exactly the case that matters. `TicketPublicController::store()` catches that specific `QueryException` (code `23000`, index name `tickets_fecha_hora_activa_unique`) and turns it into a normal "alguien más acaba de reservar ese horario" redirect instead of a 500. Don't replace this with a plain `unique(['fecha_reserva','hora_reserva'])` — that would permanently block a slot forever after the first cancellation, which contradicts the existing "only pendiente/confirmada occupy a slot" rule.

`generarNumeroTurno($fecha)` queries `withTrashed()` — the `(fecha_reserva, numero_turno)` unique index doesn't know about `deleted_at`, so a deleted ticket's turno number is still physically taken in the table and must not be recalculated as available (recycling a turno number would also be confusing for whoever's calling turns out loud). `hora_reserva_activa`'s generated-column expression (migration `update_tickets_fecha_hora_activa_unique_for_soft_deletes`) includes `AND deleted_at IS NULL`, so deleting a `pendiente`/`confirmada` ticket does free its slot for a new reservation — matching `horariosDisponibles()`, which already excludes trashed tickets automatically via Eloquent's soft-delete global scope.

`tests/Feature/TicketReservationTest.php` covers the reservation flow: turno increments, same-slot rejection, the DB unique-index backstop (verified directly against the model, bypassing the HTTP validation on purpose), that a cancelled or deleted ticket frees its slot, that a deleted ticket's turno number isn't reused, weekend rejection, per-person daily limit, and daily capacity limit.

### Admin ticket search (`Admin\TicketController::index`)

The `carnet` DataTables filter uses `LIKE '%...%'` (can't use the `carnet` index — that only helps exact/prefix lookups) with a 3-character minimum before it runs a query at all. Left as `LIKE '%...%'` deliberately: at this app's real scale (`capacidad_maxima_tickets` defaults to 50/day) a full scan of this column stays trivially fast for years of accumulated data, so a `FULLTEXT` index or similar would be solving a problem that doesn't exist yet. Revisit only if daily volume grows by an order of magnitude.

### Environment quirks worth knowing

- `config/cache.php`'s `default` reads `env('CACHE_DRIVER', 'file')`, the Laravel 10-era key — not Laravel 11's usual `CACHE_STORE`. `.env` has both set; `CACHE_DRIVER` is the one that actually takes effect. `php artisan cache:clear` / `Cache::flush()` have been unreliable in this Windows/Laragon setup for fully clearing `storage/framework/cache/data/`; if a `setting()` value seems stuck, delete files under that directory directly.
- File uploads require `upload_tmp_dir` to point somewhere the PHP-CGI worker can actually write (`C:\Windows\Temp` is not writable by the dev user here); it's set to `C:\laragon\tmp` in this machine's php.ini. A fresh environment hitting "ValueError: Path must not be empty" on any upload endpoint means this needs setting again, followed by restarting the `php-cgi` workers (killing them is enough; mod_fcgid respawns).
