252 lines
11 KiB
Markdown
252 lines
11 KiB
Markdown
# 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<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:
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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
|
|
|
|
```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 <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.
|