No description
Find a file
Antoine Pelletier 6c8715fee3 wip
2026-08-25 12:29:30 +02:00
.cargo feat: public calendar 2026-08-24 13:02:01 +02:00
.vscode feat: add oidc 2026-08-23 22:20:27 +02:00
db feat: add linka 2026-08-25 12:10:53 +02:00
dev-db first commit 2026-08-23 16:53:16 +02:00
frontend wip 2026-08-25 12:29:30 +02:00
src wip 2026-08-25 12:29:30 +02:00
.gitignore wip 2026-08-25 12:29:30 +02:00
build.rs first commit 2026-08-23 16:53:16 +02:00
Cargo.lock feat: add oidc 2026-08-23 22:20:27 +02:00
Cargo.toml feat: add oidc 2026-08-23 22:20:27 +02:00
config.example.yml feat: add linka 2026-08-25 12:10:53 +02:00
README.md feat: add linka 2026-08-25 12:10:53 +02:00

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
  • 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

# 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<Domain> 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:

cargo install sqlx-cli
cargo sqlx prepare      # writes .sqlx/, commit it

Linka Go

The locks are Linka Go's, and so is the list of who may open them. services/linka is the whole of what this app knows about the platform: the access token and its refresh, then one file per family of calls (locks, rentals, whitelist). The wording of what comes back is translated into the app's own terms (core/models/linka.rs) before anything else sees it — no serial number or lock id leaves that module.

A ticker runs one pass a minute (sync_linka):

  1. the fleet is read — lock state, battery, and who has a bike out. In use means a ride under way on that very bike, not a reservation that covers it;
  2. service state follows the platform: a bike out of service there is out of service here. The other way round is pushed as it happens, and a lock that refuses the change leaves the app unchanged (the api answers 502, and says so);
  3. the access list is reconciled: everybody entitled by a live reservation is on it, nobody else. Riders are let in 30 minutes before their booking and taken off 30 minutes after it. Approving, editing or cancelling reconciles straight away, without waiting for the tick.

The platform offers no way to read that list back, which is why linka_whitelist records what has actually been asked of it: the difference between "should be allowed" and "has been allowed" is what gets called, so a failed call is retried at the next tick and an edited reservation never leaves somebody behind.

A bike taken out with nothing entitling its rider to it raises an alert — on the admin page and in the Telegram group, once per episode.

On a development machine, set LINKA_DRY_RUN=1. The fleet is still read, but nothing is written: without it, the made-up addresses of db/seed.sql would be granted access to the real bikes.

Migrations

dbmate n add_something   # creates db/migrations/<timestamp>_add_something.sql
dbmate up                # applies, and regenerates db/schema.sql
dbmate rollback          # undoes the last migration (write your `migrate:down`!)

Frontend

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 <name>.

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.