# CLAUDE.md

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

## Project overview

Kulibri is a poll/survey/scheduling SaaS ("who-can-attend" style polls with invitations, participants, comments, subscriptions/payments). It's a monolith: a Laravel 12 (PHP 8.2+) JSON API backend serving a Vue 2 SPA frontend, plus a legacy WordPress marketing site under `wordpress/`.

- Backend: Laravel 12, `app/` (PSR-4 `App\`)
- Frontend: Vue 2 + Vuex + vue-router, lives entirely under `frontend/` (its own `package.json`, separate from the root one)
- Frontend v3 (the new UI; goal: parity with the classic frontend, then classic is retired, see `V3-PARITY-PLAN.md`): Vue 3 + Vite 6 + TypeScript (with `allowJs`) + Pinia + vue-router 4 + vue-i18n + Tailwind CSS 3.4 (the palette is a set of `--k-*` CSS variables in `src/index.css`, light/dark switched by `data-theme` on `<html>`, mapped to utilities like `bg-k-card` in `tailwind.config.js`), components as `<script setup lang="ts">`, under `frontend-v3/`. Runs alongside the classic frontend at the same URLs; see "Two frontends, one URL space" below
- The root `package.json` is a thin wrapper that `cd`s into `frontend/` (and `frontend-v3/`) and also runs Laravel Mix for legacy asset compilation

## Working rules for this repo (read before running anything)

- **Language: English for docs and code comments.** When you change a document or a file with non-English comments, translate the parts you touch (or the whole document if it is short) in the same commit. Don't translate untouched docs in bulk. Commit messages and GitHub issues are in English. User-facing strings (UI, locales) stay multi-language.
- **Never run `npm run build` (or other root `npm` scripts) in the repo root.** It is the release script: `npm version patch --force` (edits `package.json` and `package-lock.json`, creates a commit "x.y.z" and a tag `vx.y.z`), then a Mix and a vue-cli production build that rewrite files in `public/`. Run the commands of one tool at a time, with an explicit `cd` or `git -C <path>`.
- **Stage explicit files** (`git add path1 path2`), never `git add -A` or `git add .`. Another person or session may be working in the same tree. Before pushing, look at `git log` and `git show --stat HEAD` for commits and files that are not yours.
- `master` is the stable line for backend fixes; the test server deploys the branch that is set by hand in the Ansible repo, so check it. `vue3-redesign` carries `frontend-v3` and the classic/new switch and is merged with `master` regularly. Merge into `master` only when asked. After a deploy that adds migrations, run `php artisan migrate` by hand (the deploy role does not).
- Backend changes: `php artisan test`, and update `TESTING-BACKLOG.md` in the same change (see "Working with Claude Code").

## Commands

### Backend (run from repo root)

```
composer install
php artisan migrate:fresh --seed     # rebuild DB from scratch
php artisan serve                    # dev server on :8000, check http://localhost:8000/api/version
php artisan test                     # run PHPUnit (or: vendor/bin/phpunit)
vendor/bin/phpunit --filter TestName # run a single test
php artisan storage:link             # required per-environment; also exposed as GET /storage_link (see Hosting.md)
```

See `TESTING-BACKLOG.md` for the current test coverage status and what's still planned — the stock `ExampleTest.php` placeholders have been replaced with real Feature/Unit tests.

### Frontend (from `frontend/`, or via root wrapper scripts)

```
cd frontend && npm install
npm run serve          # root wrapper: cd frontend && npm run serve (dev server)
npm run build           # root: version bump + Mix production build + vue-cli-service build --no-clean into ../public
npm run build:dev / build:qa / build:vps   # env-specific builds (see webpack.mix.js / frontend/vue.config.js modes)
npm run lint            # root wrapper -> frontend eslint
cd frontend && npm run test:unit    # Jest unit tests (@vue/cli-plugin-unit-jest)
cd frontend && npm run test:e2e     # Nightwatch e2e (chromedriver/geckodriver)
```

Frontend builds deliberately use `--no-clean` and each build script `rm -rf`s only `public/legacy` first — the Vue build output is published directly into the Laravel `public/` directory (no separate frontend server in production). Generated bundles go to `public/legacy/{js,css,img}` (`assetsDir: 'legacy'`), while `outputDir` stays `public/` so static files keep root URLs: `public/images/` backs `polls.bg_image_path = /images/...` and is not in git — never delete it (`public/img` is old build output; `public/images` is data). Production-mode builds write the shell to `resources/views/legacy.blade.php` (gitignored), which `FrontendController` serves. This namespacing exists so a Vue 3 redesign can ship its own `public/v3` + shell alongside. Note `build:dev` and `--mode=hetzner` run with NODE_ENV=development and write `public/index.html` instead of the blade; to test the Laravel-served shell locally use `NODE_ENV=production npx vue-cli-service build --no-clean --mode development` from `frontend/` and open `:8000`, not the `:8080` dev server.

### Frontend v3 (from `frontend-v3/`, or via root wrapper scripts)

```
cd frontend-v3 && npm install    # also run by the root postinstall
npm run build:v3                 # root: vue-tsc type check + vite build (also chained into build, build:vps, build:dev, build:qa)
npm run test:v3                  # root: Vitest (cd frontend-v3 && npm run test)
npm run serve:v3                 # root: Vite dev server on :5173, proxies /api, /sanctum, /logout, /images, /storage to :8000
composer run dev                 # repo root: php artisan serve + queue:listen + the v3 Vite server together (via npx concurrently)
```

The build writes bundles to `public/v3/assets/` (`base: '/v3/'`, `emptyOutDir` only touches `public/v3`) and moves the built `index.html` to `resources/views/v3.blade.php` (both gitignored), which `FrontendController` serves for `v3`. API calls are same-origin and relative, so one build works on every environment (no per-environment env files like the legacy frontend). Node ≥18: Vite 6 was chosen over 7 because build hosts and nvm4w dev machines vary. Auth is the shared Laravel session: on start the app loads `/api/user`; if not logged in it does a full page load to the legacy `/signin?url=…`. Logout clears the same `localStorage` keys the legacy frontend uses. The Vite dev server (`:5173`) needs `localhost:5173` in `SANCTUM_STATEFUL_DOMAINS`, and there it can't hand over to legacy pages (no Laravel shell); to test the handover, build and open `:8000`.

## Architecture

### ID handling — `hashids` middleware

Public-facing IDs are never raw DB autoincrement integers. Nearly every `api.php` route group is wrapped in the `hashids` middleware (`App\Kulibri\HashIds\Middleware\PublicIds`, registered in `app/Http/Kernel.php`), which uses `App\Kulibri\HashIds\Converter` subclasses (`RequestDecoder`/`ResponseEncoder`) to transparently decode incoming hashid strings to integer PKs before controllers run, and re-encode integer PKs back to hashids in the outgoing JSON. A `Checker` decides which request/response fields look like "an id" (or are blacklisted) per-route config. When tracing an ID end-to-end between frontend and DB, expect it to be a hashid on the wire and an int inside controllers/models — don't assume the value in a request/response body is the DB primary key.

### Two frontends, one URL space — `UiVersionResolver`

A Vue 3 redesign is being built to run alongside the legacy Vue 2 app at the **same URLs** (no `/v3/...` paths, no redirects). `routes/web.php`'s catch-all → `FrontendController` asks `App\Kulibri\Ui\UiVersionResolver` which shell view to render (`legacy` or `v3`, see `X-Kulibri-UI` / `X-Kulibri-UI-Reason` response headers). Legacy is always the fallback: v3 is served only when `KULIBRI_UI_V3_ENABLED` is true, the path is listed in `config/kulibri_ui.php` `v3_paths`, and the visitor's version is v3 (`?ui=` > `kulibri_ui` cookie > `users.ui_version` > `KULIBRI_UI_DEFAULT`, which is `v3`; set it to `legacy` to send visitors who chose nothing to classic). This is what keeps every poll/invitation/sign-in link already sent by email working — don't add a path to `v3_paths` before v3 fully implements it, and don't make v3 a default for unlisted paths. The `kulibri_ui` cookie is deliberately unencrypted (`EncryptCookies::$except`) but httponly. `resources/views/v3.blade.php` is currently a hand-written placeholder that will be replaced by the `frontend-v3/` build.

In-app (SPA) navigation never reaches the resolver, so each shell embeds `<script id="kulibri-ui-config" type="application/json">` (from `UiVersionResolver::clientConfig()`: v3 enabled, `v3Paths`, the visitor's preferred version and its source). The legacy router (`frontend/src/router.js` + `frontend/src/services/uiVersion.js`) does a full page load when navigating to a v3 path the user chose v3 for, e.g. the `/dashboard` push after login; the server then decides. It mirrors the server precedence (a `query`/`cookie` source beats the store user's `ui_version`), skips the first navigation to the URL the page was loaded at, and a sessionStorage marker prevents reload loops. The v3 frontend does the reverse (`frontend-v3/src/router/handover.ts`, same shared rules imported as `@kulibri/ui-version`): any path it doesn't route itself — poll links, `/pr/new`, account pages — hits a catch-all route whose guard does a full page load, so the legacy frontend opens it. When v3 takes over another page, add both its v3 route and its `config/kulibri_ui.php` `v3_paths` entry. The one standing exception is `preview/*`: leftovers of the screen handover from the Bolt prototype (a contacts preview and a redirect); no new preview screens are planned. The config line lives in both `frontend/public/index.html` and `frontend-v3/index.html` as Blade (`@json($uiClient ?? null)`); on the dev servers it stays unparsed and is ignored.

**Poll links** (`/pr/{id}[/{key}[/{publicKey}]]`) are one path for every poll type and both roles, so `v3_paths` cannot claim them. `UiVersionResolver` decides them per poll: only for a visitor who chose v3, when `polls.type` is in `KULIBRI_UI_V3_POLL_TYPES` (env, comma-separated; all four types are on when the variable is left out, an empty value switches them all off; only a visitor who chose v3 is affected, and only while `KULIBRI_UI_V3_ENABLED` is on) and the poll is not hidden (`KULIBRI_UI_V3_HIDDEN_POLLS`, off while SEC-009 is open). The cheap checks run first, so a visitor who did not choose v3 never costs a poll lookup, and any failure (unknown poll, broken lookup) is legacy; `X-Kulibri-UI-Reason` says why (`poll-type-legacy`, `poll-hidden`, `poll-not-found`, `poll-lookup-failed`). `pr/new` is never a poll. The client cannot know a poll's type, so `handoverTarget()` (shared JS, `isPollPath()` mirrors the server's matching) makes the browser ask the server with a full page load. `?classic=1` is the one-off classic view: legacy for that request only, no cookie, no account change, and the embedded config keeps the real preference (`classicOnce: true` makes the legacy client not bounce back to v3 from the same poll); `?ui=legacy` is the sticky switch. Legacy's `Poll.vue` keeps the query string when it redirects a three-part link to the public link, otherwise `?classic=1` would be lost. v3 boots without a login: a route needs one only with `meta.requiresAuth` (login guard in `frontend-v3/src/router/auth.ts`), so poll pages can be opened by anyone holding a link. Plan and status: `V3-PARITY-PLAN.md`.

### Routing

- `routes/api.php` is mounted under `/api` with the `api` middleware group (Sanctum stateful, rate-limited 2000/min) — see `RouteServiceProvider`.
- Most authenticated routes additionally require `auth:sanctum, verified, hashids, logger` (see the route groups in `api.php`).
- A large number of poll/participant/invitation endpoints are gated only by `hashids` + `logger` (no `auth:sanctum`) — these are the *public poll link* endpoints, reachable by anyone holding the right poll/participant/invitation hashid. This is intentional (polls are answered without login) but is also the security-sensitive surface referenced in `Design.md`'s open questions about public vs. personal links.
- `routes/web.php` is wrapped only in `hashids` and serves the WordPress-fronted / Blade side plus utility routes (e.g. `/storage_link`).
- A separate `App\Http\Controllers\Api\AuthController` (`/login`, `/me`, `/logout` via `auth:sanctum`) exists for a mobile (Flutter) client, distinct from the SPA's own session-based auth flow.

### Domain model (see `app/Models/`)

Central entities: `Poll` → `Choice` (poll options), `Participant` (a respondent) → `ParticipantValue`/`ParticipantAttribute` (their answers/custom fields), `Invitation` (an invite to a specific email, tracks `visited`), `Comment`, `Task`, `Resource`, `Design` (poll page theming). Billing: `Membership`, `Subscription`, `Invoice`, integrated via Mollie (`app/Http/Controllers/MollieController.php`, `PaymentController.php`) — webhooks at `/payment/webhook` and `/subscription/webhook`.

Key nuance (see `Design.md` for the full Q&A log the team is working through): a `Participant` can exist as a "temp" row created when someone opens a public poll link before entering their name — cleanup/merge semantics around temp participants and re-answering via the same link are still open questions in that doc, not fully resolved in code yet.

### Backend structure

- `app/Http/Controllers/` — one controller per resource; `Admin/` and `Api/` subfolders for admin-only and mobile-API-only endpoints respectively.
- `app/Services/` — cross-cutting logic (`PollExportService`, `NewsletterService`, `SignalFlow`, `UserNotificationService`).
- `app/Kulibri/` — project-specific infra (currently just `HashIds/`).
- `app/Repositories/` — only `SubscriptionRepository` so far, not a project-wide pattern.
- `app/Exports/` / `app/Imports/` — Maatwebsite Excel import/export (poll data, contacts).
- Excel export of poll details streams through a temp `export/` folder at the public root (must exist on the server — see `Hosting.md`).

### Frontend structure (`frontend/src/`)

- `store/` — Vuex, `store/modules/` per domain.
- `services/` — `signalflow.js` (presumably pairs with backend `SignalFlow` service).
- `views/` top-level pages, routed via `router.js`; `components/` for reusable pieces.
- `locales/` — vue-i18n translations. Locale is also synced from the WordPress site's `wp-wpml_current_language` cookie for logged-out users (see `Design.md`).
- Public poll links are built client-side as `${VUE_APP_FRONTEND_URI}/pr/{pollId}/{publicKey}`, where `publicKey` is derived from the poll owner's id (`ownerId.substring(0,4) + ownerId`) — a separate scheme from the backend hashids, worth keeping straight when debugging link/access issues.

## Frontend v3 and the parity plan

The new UI lives in `frontend-v3/`. It started as a separate Vue 3 rewrite by the Bolt team (`kulibri-rewrite`); its final state was merged into this folder on 7 October 2026, and that repository is frozen. **There is no separate rewrite project any more.**

**Goal:** parity with the classic frontend, then a parallel run of up to three months in which users try the new UI and give feedback, then classic is retired. Everything is in `V3-PARITY-PLAN.md`: the parity checklist (status of every function), the phases, the work tracks, the decisions needed from the product owner. **Keep it current:** update the checklist row in the same change that closes a gap, and check a row against the code and the classic UI before writing "same as classic".

- Working rules for `frontend-v3` (the 14 rules, the tests that enforce them, how we test a change): `docs/frontend-v3-rules.md`.
- Link formats and how the second segment of a poll link is read: `docs/route-map.md`.
- Premium: who is Premium, what it unlocks, the free limits, how a lock behaves in classic, the new UI and on the server (402), and what is still open: `docs/premium.md`. Change a Premium rule in all three places together (section 9 there).
- Grid, sorting, inline editing and paging (a concept for new work): `GRID-KONZEPT.md`.
- The history of the merge, the old plan and the rewrite's own documents (German) are in `docs/archive/` (read-only; look there for the reasoning behind a decision).
- For parity work use the `v3-parity` skill (`.claude/skills/v3-parity/`).
- **Changes to this backend only after agreement with Mitko.** Findings go to `SECURITY-BACKLOG.md` and the plan, section 6.5.

## Notes for future work

- `Design.md` at the repo root is a live design-questions doc covering unresolved behavior around public/personal poll links, the admin key vs. poll key relationship, invitation `visited` semantics, and temp-participant cleanup. Check it before making changes to invitation/participant/link logic — it documents known ambiguity, not settled behavior.
- `routes/api.php` contains a few routes worth treating cautiously rather than as patterns to copy: `GET /api/version` echoes `MAIL_USERNAME`/`MAIL_PASSWORD` env values into the response, and `POST /api/email` gates on a hardcoded literal string instead of real auth.

## Working with Claude Code

Shared across projects (not Kulibri-specific), kept in `c:\MyProjects\93-Technical-Notes\Claude Code\`:

- `collaboration-principles.md` — agreements on how we work together
- `claude-code-collaboration-guide.md` — guide for using AI coding agents effectively

How this applies here: Kulibri's work is mostly backlog/bug-fix driven rather than greenfield schema design, so the shared "design before implementation" principle shows up as *investigate → verify the finding empirically → propose the fix → get a go-ahead → implement → test*, especially for security-sensitive code (auth, access control, anything touching a "backdoor" or role check) and framework/library quirks that are easy to get subtly wrong on the first guess (e.g. `Mail::fake()` routing `ShouldQueue` mailables to `assertQueued` not `assertSent`, PhpSpreadsheet's CSV reader trimming trailing empty fields). `TESTING-BACKLOG.md` is the living status board for this — update its Status column in the same turn a test is added or completed, not as a write-once plan.
