# CLAUDE.md Guidance for AI coding agents working in this repo. See [README.md](README.md) for the feature/tech overview, [ARCHITECTURE.md](ARCHITECTURE.md) for how the app is put together, and [install.md](install.md) for full deployment instructions. ## This is a live production system The only real user today is the owner's own account (client+operator+admin roles). Treat the running database as production, not a sandbox: - Never create/modify/delete real DB records (users, tickets, settings, etc.) without the user explicitly asking. Don't spin up throwaway test users via `php artisan tinker` against this DB. - For UI/CSS verification, prefer an offline static-HTML harness reproducing the real markup+CSS, or ask for test credentials, rather than poking at prod data. - Production is fronted by Traefik with a **private-CA TLS cert** (not publicly trusted) — tools like `curl`/Playwright need `-k` / `ignore_https_errors` to hit it directly. ## Container operations: use `sudo`, never build the image locally All Docker commands against this stack need `sudo` (e.g. `sudo docker compose exec app ...`, `sudo docker exec servicedesk-app-1 ...`). The stack is four services sharing the one `servicedesk` image — `app` (Apache, what actually serves HTTP), `reverb` (websocket server, `php artisan reverb:start`), `cron` (scheduler loop, `php artisan schedule:work` — see below), and `mariadb`. Only `app` and `reverb` are reachable from Traefik. **Never run `docker build`, `docker compose build`, or `--build`.** The `servicedesk` image is built by CI (`.gitea/workflows/build.yml`, triggered on `Dockerfile` changes on `main`) and pushed to the Gitea container registry; `compose.yaml` only ever `pull`s a tag (`sudo docker compose pull && sudo docker compose up -d`, see [install.md](install.md) 1.3/1.3a) — building locally would just diverge from what CI produces. The app container (`servicedesk-app-1`) mounts `./src` from the host over NFS (`/mnt/rabbit-containers` → NFS export), so plain file edits already take effect with no rebuild or restart: - Blade/PHP/route edits: just edit the files — live instantly, no restart needed. - `resources/css/app.css` / `resources/js/*` (Vite/Tailwind entrypoints) **do** need a rebuild — `public/build/assets/` is static compiled output. Neither the host nor the app container has Node installed; rebuild with a throwaway container instead of touching the app image: ```bash sudo docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm run build ``` (run from the repo root; `node_modules` already exists, no install needed). Verify via `public/build/manifest.json` picking up a new hash. - After running artisan cache commands for diagnostics (`config:cache`, `view:cache`), clear them again afterward (`config:clear`/`view:clear`) — this app normally runs uncached so edits apply live; leaving a cache on silently breaks that workflow. - **`sudo docker exec` runs as `root`, not `www-data`.** Apache's worker processes run as `www-data`; anything you run via a plain `docker exec` (`php artisan test`, `tinker`, `view:cache`, etc.) runs as `root`. On this NFS-backed mount, a file Blade compiles/caches while running as `root` (`storage/framework/views/*.php`) can't later be overwritten by `www-data` when a real request needs to recompile it (the source changed) — this surfaces in production as a 500 with `touch(): Utime failed: Operation not permitted`. If you ran `php artisan test`/`tinker`/any artisan command via `docker exec` in a session where you also edited Blade files afterward, finish with `sudo docker exec servicedesk-app-1 php artisan view:clear` to flush any root-owned compiled views before ending the session — don't wait for a report of a broken page to catch it. ## Scheduled commands run in the dedicated `cron` container The Docker image ships no cron/supervisor of its own (see [install.md](install.md)), so `tickets:check-sla-breaches`, `automation:run-rules`, `emails:fetch-imap`, and `ai:run-ticket-automation` (all registered in `routes/console.php` via `Schedule::command(...)`) only ever run if something calls `php artisan schedule:run` on a timer. **As of 2026-08-04 this is the `cron` service** in `compose.yaml` — same `servicedesk` image, running `php artisan schedule:work` (Laravel's own foreground scheduler loop, ticks every minute internally, no external trigger needed). Before this it was a root crontab entry on the host calling `docker compose exec -T servicedesk schedule:run`; that entry has been removed from `sudo crontab -l -u root` now that the container replaces it — don't re-add it, the two would double-run every scheduled command. If the `cron` container isn't running (`sudo docker compose ps cron`), none of the four scheduled commands fire — same failure mode as the old missing-crontab case, just check the container instead of the crontab. IMAP-specific activity (connect attempts, per-message accept/reject decisions, created/replied ticket ids) is logged separately from the app's normal `LOG_LEVEL` to `storage/logs/imap-*.log` (see the `imap` channel in `config/logging.php`) — check there first when a mailbox isn't behaving as expected, before assuming the scheduler itself isn't firing. `ai:run-ticket-automation` and `hesk:import` get the same always-debug treatment via the `ai`/`hesk_import` channels (`storage/logs/ai.log`/`hesk-import.log`). All of `storage/logs/*.log` is also browsable from Admin > Logi (`app/Livewire/Admin/Logs.php`) if you'd rather not `docker exec` in just to tail a file. All four commands' intervals are admin-configurable (Admin > Konfiguracja — `schedule_sla_check_minutes`/`schedule_automation_rules_minutes`/ `schedule_imap_fetch_minutes`/`schedule_ai_automation_minutes`), which is why `routes/console.php` registers them as `->everyMinute()->when(fn () => Settings::dueEveryMinutes(...))` instead of a plain `->everyFifteenMinutes()`/ `->cron(...)` call — **never build a cron expression (or otherwise read `Settings`) at that file's top level**. `routes/console.php` is `require`'d on every artisan boot (`migrate`, `tinker`, `php artisan test`, not just `schedule:run` — it's wired in via `bootstrap/app.php`'s `commands:` key), so a top-level `Settings::get(...)` call runs before a fresh/test database necessarily has the `settings` table, and crashes every single artisan invocation, not just the scheduler. A `->when($closure)` guard is the fix — the closure is only ever evaluated later, when `schedule:run` processes due events. One visible side effect: `php artisan schedule:list` shows `* * * * *` for all four regardless of the actual configured interval, since that only exists inside the closure — expected, not a bug worth chasing. ## Apache `/icons/` alias trap The stock `php:apache` image enables `mods-enabled/alias.conf`, which defines `Alias /icons/ "/usr/share/apache2/icons/"`. Any app-level static directory at `public/icons/` is silently shadowed by Apache's own stock icon set — requests 404 before ever reaching Laravel, with no useful log line. This project deliberately uses `public/pwa-icons/` for PWA manifest icons for exactly this reason — never add a `public/icons/` directory. ## Established UI conventions - **Post-login redirect** always lands on `/client` (`User::defaultArea()` in `app/Models/User.php`), even for accounts holding operator/admin roles too — staff switch areas via the role switcher in the header (`resources/views/components/profile-menu.blade.php`). Keep `/client` first-priority in `defaultArea()` if you add new roles/areas. - **Mobile tables**: don't rely on horizontal scroll alone for wide data tables. The established pattern (`.table-cards-mobile` in `resources/css/app.css`, `@media (max-width:640px)`) turns each `` into a card with `data-label` attributes on ``s. Currently applied to the operator ticket queue; apply the same treatment to any other wide table you add or make mobile-relevant (admin panel tables don't have it yet). ## Testing & code style See [TESTING.md](TESTING.md) and [CONTRIBUTING.md](CONTRIBUTING.md) — run `php artisan test` and `./vendor/bin/pint` before considering a change done. There is no CI; local test runs are the only gate.