No description
Find a file
2026-08-23 17:47:31 +02:00
.vscode feat: add bikes, reservations, users tables 2026-08-23 17:47:31 +02:00
db feat: add bikes, reservations, users tables 2026-08-23 17:47:31 +02:00
dev-db first commit 2026-08-23 16:53:16 +02:00
frontend feat: add bikes, reservations, users tables 2026-08-23 17:47:31 +02:00
src feat: add bikes, reservations, users tables 2026-08-23 17:47:31 +02:00
.env feat: add bikes, reservations, users tables 2026-08-23 17:47:31 +02:00
.gitignore first commit 2026-08-23 16:53:16 +02:00
build.rs first commit 2026-08-23 16:53:16 +02:00
Cargo.lock feat: add bikes, reservations, users tables 2026-08-23 17:47:31 +02:00
Cargo.toml feat: add bikes, reservations, users tables 2026-08-23 17:47:31 +02:00
config.example.yml feat: add bikes, reservations, users tables 2026-08-23 17:47:31 +02:00
README.md feat: add bikes, reservations, users tables 2026-08-23 17:47:31 +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

Authentication is not wired yet. Users are mirrored from AGEPoly's OIDC provider (Whiskey): users.oidc_sub is the identity, and AppController::login upserts the row from the claims of the token. Because the units also come from Whiskey, they are stored as plain text (units_users.unit_name, reservations.unit) rather than as an enum or a reference table: a unit unknown to us must never break a login.

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

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.

To build without a database (CI, docker image), commit the offline data:

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

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

users ──< units_users            a user belongs to several units (from Whiskey)
  │
  ├──< reservations_users >── reservations ──< reservations_bikes >── bikes
  └──── reservations.requester_id

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.