# AGENTS.md

Guidance for AI coding agents working in this repository.

## Project

SERVUS — desktop-first duty-roster, watch-log (`Wachbuch`) and administration system for the Wasserwacht. The signed-in surface is a Vue 3 SPA over Inertia; Laravel provides authentication, organization permissions, JSON/HTML fallbacks and the database logic.

## Stack

- PHP 8.3, Laravel 13, Inertia 2, Livewire 4
- Vue 3 + Vite 8 + Tailwind 4
- MySQL 8.4 in Docker; **SQLite in-memory for PHPUnit** (see the warning under *Testing*)
- Apache (`php:8.3-apache`) in the container, document root `public/`

## Layout

```
app/Http/Controllers/   thin controllers, one per domain area
app/Models/             Eloquent models (Organization, Event, WatchLog, Member, …)
app/Services/           AuditLogger, IcsCalendar, WatchLogService
app/Support/            OrganizationContext (active org in session), OrganizationAccess (role checks)
app/Http/Middleware/    EnsureActiveUser ('active'), HandleInertiaRequests
routes/web.php          all web routes, German URL segments
resources/js/Pages/     Inertia pages
resources/js/Composables/  useUrlState (filters, tabs and calendar cursor in the query string)
database/migrations/    schema
docs/                   feature documentation, German (see Documentation below)
docs/MIGRATION.md       legacy-data import rules (read before touching import code)
scripts/preflight.sh    non-mutating source-data inventory
docker/entrypoint.sh    container startup: migrate + cache + banner
docker/cron.sh          lima-city: scheduler + queue worker, flock-guarded, once per minute
.github/workflows/      CD to lima-city (see docs/DEPLOYMENT.md)
```

## Conventions

- **Route URLs are German** (`/terminkalender`, `/wachbuch`, `/einrichtung`); route *names*, class names, variables and code comments are English. Keep this split.
- **Everything is organization-scoped.** Never query domain data without scoping to the active organization. Use `OrganizationContext` for the active org and `OrganizationAccess` for permissions (`ADMIN_ROLES`, `PLANNER_ROLES`, `WATCH_ROLES`, `INVENTORY_ROLES`, `AUDIT_ROLES`); `is_system_admin` bypasses org membership.
- **List state belongs in the URL.** Filters, tabs, searches and the calendar cursor go through `useUrlState` so the back button returns to the previous view; never keep them in a plain `ref`. See `docs/NAVIGATION.md`.
- Auth-only routes go inside the `['auth', 'active']` middleware group. Unauthenticated POST endpoints get `throttle:5,1`.
- Production files live **only** under `public/`. Private attachments stay on local storage, never under `public/`.
- Legacy source dumps (`db_398681_9.sql`, the Dienstkalender SQLite, Espo uploads) are never committed and never placed under `public/`.
- Follow `laravel/pint` formatting: `vendor/bin/pint`.

### UI vocabulary

- **Untere Felder** bezeichnet das gemeinsame, am Ende einer Entitätsdetailseite liegende Modul für
  relationale Inhalte. Die einzelnen Beziehungstypen erscheinen darin als Tabs; vorhandene
  Verknüpfungen werden am jeweiligen Tab mit einem Zähler hervorgehoben. Neue relationale Panels
  sollen nach Möglichkeit in dieses Modul integriert und nicht als lose Karten darunter gesetzt
  werden. Die gemeinsame Tab- und Zählerdarstellung liegt in
  `resources/js/Components/LowerFields.vue`.
- App- und Feature-Hierarchien werden oberhalb des Seitentitels durch die zentralen Breadcrumbs in
  `resources/js/Components/AppBreadcrumbs.vue` dargestellt. Unterseiten sollen keine parallelen
  „← Zurück“-Links im Titelblock erhalten; neue App-, Feature- oder Unterseitenrouten müssen in der
  zentralen Breadcrumb-Zuordnung ergänzt werden.

## Documentation

