# CarGAGEP Cargo bike reservation service for AGEPoly: a **Rust** backend (axum + sqlx + aide) serving a **Vue 3** frontend (TypeScript + vue-query + shadcn-vue), on **PostgreSQL** with **dbmate** migrations. The data model is in place (users, bikes, reservations) down to the sqlx layer; the api routes and the frontend views are not written yet. ## What you get - Layered backend (api / core / services), see [Backend](#backend) - An OpenAPI document generated from the code, and **TypeScript types generated from it**: the frontend cannot call an endpoint that does not exist - i18n (fr/en) on both sides, localized strings stored as JSONB - `dev-db/`: postgres + adminer in docker for development - Tailwind 4 and the shadcn-vue components already vendored in `frontend/src/components/ui` - A single binary in production: the backend serves the built frontend Users are mirrored from AGEPoly's OIDC provider (Whiskey): `users.oidc_sub` is the identity, and a login upserts the row from the claims of the token. Whiskey calls the units **groups** and sends them in the `groups` claim; each one is a row in `units`, shared by everybody who belongs to it, and the memberships are rewritten at every login — so a user removed from a group there loses it here too. A group we have never seen is created on the spot rather than rejected: an unknown unit must never break a login. `admin` is ours, and is never touched by a login. In a debug build, `dev_users` from the configuration can be logged in through `POST /api/login` without going through the provider — see the login page. `/reservations` needs a session and `/admin` needs an admin. The router guards are a convenience only: every protected route answers 401 or 403 on its own, whatever the frontend does. ## Getting started ```bash # 1. Database cd dev-db && docker compose up -d && cd .. # or use a local postgres psql -h localhost -U postgres -c 'CREATE DATABASE cargagep' dbmate up psql "$(grep DATABASE_URL .env | cut -d= -f2-)" -f db/seed.sql # optional demo data # 2. Configure the app cp config.example.yml config.yml # 3. Run the backend (port 3000) cargo run # 4. Run the frontend (port 5000, proxies /api to the backend) cd frontend && npm install && npm run dev ``` Open http://localhost:5000. The api documentation is on http://localhost:3000/api/docs. `config.yml` is gitignored: it is where the secrets go. `config.example.yml` documents every key, keep it up to date. Every value can also come from the environment (`APP__POSTGRES__PASSWORD=...`), which is how the app is configured in production. ## Layout ``` ├── src/ backend │ ├── api/ http layer: routes, extractors, OpenAPI │ ├── core/ business logic, independent of axum and sqlx │ │ ├── controller/ what the app can do │ │ ├── models/ domain types │ │ └── repositories/ traits describing what the core needs from the storage │ ├── services/ implementations of the repositories (postgres/sqlx) │ └── utils/ configuration ├── db/ │ ├── migrations/ dbmate migrations │ ├── schema.sql dump regenerated by dbmate, do not edit by hand │ └── seed.sql development data ├── dev-db/ postgres + adminer for development └── frontend/ └── src/ ├── components/ shared components (ui/ = shadcn-vue) ├── views/ one component per route ├── router/ route table ├── services/ api/ (one file per domain area) + i18n ├── lib/api.d.ts generated from the backend, never edited by hand ├── utils/types.ts shorthands over the generated schemas └── locales/ fr.yml / en.yml ``` ## Backend Requests flow through three layers, each one only knowing the next: ``` api (axum handler) -> core/controller -> core/repositories -> services/database status codes business logic trait sqlx + postgres domain models ``` The point of the repository traits is that the core never depends on sqlx: you can add another implementation (a mock in tests, another storage) without touching the logic. Handlers stay thin — extract the controller, call it, map the error to a status code — and their OpenAPI documentation sits right next to them (`fn *_docs`). ### Authorization is a type, not a check There are four controllers, each dereferencing into the one above it: ``` AnonAppController anybody: reading the fleet, the calendar, the login routes └─ AppController a logged in user ├─ ManagerAppController a member of one unit, acting for that unit └─ AdminAppController an admin ``` A handler declares what it needs in its signature. `AnonAppController` always extracts; `AppController` resolves the session cookie and answers **401** on its own, so a protected route cannot be left open by forgetting a check. Going further down is explicit and fallible — `try_into_manager(unit)` and `try_into_admin()`, wrapped by `api::helpers` so a refusal becomes a 403. Sessions are handled by `axum-login` on top of `tower-sessions`, wired in `api/mod.rs`. The store is in memory: **everybody is logged out when the backend restarts**. Swap it for a persistent store if that becomes a problem. ### Adding an entity 1. `dbmate n create_things` and write the migration, then `dbmate up` 2. `src/core/models/thing.rs`: the domain types 3. `src/core/repositories/things_repository.rs`: the trait, added to `DatabaseRepository` 4. `src/services/database/things.rs`: the sqlx implementation 5. `src/core/controller/things.rs`: the logic, plus a `ThingsControllerError` if needed 6. `src/api/things.rs`: the handlers and their docs, mounted in `src/api/mod.rs` 7. `cd frontend && npm run openapi` to regenerate the types, then write the service and the view Steps 1 to 5 are done for `users`, `bikes` and `reservations`; steps 6 and 7 are not. Until an api route exists, the whole stack is unused, which is why `src/main.rs` carries a crate-level `#![allow(dead_code)]` — delete it once the handlers are written. Request bodies are best kept separate from the domain models (an `api/models.rs` holding the `...Api` structs and their `Into` impls), so that the public contract does not change every time a domain model does. ### sqlx and compilation `query!`/`query_as!` check the sql against a **real database at compile time**, so the development database must be up and migrated for `cargo build` to work. `DATABASE_URL` is read from `.env`, which is **gitignored**: copy `.env.example` to `.env` and fill in the secrets there — never commit them. To build without a database (CI, docker image), commit the offline data: ```bash cargo install sqlx-cli cargo sqlx prepare # writes .sqlx/, commit it ``` ### Migrations ```bash dbmate n add_something # creates db/migrations/_add_something.sql dbmate up # applies, and regenerates db/schema.sql dbmate rollback # undoes the last migration (write your `migrate:down`!) ``` ## Frontend ```bash npm run dev # dev server on :5000, /api proxied to the backend on :3000 npm run build # type-check + build into dist/ npm run type-check npm run lint npm run format npm run openapi # regenerate src/lib/api.d.ts from the running backend ``` Rules of thumb: - views never call `fetch`: they use a hook from `services/api/`, which returns a vue-query query or mutation. Caching, loading and error states come for free. - mutations update the cache in `onSuccess` so the ui reacts immediately. - texts live in `locales/*.yml` and are used through `$t('key')`, never hardcoded. - add a shadcn-vue component with `npx shadcn-vue@latest add `. ## Production `cargo build --release`, `npm run build`, then point `frontend_dir` at the built `frontend/dist`: the backend serves the static files and falls back on `index.html` so the vue router keeps working on a page reload. Only one process to deploy. ## Data model ``` units ──< units_users >── users a unit (a Whiskey group) has many members, │ │ and a user belongs to many units │ ├──< reservations_users >── reservations │ └──── reservations.requester_id └──── reservations.unit_id exactly one unit borrows the bikes reservations ──< reservations_bikes >── bikes ``` A reservation moves through a state machine, enforced in `ReservationsController::set_reservation_status` and documented on `core::models::reservation::ReservationStatus`: ``` requested ──▶ refused │ ▼ approved ──▶ cancelled │ ▲ ▼ │ ongoing ────────┘ │ ▼ archived ``` `refused`, `cancelled` and `archived` are final. A bike is `in_service` or `out_of_service`; a bike that has ever been booked cannot be deleted (`ON DELETE RESTRICT`), take it out of service instead.