# AGENTS.md

Guidance for AI coding agents working in this repository.

## Project

WWSK IMS — 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
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.
- 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`.

## 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-ims-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.

**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`.