`docs/` holds the feature documentation, one file per area, indexed by `docs/README.md`. It is
written in German like the rest of the domain surface, while code, comments and identifiers stay
English.

**Keep it current — this is part of the change, not a follow-up.** Whenever behaviour, a rule or a
data-model decision changes, update the affected document in the same change:

- New feature or area → new `docs/<AREA>.md` plus a row in `docs/README.md`.
- Changed rule (who may do what, how a value is derived, what a default is) → fix the sentence that
  states it; do not leave both versions standing.
- Renamed or moved file → fix the “Umsetzung” table at the bottom of the document.
- Removed feature → remove its document and its row in the index.

Document the *rules and the reasons*, not a retelling of the code: what a field means, which
constraint holds, what is deliberately frontend-only. If a decision limits a future option (or keeps
one open), say so — that is the part nobody can reconstruct from the source.

## Commands

```sh
# local, containerized (the primary path)
docker compose up --build          # SPA :8080, Mailpit :8025, MySQL 127.0.0.1:3307
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit

# without containers
composer setup                     # install, .env, key, migrate, npm build
composer dev                       # serve + queue + pail + vite (concurrently)
composer test                      # config:clear + artisan test (needs a complete host PHP)
./scripts/test.sh                  # PHPUnit in a container — works regardless of host PHP
vendor/bin/pint
```

## Testing

```sh
./scripts/test.sh                       # whole suite
./scripts/test.sh --filter=test_name    # arguments are forwarded to phpunit
./scripts/test.sh --rebuild             # after editing docker/Dockerfile.test
```

`scripts/test.sh` runs PHPUnit in the `wwsk-servus-test` image (`docker/Dockerfile.test`). That image contains PHP and extensions only — **no application code** — and the sources are bind-mounted, so it is built once and never needs rebuilding for a code change. A run takes well under a second.

Use it instead of `composer test` when the host PHP has no usable extension configuration. On this machine `/etc/php` does not exist, so no `.ini` is loaded and `mbstring`, `tokenizer`, `dom` and `xmlwriter` are all unavailable to PHPUnit even though the Debian packages are installed; `sqlite3`/`pdo_sqlite` and `intl` are not installed at all. `composer test` works normally on a host with a complete PHP install.

Two traps the image already handles — reproduce them if you run PHPUnit some other way:

- `APP_ENV` must really be `testing`. phpunit.xml's `<env>` does **not** override an actual environment variable, and the app image hardcodes `APP_ENV=local`. Without it the CSRF middleware stays active and every POST test fails with 419.
- `APP_KEY` must be set, or every test touching the session dies with `MissingAppKeyException`.

`vendor/bin/pint` needs `Phar`, which is also unavailable here; run it in the same container if the host refuses.

### Test ownership / Definition of Done

Automated tests are part of every feature and bug fix, not an optional follow-up:

- Every new or changed behaviour must add or update tests in the same change. A bug fix starts with a regression test that fails for the reported defect and passes with the fix.
- Test the externally observable rule, not the current implementation. Cover the successful path plus relevant validation, authorization, organization-isolation and edge cases.
- Put pure domain/service logic in unit tests. Use feature tests for routes, controllers, persistence, permissions, Inertia props and interactions spanning multiple classes.
- Frontend changes must at least pass the production build. Complex client-side behaviour must be extracted into testable code and covered by an appropriate frontend test; do not leave important workflows protected only by manual clicking.
- Do not delete, weaken or broadly rewrite assertions merely to make a suite green. Change an expectation only when the intended behaviour changed, and update the corresponding documentation at the same time.
- Run focused tests while implementing, then the full suite before handing off. Report pre-existing failures explicitly; the change must not introduce additional failures.
- If automation is genuinely impractical (for example a purely visual adjustment), document the reason in the hand-off and perform the smallest relevant automated check plus a concrete manual verification. This is an exception, not the default.

### WIP and TODO backlog discipline

The admin-only product backlog lives under **Aufgaben → WIP & TODO**. Keep it current whenever work exposes an unfinished feature, a deliberately deferred part, a known defect or a follow-up required for safe operation:

- Add or update a backlog item instead of leaving an unexplained `TODO`, placeholder or partially working control only in code or prose. Include a concrete title, acceptance-oriented description and, where useful, the source path or ticket reference.
- Reuse an existing item when it describes the same gap; do not create duplicates for every occurrence.
- Move an item to **In Arbeit** when implementation begins. Mark it **Erledigt** only after the behaviour, tests, documentation and required checks are complete.
- If the configured development database is available, maintain the item through the application while doing the work. If it is not available, state the exact backlog entry to be created or updated in the hand-off; do not silently lose the finding.
- The product backlog is not a substitute for tests, documentation or an immediate fix when the current request requires completion. Do not create a backlog item merely to avoid finishing in-scope work.

### Optional pre-commit quality gate

The repository contains an opt-in hook under `.githooks/pre-commit`. Enable it locally with:

```sh
sh ./scripts/install-git-hooks.sh
```

or from PowerShell:

```powershell
./scripts/install-git-hooks.ps1
```

The hook checks the staged diff, requires a staged test for backend behaviour changes, runs staged PHPUnit tests, and builds the frontend when frontend sources are staged. Set `SERVUS_FULL_PRECOMMIT=1` to additionally run the complete PHPUnit suite. For an exceptional non-behavioural backend change, `SERVUS_ALLOW_NO_TESTS=1` bypasses only the test-presence guard; explain that exception in the commit or review context. Agents must not enable or disable hooks in the user's Git configuration without being asked.

**PHPUnit runs on SQLite in memory (`phpunit.xml`), production and local Docker run MySQL 8.4.** The two engines disagree on real things — identifier length limits, index-name limits, strict mode, column-type coercion, `ALTER TABLE` behaviour. A green test suite does **not** prove a migration works on MySQL.

## Required check for bigger changes

Before considering any non-trivial change done — and always for **migrations, schema changes, Dockerfile/entrypoint changes, dependency bumps, or config/caching changes** — verify a clean build and a clean first start:

```sh
./scripts/clean-boot.sh
```

**Never run `docker compose down -v` on the dev stack.** That drops the `wwsk_mysql` volume and silently destroys the testing instance's accounts and data — the loss stays invisible until someone tries to log in or expects a mail. `scripts/clean-boot.sh` gets the same guarantee safely: it starts a throwaway stack under its own compose project name (`wwsk-cleanboot`) with its own volume and no published ports, checks it, and tears it down. The dev stack keeps running untouched.

The script fails unless all of these hold:

1. Every migration reports `DONE` — no `SQLSTATE`, no exception, no fatal error.
2. The `Now running on …` banner is printed, i.e. the entrypoint reached the end.
3. The app container reaches `healthy` and `/up` answers.
4. `/einrichtung` answers on the empty database.

To restart the dev stack normally use `docker compose up -d --build` (never `-v`). Note that naming a single service — `docker compose up -d app` — starts only that service and its `depends_on`, which is how mailpit once ended up missing and mail failed with `getaddrinfo for mailpit failed`.

Why this is mandatory: MySQL DDL is **not transactional**. A migration that fails halfway leaves tables created but no `migrations` row. Because migrations here are wrapped in `if (! Schema::hasTable(...))` guards, the retry then silently *skips* the half-created table and the missing index never appears — the database is permanently wrong while `migrate` reports success. Running against a fresh volume is the only way to catch this.

If you do hit a partial migration, drop the affected tables (check they are empty first), then re-run — do not just re-run `migrate`.

Migration-specific rules:

- Keep generated index names **≤ 64 characters** (MySQL's identifier limit). Laravel builds names as `{table}_{col1}_{col2}…_{type}`, which overflows quickly on wide unique keys — pass an explicit short name as the second argument to `unique()` / `index()`.
- SQLite will happily accept names MySQL rejects, so this class of bug never shows up in `composer test`.
