feat: add bikes, reservations, users tables

This commit is contained in:
Antoine Pelletier 2026-08-23 17:47:31 +02:00
parent 25b8d726e0
commit 5169cad8ef
55 changed files with 2361 additions and 398 deletions

2
.env
View file

@ -1,2 +1,2 @@
# This file is used by dbmate, and by the sqlx macros at compile time.
DATABASE_URL=postgres://postgres:postgres@localhost:5432/app_template?sslmode=disable
DATABASE_URL=postgres://postgres:postgres@localhost:5432/cargagep?sslmode=disable

5
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"i18n-ally.localesPaths": [
"frontend/src/locales"
]
}

41
Cargo.lock generated
View file

@ -60,25 +60,6 @@ dependencies = [
"libc",
]
[[package]]
name = "app-template"
version = "0.1.0"
dependencies = [
"aide",
"async-trait",
"axum",
"config",
"schemars",
"serde",
"serde_json",
"sqlx",
"thiserror",
"tokio",
"tower-http",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "arraydeque"
version = "0.5.1"
@ -229,6 +210,26 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cargagep"
version = "0.1.0"
dependencies = [
"aide",
"async-trait",
"axum",
"chrono",
"config",
"schemars",
"serde",
"serde_json",
"sqlx",
"thiserror",
"tokio",
"tower-http",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "cc"
version = "1.4.0"
@ -253,6 +254,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
"serde",
"windows-link",
]
@ -1546,6 +1548,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"chrono",
"dyn-clone",
"indexmap",
"ref-cast",

View file

@ -1,5 +1,5 @@
[package]
name = "app-template"
name = "cargagep"
version = "0.1.0"
edition = "2024"
@ -13,8 +13,9 @@ aide = { version = "0.15.1", features = [
] }
async-trait = "0.1.89"
axum = { version = "0.8.9", features = ["macros"] }
chrono = { version = "0.4.45", default-features = false, features = ["serde", "clock", "std"] }
config = "0.15.23"
schemars = "0.9.0"
schemars = { version = "0.9", features = ["chrono04"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
sqlx = { version = "0.8.6", features = [

View file

@ -1,12 +1,11 @@
# App template
# CarGAGEP
Starting point for an AGEPoly web project: a **Rust** backend (axum + sqlx + aide) serving a
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 template is deliberately almost empty: one table (`items`), one route
(`GET /api/items`) and one page (home) that displays it. They exist to show how the layers
fit together — rename them, or delete them once your own code is in place.
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
@ -18,30 +17,28 @@ fit together — rename them, or delete them once your own code is in place.
- 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
There is **no authentication** in this template: add whatever the project needs.
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.
## Bootstrap a new project
## Getting started
```bash
cp -r template /path/to/my-project && cd /path/to/my-project
git init
# 1. Rename the crate and the database (app-template -> my-project)
./rename.sh my-project "My Project" && rm rename.sh
# 2. Start the development database
cd dev-db && docker compose up -d && cd ..
psql -h localhost -U postgres -c 'CREATE DATABASE my_project'
# 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
# 3. Configure the app
# 2. Configure the app
cp config.example.yml config.yml
# 4. Run the backend (port 3000)
# 3. Run the backend (port 3000)
cargo run
# 5. Run the frontend (port 5000, proxies /api to the backend)
# 4. Run the frontend (port 5000, proxies /api to the backend)
cd frontend && npm install && npm run dev
```
@ -103,7 +100,9 @@ their OpenAPI documentation sits right next to them (`fn *_docs`).
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
The `Item` entity follows exactly these steps: copy it.
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
@ -154,3 +153,33 @@ Rules of thumb:
`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.

View file

@ -9,7 +9,7 @@ postgres:
port: 5432
user: postgres
password: postgres
name: app_template
name: cargagep
# Directory containing the built frontend (frontend/dist after `npm run build`)
frontend_dir: frontend/dist

View file

@ -1,10 +0,0 @@
-- migrate:up
-- Example table. Localized texts are stored as JSONB: {"fr": "...", "en": "..."}
CREATE TABLE items (
id SERIAL PRIMARY KEY,
"name" JSONB NOT NULL,
"description" JSONB NOT NULL
);
-- migrate:down
DROP TABLE IF EXISTS items;

View file

@ -0,0 +1,28 @@
-- migrate:up
-- Users come from the OIDC provider (Whiskey): `oidc_sub` is the stable identity,
-- and a user row is created/refreshed on every login (see `upsert_user`).
CREATE TABLE users (
id SERIAL PRIMARY KEY,
external_id TEXT UNIQUE,
firstname TEXT NOT NULL,
"name" TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
oidc_sub TEXT NOT NULL UNIQUE,
admin BOOLEAN NOT NULL DEFAULT FALSE
);
-- The units a user belongs to. The list is provided by Whiskey, so the unit
-- identifiers are plain text rather than a fixed enum or a reference table:
-- a unit that appears in the provider must not break a login.
CREATE TABLE units_users (
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
unit_name TEXT NOT NULL,
PRIMARY KEY (user_id, unit_name)
);
CREATE INDEX units_users_unit_name_idx ON units_users (unit_name);
-- migrate:down
DROP TABLE IF EXISTS units_users;
DROP TABLE IF EXISTS users;

View file

@ -0,0 +1,22 @@
-- migrate:up
-- A bike is either available for reservation, or withdrawn from the fleet
-- (maintenance, damage, ...). The set of states is fixed by the app, so an enum
-- type is the right fit here.
CREATE TYPE bike_status AS ENUM ('in_service', 'out_of_service');
CREATE TABLE bikes (
id SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
-- Identifier written on the keys, and how many of them exist for this bike
key_number TEXT,
key_quantity INTEGER NOT NULL DEFAULT 0,
drivetrain TEXT,
battery TEXT,
status bike_status NOT NULL DEFAULT 'in_service',
CONSTRAINT bikes_key_quantity_positive CHECK (key_quantity >= 0)
);
-- migrate:down
DROP TABLE IF EXISTS bikes;
DROP TYPE IF EXISTS bike_status;

View file

@ -0,0 +1,73 @@
-- migrate:up
-- Lifecycle of a reservation:
--
-- requested ──▶ refused
-- │
-- ▼
-- approved ──▶ cancelled
-- │ ▲
-- ▼ │
-- ongoing ────────┘
-- │
-- ▼
-- archived
--
-- `refused`, `cancelled` and `archived` are final. The transitions are enforced
-- in the controller (`ReservationStatus::can_transition_to`), not by a trigger,
-- so the rule stays with the rest of the business logic.
CREATE TYPE reservation_status AS ENUM (
'requested',
'refused',
'approved',
'cancelled',
'ongoing',
'archived'
);
CREATE TABLE reservations (
id SERIAL PRIMARY KEY,
-- Exactly one unit borrows the bikes. Free text for the same reason as
-- `units_users.unit_name`: the value comes from Whiskey.
unit TEXT NOT NULL,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
-- Who filed the request; also part of `reservations_users`
requester_id INTEGER NOT NULL REFERENCES users (id),
-- Telegram handle to reach the group, stored with its leading '@'
telegram TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
status reservation_status NOT NULL DEFAULT 'requested',
CONSTRAINT reservations_time_order CHECK (end_time > start_time),
CONSTRAINT reservations_telegram_handle CHECK (
telegram ~ '^@[A-Za-z][A-Za-z0-9_]{4,31}$'
)
);
CREATE INDEX reservations_unit_idx ON reservations (unit);
CREATE INDEX reservations_status_idx ON reservations (status);
CREATE INDEX reservations_period_idx ON reservations (start_time, end_time);
-- People allowed to pick the bikes up for this reservation
CREATE TABLE reservations_users (
reservation_id INTEGER NOT NULL REFERENCES reservations (id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
PRIMARY KEY (reservation_id, user_id)
);
-- Bikes booked by this reservation. A bike that has been booked cannot be
-- deleted (ON DELETE RESTRICT): take it out of the fleet with
-- `status = 'out_of_service'` instead.
CREATE TABLE reservations_bikes (
reservation_id INTEGER NOT NULL REFERENCES reservations (id) ON DELETE CASCADE,
bike_id INTEGER NOT NULL REFERENCES bikes (id) ON DELETE RESTRICT,
PRIMARY KEY (reservation_id, bike_id)
);
CREATE INDEX reservations_bikes_bike_id_idx ON reservations_bikes (bike_id);
-- migrate:down
DROP TABLE IF EXISTS reservations_bikes;
DROP TABLE IF EXISTS reservations_users;
DROP TABLE IF EXISTS reservations;
DROP TYPE IF EXISTS reservation_status;

View file

@ -1,7 +1,7 @@
\restrict dbmate
-- Dumped from database version 18.4
-- Dumped by pg_dump version 18.4
-- Dumped from database version 18.6
-- Dumped by pg_dump version 18.6
SET statement_timeout = 0;
SET lock_timeout = 0;
@ -15,26 +15,55 @@ SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: bike_status; Type: TYPE; Schema: public; Owner: -
--
CREATE TYPE public.bike_status AS ENUM (
'in_service',
'out_of_service'
);
--
-- Name: reservation_status; Type: TYPE; Schema: public; Owner: -
--
CREATE TYPE public.reservation_status AS ENUM (
'requested',
'refused',
'approved',
'cancelled',
'ongoing',
'archived'
);
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- Name: items; Type: TABLE; Schema: public; Owner: -
-- Name: bikes; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.items (
CREATE TABLE public.bikes (
id integer NOT NULL,
name jsonb NOT NULL,
description jsonb NOT NULL
name text NOT NULL,
key_number text,
key_quantity integer DEFAULT 0 NOT NULL,
drivetrain text,
battery text,
status public.bike_status DEFAULT 'in_service'::public.bike_status NOT NULL,
CONSTRAINT bikes_key_quantity_positive CHECK ((key_quantity >= 0))
);
--
-- Name: items_id_seq; Type: SEQUENCE; Schema: public; Owner: -
-- Name: bikes_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.items_id_seq
CREATE SEQUENCE public.bikes_id_seq
AS integer
START WITH 1
INCREMENT BY 1
@ -44,10 +73,68 @@ CREATE SEQUENCE public.items_id_seq
--
-- Name: items_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
-- Name: bikes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.items_id_seq OWNED BY public.items.id;
ALTER SEQUENCE public.bikes_id_seq OWNED BY public.bikes.id;
--
-- Name: reservations; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.reservations (
id integer NOT NULL,
unit text NOT NULL,
start_time timestamp with time zone NOT NULL,
end_time timestamp with time zone NOT NULL,
requester_id integer NOT NULL,
telegram text NOT NULL,
description text DEFAULT ''::text NOT NULL,
status public.reservation_status DEFAULT 'requested'::public.reservation_status NOT NULL,
CONSTRAINT reservations_telegram_handle CHECK ((telegram ~ '^@[A-Za-z][A-Za-z0-9_]{4,31}$'::text)),
CONSTRAINT reservations_time_order CHECK ((end_time > start_time))
);
--
-- Name: reservations_bikes; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.reservations_bikes (
reservation_id integer NOT NULL,
bike_id integer NOT NULL
);
--
-- Name: reservations_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.reservations_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: reservations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.reservations_id_seq OWNED BY public.reservations.id;
--
-- Name: reservations_users; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.reservations_users (
reservation_id integer NOT NULL,
user_id integer NOT NULL
);
--
@ -60,18 +147,101 @@ CREATE TABLE public.schema_migrations (
--
-- Name: items id; Type: DEFAULT; Schema: public; Owner: -
-- Name: units_users; Type: TABLE; Schema: public; Owner: -
--
ALTER TABLE ONLY public.items ALTER COLUMN id SET DEFAULT nextval('public.items_id_seq'::regclass);
CREATE TABLE public.units_users (
user_id integer NOT NULL,
unit_name text NOT NULL
);
--
-- Name: items items_pkey; Type: CONSTRAINT; Schema: public; Owner: -
-- Name: users; Type: TABLE; Schema: public; Owner: -
--
ALTER TABLE ONLY public.items
ADD CONSTRAINT items_pkey PRIMARY KEY (id);
CREATE TABLE public.users (
id integer NOT NULL,
external_id text,
firstname text NOT NULL,
name text NOT NULL,
email text NOT NULL,
oidc_sub text NOT NULL,
admin boolean DEFAULT false NOT NULL
);
--
-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.users_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id;
--
-- Name: bikes id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.bikes ALTER COLUMN id SET DEFAULT nextval('public.bikes_id_seq'::regclass);
--
-- Name: reservations id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.reservations ALTER COLUMN id SET DEFAULT nextval('public.reservations_id_seq'::regclass);
--
-- Name: users id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass);
--
-- Name: bikes bikes_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.bikes
ADD CONSTRAINT bikes_pkey PRIMARY KEY (id);
--
-- Name: reservations_bikes reservations_bikes_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.reservations_bikes
ADD CONSTRAINT reservations_bikes_pkey PRIMARY KEY (reservation_id, bike_id);
--
-- Name: reservations reservations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.reservations
ADD CONSTRAINT reservations_pkey PRIMARY KEY (id);
--
-- Name: reservations_users reservations_users_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.reservations_users
ADD CONSTRAINT reservations_users_pkey PRIMARY KEY (reservation_id, user_id);
--
@ -82,6 +252,129 @@ ALTER TABLE ONLY public.schema_migrations
ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
--
-- Name: units_users units_users_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.units_users
ADD CONSTRAINT units_users_pkey PRIMARY KEY (user_id, unit_name);
--
-- Name: users users_email_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_email_key UNIQUE (email);
--
-- Name: users users_external_id_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_external_id_key UNIQUE (external_id);
--
-- Name: users users_oidc_sub_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_oidc_sub_key UNIQUE (oidc_sub);
--
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
--
-- Name: reservations_bikes_bike_id_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX reservations_bikes_bike_id_idx ON public.reservations_bikes USING btree (bike_id);
--
-- Name: reservations_period_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX reservations_period_idx ON public.reservations USING btree (start_time, end_time);
--
-- Name: reservations_status_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX reservations_status_idx ON public.reservations USING btree (status);
--
-- Name: reservations_unit_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX reservations_unit_idx ON public.reservations USING btree (unit);
--
-- Name: units_users_unit_name_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX units_users_unit_name_idx ON public.units_users USING btree (unit_name);
--
-- Name: reservations_bikes reservations_bikes_bike_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.reservations_bikes
ADD CONSTRAINT reservations_bikes_bike_id_fkey FOREIGN KEY (bike_id) REFERENCES public.bikes(id) ON DELETE RESTRICT;
--
-- Name: reservations_bikes reservations_bikes_reservation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.reservations_bikes
ADD CONSTRAINT reservations_bikes_reservation_id_fkey FOREIGN KEY (reservation_id) REFERENCES public.reservations(id) ON DELETE CASCADE;
--
-- Name: reservations reservations_requester_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.reservations
ADD CONSTRAINT reservations_requester_id_fkey FOREIGN KEY (requester_id) REFERENCES public.users(id);
--
-- Name: reservations_users reservations_users_reservation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.reservations_users
ADD CONSTRAINT reservations_users_reservation_id_fkey FOREIGN KEY (reservation_id) REFERENCES public.reservations(id) ON DELETE CASCADE;
--
-- Name: reservations_users reservations_users_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.reservations_users
ADD CONSTRAINT reservations_users_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
--
-- Name: units_users units_users_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.units_users
ADD CONSTRAINT units_users_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
--
-- PostgreSQL database dump complete
--
@ -94,4 +387,6 @@ ALTER TABLE ONLY public.schema_migrations
--
INSERT INTO public.schema_migrations (version) VALUES
('20260101000000');
('20260823153300'),
('20260823153310'),
('20260823153320');

View file

@ -2,12 +2,45 @@
-- psql "$DATABASE_URL" -f db/seed.sql
BEGIN;
INSERT INTO public.items (id, "name", "description") VALUES
(1, '{"fr": "Premier objet", "en": "First item"}', '{"fr": "Un objet de démonstration.", "en": "A demo item."}'),
(2, '{"fr": "Deuxième objet", "en": "Second item"}', '{"fr": "Un autre objet de démonstration.", "en": "Another demo item."}')
INSERT INTO public.users (id, external_id, firstname, "name", email, oidc_sub, admin) VALUES
(1, '100001', 'Alice', 'Martin', 'alice.martin@epfl.ch', 'oidc-sub-alice', TRUE),
(2, '100002', 'Bob', 'Dupont', 'bob.dupont@epfl.ch', 'oidc-sub-bob', FALSE),
(3, NULL, 'Chloé', 'Favre', 'chloe.favre@epfl.ch', 'oidc-sub-chloe', FALSE)
ON CONFLICT DO NOTHING;
-- Keep the sequence in sync with the explicit ids inserted above
SELECT setval('public.items_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.items));
-- Unit names come from Whiskey; these are placeholders for development
INSERT INTO public.units_users (user_id, unit_name) VALUES
(1, 'agepoly'),
(2, 'agepoly'),
(2, 'clic'),
(3, 'clic')
ON CONFLICT DO NOTHING;
INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, battery, status) VALUES
(1, 'Cargo 1', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'),
(2, 'Cargo 2', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service'),
(3, 'Cargo 3', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'out_of_service')
ON CONFLICT DO NOTHING;
INSERT INTO public.reservations
(id, unit, start_time, end_time, requester_id, telegram, "description", status) VALUES
(1, 'agepoly', '2026-09-01 08:00:00+02', '2026-09-01 18:00:00+02', 1, '@alice_martin',
'Transport du matériel pour la rentrée', 'approved'),
(2, 'clic', '2026-09-05 09:00:00+02', '2026-09-06 17:00:00+02', 3, '@chloe_favre',
'Déménagement du stock de la commission', 'requested')
ON CONFLICT DO NOTHING;
INSERT INTO public.reservations_users (reservation_id, user_id) VALUES
(1, 1), (1, 2), (2, 3)
ON CONFLICT DO NOTHING;
INSERT INTO public.reservations_bikes (reservation_id, bike_id) VALUES
(1, 1), (1, 2), (2, 1)
ON CONFLICT DO NOTHING;
-- Keep the sequences in sync with the explicit ids inserted above
SELECT setval('public.users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.users));
SELECT setval('public.bikes_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.bikes));
SELECT setval('public.reservations_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.reservations));
COMMIT;

View file

@ -9,7 +9,7 @@
href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.3.2/css/flag-icons.min.css"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App template</title>
<title>CarGAGEP</title>
</head>
<body>
<div id="app"></div>

View file

@ -1,5 +1,5 @@
{
"name": "app-template-frontend",
"name": "cargagep-frontend",
"version": "0.0.0",
"private": true,
"type": "module",

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View file

@ -39,57 +39,10 @@ export interface paths {
patch?: never
trace?: never
}
'/api/items': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Get the list of items */
get: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Item'][]
}
}
}
}
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
}
export type webhooks = Record<string, never>
export interface components {
schemas: {
Item: {
description: components['schemas']['LocalizedString']
/** Format: int32 */
id: number
name: components['schemas']['LocalizedString']
}
/** @description Translated string, stored as a JSONB column in postgres */
LocalizedString: {
en: string
fr: string
}
}
schemas: never
responses: never
parameters: never
requestBodies: never

View file

@ -1,12 +1,9 @@
locale: en
app:
title: App template
title: CarGAGEP
sidebar:
navigation: Navigation
home: Home
home:
welcome: Welcome!
version: 'Backend version: {version}'
items: Items
items-empty: No item yet.
items-error: Unable to load the items.

View file

@ -1,12 +1,9 @@
locale: fr
app:
title: App template
title: CarGAGEP
sidebar:
navigation: Navigation
home: Accueil
home:
welcome: Bienvenue !
version: 'Version du backend : {version}'
items: Objets
items-empty: Aucun objet pour le moment.
items-error: Impossible de charger les objets.

View file

@ -1,39 +0,0 @@
/**
* Example api service: one file per domain area, exposing vue-query hooks.
* Views never call `fetch` themselves, they use these hooks and get caching,
* loading and error states for free.
*/
import { useQuery } from '@tanstack/vue-query'
import { HttpStatus } from 'http-status-ts'
import { computed } from 'vue'
import { i18n } from '../i18n'
import { getClient } from './client'
export function useItems() {
const query = useQuery({
queryKey: ['items'],
staleTime: 3600 * 1000,
queryFn: async () => {
const { data, response } = await getClient().GET('/api/items')
if (response.status === HttpStatus.OK) {
return data
}
throw new Error(`Unexpected status code received: ${response.status}`)
},
})
const locale = i18n.locale
// Localized fields are resolved here, so the views only deal with strings
return {
...query,
data: computed(() =>
query.data.value?.map((item) => ({
...item,
name: item.name[locale.value],
description: item.description[locale.value],
})),
),
}
}

View file

@ -1,5 +1,7 @@
import type { components } from '@/lib/api'
// Shorthands over the generated schemas, so views never import `@/lib/api` directly
export type LocalizedString = components['schemas']['LocalizedString']
export type Item = components['schemas']['Item']
// Shorthands over the generated schemas, so views never import `@/lib/api` directly.
// Re-export a type here for every schema the views use, e.g.
// export type Bike = components['schemas']['Bike']
// once `src/api/` exposes the routes and `npm run openapi` has been run again.
export type Schemas = components['schemas']

View file

@ -1,34 +1,16 @@
<script setup lang="ts">
/**
* Only page of the template: shows the backend version and the example route.
* Replace its content with your own, and add a view per route in `router/index.ts`.
* Only page so far: shows the backend version.
* Add a view per route in `router/index.ts`.
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { useItems } from '@/services/api/items'
import { useVersion } from '@/services/api/misc'
const { data: version } = useVersion()
const { data: items, isPending, isError } = useItems()
</script>
<template>
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-semibold">{{ $t('home.welcome') }}</h1>
<p class="text-muted-foreground text-sm">{{ $t('home.version', { version }) }}</p>
<h2 class="text-lg font-medium">{{ $t('home.items') }}</h2>
<div v-if="isPending" class="flex flex-col gap-2">
<Skeleton class="h-20 w-full" />
<Skeleton class="h-20 w-full" />
</div>
<p v-else-if="isError" class="text-destructive">{{ $t('home.items-error') }}</p>
<p v-else-if="!items?.length" class="text-muted-foreground">{{ $t('home.items-empty') }}</p>
<Card v-for="item in items" v-else :key="item.id">
<CardHeader>
<CardTitle>{{ item.name }}</CardTitle>
</CardHeader>
<CardContent class="text-muted-foreground">{{ item.description }}</CardContent>
</Card>
</div>
</template>

View file

@ -1,56 +0,0 @@
#!/usr/bin/env bash
# Renames the template to your own project name.
#
# ./rename.sh my-project ["My Project"]
#
# Replaces app-template / app_template / "App template" everywhere they are used
# (crate name, database name, package name, page title, ...). Run it once, right
# after copying the template, then delete this script.
set -euo pipefail
if [ $# -lt 1 ]; then
echo "usage: $0 <project-name> [\"Display Name\"]" >&2
exit 1
fi
NAME="$1"
SNAKE="${NAME//-/_}"
DISPLAY="${2:-$NAME}"
if ! [[ "$NAME" =~ ^[a-z][a-z0-9-]*$ ]]; then
echo "The project name must be lowercase, and may contain digits and dashes." >&2
exit 1
fi
cd "$(dirname "$0")"
FILES=(
Cargo.toml
Cargo.lock
.env
config.example.yml
config.yml
README.md
src/utils/config.rs
frontend/package.json
frontend/index.html
frontend/src/locales/fr.yml
frontend/src/locales/en.yml
)
for file in "${FILES[@]}"; do
[ -f "$file" ] || continue
sed -i \
-e "s/app-template/$NAME/g" \
-e "s/app_template/$SNAKE/g" \
-e "s/App template/$DISPLAY/g" \
"$file"
echo "updated $file"
done
echo
echo "Done. Next steps:"
echo " - create the database: psql -h localhost -U postgres -c 'CREATE DATABASE $SNAKE'"
echo " - dbmate up"
echo " - cp config.example.yml config.yml"
echo " - rm rename.sh"

180
src/api/auth.rs Normal file
View file

@ -0,0 +1,180 @@
use aide::{
NoApi,
axum::{
ApiRouter,
routing::{get_with, post_with},
},
transform::TransformOperation,
};
use axum::{Json, debug_handler, http::StatusCode};
use axum_login::AuthSession;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tracing::{debug, error, info};
use crate::{
api::helpers::unexpected_error,
core::{
controller::{AnonAppController, ControllerError, authn::AuthnControllerError},
models::user::User,
},
};
pub fn routes() -> ApiRouter {
let mut r = ApiRouter::new()
.api_route("/api/logout", post_with(logout, logout_docs))
// .api_route("/api/me", get_with(me, me_docs))
.api_route(
"/api/whiskey/authorize",
get_with(whiskey_authorize, whiskey_authorize_docs),
)
.api_route(
"/api/whiskey/callback",
post_with(whiskey_callback, whiskey_callback_docs),
);
#[cfg(debug_assertions)]
{
r = r.api_route("/api/login", post_with(login_dev, login_dev_docs))
}
r
}
#[debug_handler]
async fn logout(
NoApi(mut auth_session): NoApi<AuthSession<AnonAppController>>,
) -> Result<(), StatusCode> {
debug!("[HANDLER] logout");
if auth_session.user.is_none() {
debug!("[HANDLER] logout failed: no active session");
return Err(StatusCode::UNAUTHORIZED);
}
match auth_session.logout().await {
Ok(_) => {
debug!("[HANDLER] logout successful");
Ok(())
}
Err(err) => {
error!("[HANDLER] logout failed: {err:?}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
fn logout_docs(op: TransformOperation) -> TransformOperation {
op.summary("logout the user")
.tag("Auth")
.response::<401, ()>()
.security_requirement("session_cookie")
}
#[derive(Debug, JsonSchema, Serialize)]
struct GetAuthorizeResponse {
redirect_to: String,
}
#[debug_handler]
async fn whiskey_authorize(
aac: AnonAppController,
) -> Result<Json<GetAuthorizeResponse>, (StatusCode, String)> {
match aac.whiskey_authorize().await {
Err(ControllerError::Authn(AuthnControllerError::OIDCProtocolError)) => {
info!("[HANDLER] whiskey_authorize: protocol error");
Err((StatusCode::BAD_REQUEST, "Protocol error".to_owned()))
}
Err(err) => unexpected_error("whiskey_authorize", err),
Ok(redirect_to) => Ok(Json(GetAuthorizeResponse { redirect_to })),
}
}
fn whiskey_authorize_docs(op: TransformOperation) -> TransformOperation {
op.summary("Whiskey - Get authorization URL")
.tag("Auth")
.response::<400, ()>()
}
#[derive(Debug, JsonSchema, Deserialize)]
struct PostCallbackParams {
code: String,
state: String,
}
#[debug_handler]
async fn whiskey_callback(
aac: AnonAppController,
NoApi(mut auth_session): NoApi<AuthSession<AnonAppController>>,
Json(PostCallbackParams { code, state }): Json<PostCallbackParams>,
) -> Result<Json<User>, (StatusCode, String)> {
match aac.whiskey_callback(code, state).await {
Ok(user) => {
let login_res = auth_session.login(&user).await;
if let Err(err) = login_res {
error!("[HANDLER] whiskey_callback failed to login the user: {err:?}");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Unexpected error".to_owned(),
));
}
Ok(Json(user.into()))
}
Err(ControllerError::Authn(AuthnControllerError::NotAuthorized)) => {
info!("[HANDLER] whiskey_callback: user not authorized (missing group)");
Err((
StatusCode::FORBIDDEN,
"You are not authorized to access this yet".to_owned(),
))
}
Err(ControllerError::Authn(AuthnControllerError::OIDCProtocolError)) => {
info!("[HANDLER] whiskey_callback: protocol error");
Err((StatusCode::BAD_REQUEST, "Protocol error".to_owned()))
}
Err(err) => unexpected_error("whiskey_callback", err),
}
}
fn whiskey_callback_docs(op: TransformOperation) -> TransformOperation {
op.summary("Whiskey - Callback endpoint")
.tag("Auth")
.response::<400, ()>()
.response::<403, ()>()
}
#[cfg(debug_assertions)]
#[derive(Debug, Deserialize, JsonSchema)]
struct LoginDevForm {
pub user: String,
}
#[cfg(debug_assertions)]
#[debug_handler]
async fn login_dev(
aac: AnonAppController,
NoApi(mut auth): NoApi<AuthSession<AnonAppController>>,
Json(form): Json<LoginDevForm>,
) -> Result<(), StatusCode> {
match aac.get_dev_user(form.user).await {
Ok(user) => {
if let Err(err) = auth.login(&user).await {
error!("Login dev: failed to login the user: {err:?}");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
Ok(())
}
Err(err) => {
error!("Login dev: failed to get dev user: {err:?}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
#[cfg(debug_assertions)]
fn login_dev_docs(op: TransformOperation) -> TransformOperation {
op.tag("Auth")
.summary("Login with a dev user")
.description("This function expect only the name of the user. It created the user in db if required and logins the user with that user.")
}

View file

@ -15,10 +15,6 @@ use axum::{Extension, Json, response::IntoResponse, routing::get};
pub fn api_docs_metadata(api: TransformOpenApi) -> TransformOpenApi {
api.title(&format!("{} API documentation", env!("CRATE_NAME")))
.description(format!("Build version: {}", env!("GIT_HASH")).as_str())
.tag(Tag {
name: "Items".to_owned(),
..Default::default()
})
.tag(Tag {
name: "misc".to_owned(),
..Default::default()

View file

@ -1,29 +0,0 @@
//! Example route. Copy this file for your own domain areas, and mount it in `mod.rs`.
use aide::{
axum::{ApiRouter, routing::get_with},
transform::TransformOperation,
};
use axum::{Json, http::StatusCode};
use crate::{
api::helpers::unexpected_error,
core::{controller::AppController, models::item::Item},
};
pub fn routes() -> ApiRouter {
ApiRouter::new().api_route("/", get_with(get_items, get_items_docs))
}
#[axum::debug_handler]
async fn get_items(controller: AppController) -> Result<Json<Vec<Item>>, (StatusCode, String)> {
match controller.get_items().await {
Ok(items) => Ok(Json(items)),
// Every expected error gets its own arm and status code above this one
Err(err) => unexpected_error("get_items", err),
}
}
fn get_items_docs(op: TransformOperation) -> TransformOperation {
op.tag("Items").summary("Get the list of items")
}

View file

@ -21,7 +21,6 @@ use crate::core::controller::AppController;
mod docs;
mod helpers;
mod items;
pub fn get_router(controller: AppController) -> Router {
aide::generate::on_error(|err| error!("aide generated error: {err}"));
@ -36,7 +35,6 @@ pub fn get_router(controller: AppController) -> Router {
|op| op.tag("misc").summary("Get app version"),
),
)
.nest_api_service("/api/items", items::routes())
.nest_api_service("/api/docs", docs::routes())
.finish_api_with(&mut api, docs::api_docs_metadata)
.layer(Extension(controller))

View file

@ -0,0 +1,141 @@
use axum_login::{AuthUser, AuthnBackend};
use thiserror::Error;
use crate::{
core::{
controller::{AnonAppController, ControllerError},
models::user::{User, UserNoId},
},
utils::whiskey::{WhiskeyError, authorize, callback},
};
impl AuthUser for User {
type Id = i32;
fn id(&self) -> Self::Id {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
&self.oidc_sub.as_bytes()
}
}
impl AuthnBackend for AnonAppController {
type User = User;
type Credentials = ();
type Error = ControllerError;
async fn authenticate(
&self,
_creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
Ok(None)
}
async fn get_user(
&self,
user_id: &axum_login::UserId<Self>,
) -> Result<Option<Self::User>, Self::Error> {
Ok(Some(self.db.get_user(user_id.clone()).await?))
}
}
impl super::AnonAppController {
pub async fn whiskey_authorize(&self) -> Result<String, ControllerError> {
match authorize().await {
Ok((redirect_to, authorize_backend_data)) => {
self.db.save_whiskey_data(authorize_backend_data).await?;
Ok(redirect_to)
}
Err(WhiskeyError::ProtocolError) => Err(ControllerError::Authn(
AuthnControllerError::OIDCProtocolError,
)),
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
"Originated from Whiskey".to_string(),
)),
}
}
async fn get_or_create_user_oidc(
&self,
sciper: String,
firstname: String,
name: String,
sub: String,
email: String,
) -> Result<User, ControllerError> {
self.db
.upsert_user(UserNoId {
external_id: Some(sciper),
firstname,
name,
email,
oidc_sub: sub,
units: vec![], //TODO use real units
admin: false,
})
.await
.map_err(Into::into)
}
pub async fn whiskey_callback(
&self,
code: String,
state: String,
) -> Result<User, ControllerError> {
let backend_data = self.db.get_whiskey_data(state.clone()).await?;
match callback(code, state, backend_data).await {
Ok(user_info) => {
self.get_or_create_user_oidc(
user_info.sciper,
user_info.firstname,
user_info.name,
user_info.sub,
user_info.email,
)
.await
}
Err(WhiskeyError::ProtocolError) => Err(ControllerError::Authn(
AuthnControllerError::OIDCProtocolError,
)),
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
"originated from Whiskey".to_string(),
)),
}
}
#[cfg(debug_assertions)]
pub async fn get_dev_user(&self, username: String) -> Result<User, ControllerError> {
use crate::core::repositories::RepositoryError;
let user = self.db.get_user_external_id(username.clone()).await;
let user = if let Err(RepositoryError::NotFound(_)) = user {
use crate::utils::config;
let user = config::get()
.get_dev_users()
.into_iter()
.find(|u| u.external_id.clone().is_some_and(|u| u == username))
.ok_or(AuthnControllerError::DevUserNotFound)?;
self.db.upsert_user(user).await?
} else {
user?
};
Ok(user)
}
}
#[derive(Error, Debug)]
pub enum AuthnControllerError {
#[error("OIDC protocol error")]
OIDCProtocolError,
#[error("Not authenticated")]
NotAuthenticated,
#[error("Not authorized")]
NotAuthorized,
#[error("Dev user does not exists")]
DevUserNotFound,
}

View file

@ -0,0 +1,52 @@
use thiserror::Error;
use crate::core::{
controller::ControllerError,
models::bike::{Bike, BikeId, BikeStatus, NewBike},
};
impl super::AppController {
pub async fn get_bikes(&self) -> Result<Vec<Bike>, ControllerError> {
self.db.get_bikes().await.map_err(Into::into)
}
pub async fn get_bike(&self, id: BikeId) -> Result<Bike, ControllerError> {
self.db.get_bike(id).await.map_err(Into::into)
}
pub async fn create_bike(&self, bike: NewBike) -> Result<Bike, ControllerError> {
if bike.key_quantity < 0 || bike.name.trim().is_empty() {
return Err(BikesControllerError::BikeInvalid.into());
}
self.db.create_bike(bike).await.map_err(Into::into)
}
pub async fn update_bike(&self, bike: Bike) -> Result<(), ControllerError> {
if bike.key_quantity < 0 || bike.name.trim().is_empty() {
return Err(BikesControllerError::BikeInvalid.into());
}
if bike == self.db.get_bike(bike.id).await? {
return Ok(());
}
self.db.update_bike(bike).await.map_err(Into::into)
}
pub async fn set_bike_status(
&self,
id: BikeId,
status: BikeStatus,
) -> Result<(), ControllerError> {
self.db
.set_bike_status(id, status)
.await
.map_err(Into::into)
}
}
#[derive(Error, Debug)]
pub enum BikesControllerError {
#[error("The bike is malformed")]
BikeInvalid,
#[error("The bike appears in a reservation: take it out of service instead of deleting it")]
BikeReserved,
}

View file

@ -1,19 +0,0 @@
//! Example business logic. Copy this file for your own domain areas.
use thiserror::Error;
use crate::core::{controller::ControllerError, models::item::Item};
impl super::AppController {
pub async fn get_items(&self) -> Result<Vec<Item>, ControllerError> {
self.db.get_items().await.map_err(Into::into)
}
}
/// Errors specific to this domain area, turned into status codes by the api
#[derive(Error, Debug)]
#[allow(dead_code)]
pub enum ItemsControllerError {
#[error("The item is malformed")]
ItemInvalid,
}

View file

@ -2,19 +2,21 @@
//! rules of the app. It talks to the outside world through the repository traits,
//! so it depends neither on axum nor on sqlx.
//!
//! One file per domain area (`items.rs` here), each one adding methods to
//! `AppController` through `impl super::AppController`.
//! One file per domain area, each one adding methods to `AppController` through
//! `impl super::AppController`.
use std::sync::Arc;
use thiserror::Error;
use crate::core::{
controller::items::ItemsControllerError,
controller::{bikes::BikesControllerError, reservations::ReservationsControllerError},
repositories::{DatabaseRepository, RepositoryError},
};
pub mod items;
pub mod bikes;
pub mod reservations;
pub mod users;
/// Entry point of the business logic. Cheap to clone: handlers get one per request.
#[derive(Clone)]
@ -29,23 +31,22 @@ impl AppController {
}
/// Every error the api may have to translate into a status code.
/// Domain specific errors are nested (`Item`), so each area keeps its own enum.
/// Domain specific errors are nested, so each area keeps its own enum.
#[derive(Error, Debug)]
// Skeleton kept for your own logic: the example only performs a read
#[allow(dead_code)]
pub enum ControllerError {
#[error("Internal error: {0}")]
InternalError(String),
#[error("Generic repository error")]
RepositoryError(#[from] RepositoryError),
#[error("Item specific error: {0}")]
Item(#[from] ItemsControllerError),
#[error("Bike specific error: {0}")]
Bike(#[from] BikesControllerError),
#[error("Reservation specific error: {0}")]
Reservation(#[from] ReservationsControllerError),
}
impl ControllerError {
/// Handy in the handlers: `Err(err) if err.is_not_found() => 404`
#[allow(dead_code)]
pub fn is_not_found(&self) -> bool {
matches!(
self,

View file

@ -0,0 +1,104 @@
use thiserror::Error;
use crate::core::{
controller::ControllerError,
models::{
bike::BikeStatus,
reservation::{
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
},
unit::UnitId,
},
};
impl super::AppController {
pub async fn get_reservations(&self) -> Result<Vec<Reservation>, ControllerError> {
self.db.get_reservations().await.map_err(Into::into)
}
pub async fn get_unit_reservations(
&self,
unit: UnitId,
) -> Result<Vec<Reservation>, ControllerError> {
self.db
.get_unit_reservations(unit)
.await
.map_err(Into::into)
}
pub async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, ControllerError> {
self.db.get_reservation(id).await.map_err(Into::into)
}
pub async fn create_reservation(
&self,
reservation: NewReservation,
) -> Result<Reservation, ControllerError> {
if !reservation.is_valid() {
return Err(ReservationsControllerError::ReservationInvalid.into());
}
// A bike out of service cannot be booked
for id in &reservation.bikes {
if self.get_bike(*id).await?.status == BikeStatus::OutOfService {
return Err(ReservationsControllerError::BikeOutOfService(*id).into());
}
}
self.db
.create_reservation(reservation)
.await
.map_err(Into::into)
}
pub async fn update_reservation(
&self,
reservation: ReservationEdit,
) -> Result<(), ControllerError> {
if !reservation.is_valid() {
return Err(ReservationsControllerError::ReservationInvalid.into());
}
let current = self.db.get_reservation(reservation.id).await?;
if current.status.is_final() {
return Err(ReservationsControllerError::ReservationFinal(current.status).into());
}
self.db
.update_reservation(reservation)
.await
.map_err(Into::into)
}
pub async fn set_reservation_status(
&self,
id: ReservationId,
status: ReservationStatus,
) -> Result<(), ControllerError> {
let current = self.db.get_reservation(id).await?.status;
if current == status {
return Ok(());
}
if !current.can_transition_to(status) {
return Err(ReservationsControllerError::InvalidTransition(current, status).into());
}
self.db
.set_reservation_status(id, status)
.await
.map_err(Into::into)
}
pub async fn delete_reservation(&self, id: ReservationId) -> Result<(), ControllerError> {
self.db.delete_reservation(id).await.map_err(Into::into)
}
}
#[derive(Error, Debug)]
pub enum ReservationsControllerError {
#[error("The reservation is malformed")]
ReservationInvalid,
#[error("A reservation cannot go from {0:?} to {1:?}")]
InvalidTransition(ReservationStatus, ReservationStatus),
#[error("The reservation is {0:?} and cannot be modified any more")]
ReservationFinal(ReservationStatus),
#[error("Bike {0} is out of service and cannot be booked")]
BikeOutOfService(i32),
}

View file

@ -0,0 +1,32 @@
//! Users are not created by the app: they are mirrored from the authentication
//! provider on login. The only decision that belongs to us is `admin`.
use crate::core::{
controller::ControllerError,
models::user::{NewUser, User, UserId},
};
impl super::AppController {
pub async fn login(&self, user: NewUser) -> Result<User, ControllerError> {
self.db.upsert_user(user).await.map_err(Into::into)
}
pub async fn get_user(&self, id: UserId) -> Result<User, ControllerError> {
self.db.get_user(id).await.map_err(Into::into)
}
pub async fn get_user_email(&self, email: String) -> Result<User, ControllerError> {
self.db.get_user_email(email).await.map_err(Into::into)
}
pub async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result<User, ControllerError> {
self.db
.get_user_oidc_sub(oidc_sub)
.await
.map_err(Into::into)
}
pub async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), ControllerError> {
self.db.set_user_admin(id, admin).await.map_err(Into::into)
}
}

34
src/core/models/bike.rs Normal file
View file

@ -0,0 +1,34 @@
//! The cargo bikes of the fleet.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub type BikeId = i32;
#[derive(Debug, Serialize, Deserialize, Clone, Copy, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BikeStatus {
InService,
OutOfService,
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct Bike {
pub id: BikeId,
pub name: String,
pub key_number: Option<String>,
pub key_quantity: i32,
pub drivetrain: Option<String>,
pub battery: Option<String>,
pub status: BikeStatus,
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct NewBike {
pub name: String,
pub key_number: Option<String>,
pub key_quantity: i32,
pub drivetrain: Option<String>,
pub battery: Option<String>,
pub status: BikeStatus,
}

View file

@ -1,15 +0,0 @@
//! Example domain model. Rename/duplicate this file for your own entities.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::core::models::localized_string::LocalizedString;
pub type ItemId = i32;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Item {
pub id: ItemId,
pub name: LocalizedString,
pub description: LocalizedString,
}

View file

@ -1,2 +1,5 @@
pub mod item;
pub mod bike;
pub mod localized_string;
pub mod reservation;
pub mod unit;
pub mod user;

View file

@ -0,0 +1,92 @@
//! Reservations: one unit borrows a set of bikes over a period of time.
use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::core::models::{bike::BikeId, unit::UnitId, user::UserId};
pub type ReservationId = i32;
#[derive(Debug, Serialize, Deserialize, Clone, Copy, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReservationStatus {
Requested,
Refused,
Approved,
Cancelled,
Ongoing,
Archived,
}
impl ReservationStatus {
pub fn can_transition_to(self, next: Self) -> bool {
use ReservationStatus::*;
matches!(
(self, next),
(Requested, Refused)
| (Requested, Approved)
| (Approved, Cancelled)
| (Approved, Ongoing)
| (Ongoing, Cancelled)
| (Ongoing, Archived)
)
}
pub fn is_final(self) -> bool {
use ReservationStatus::*;
matches!(self, Refused | Cancelled | Archived)
}
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct Reservation {
pub id: ReservationId,
pub unit: UnitId,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub requester: UserId,
pub users: Vec<UserId>,
pub telegram: String,
pub description: String,
pub bikes: Vec<BikeId>,
pub status: ReservationStatus,
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct NewReservation {
pub unit: UnitId,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub requester: UserId,
pub users: Vec<UserId>,
pub telegram: String,
pub description: String,
pub bikes: Vec<BikeId>,
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct ReservationEdit {
pub id: ReservationId,
pub unit: UnitId,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub users: Vec<UserId>,
pub telegram: String,
pub description: String,
pub bikes: Vec<BikeId>,
}
impl NewReservation {
pub fn is_valid(&self) -> bool {
self.end_time > self.start_time
&& !self.bikes.is_empty()
&& self.users.contains(&self.requester)
}
}
impl ReservationEdit {
pub fn is_valid(&self) -> bool {
self.end_time > self.start_time && !self.bikes.is_empty() && !self.users.is_empty()
}
}

1
src/core/models/unit.rs Normal file
View file

@ -0,0 +1 @@
pub type UnitId = String;

28
src/core/models/user.rs Normal file
View file

@ -0,0 +1,28 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::core::models::unit::UnitId;
pub type UserId = i32;
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct User {
pub id: UserId,
pub external_id: Option<String>,
pub firstname: String,
pub name: String,
pub email: String,
pub oidc_sub: String,
pub units: Vec<UnitId>,
pub admin: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct NewUser {
pub external_id: Option<String>,
pub firstname: String,
pub name: String,
pub email: String,
pub oidc_sub: String,
pub units: Vec<UnitId>,
}

View file

@ -0,0 +1,16 @@
use async_trait::async_trait;
use crate::core::{
models::bike::{Bike, BikeId, BikeStatus, NewBike},
repositories::RepositoryError,
};
#[async_trait]
pub trait BikesRepository {
async fn get_bikes(&self) -> Result<Vec<Bike>, RepositoryError>;
async fn get_bike(&self, id: BikeId) -> Result<Bike, RepositoryError>;
async fn create_bike(&self, bike: NewBike) -> Result<Bike, RepositoryError>;
async fn update_bike(&self, bike: Bike) -> Result<(), RepositoryError>;
async fn set_bike_status(&self, id: BikeId, status: BikeStatus) -> Result<(), RepositoryError>;
async fn delete_bike(&self, id: BikeId) -> Result<(), RepositoryError>;
}

View file

@ -1,8 +0,0 @@
use async_trait::async_trait;
use crate::core::{models::item::Item, repositories::RepositoryError};
#[async_trait]
pub trait ItemsRepository {
async fn get_items(&self) -> Result<Vec<Item>, RepositoryError>;
}

View file

@ -7,13 +7,21 @@
use async_trait::async_trait;
use thiserror::Error;
use crate::core::repositories::items_repository::ItemsRepository;
use crate::core::repositories::{
bikes_repository::BikesRepository, reservations_repository::ReservationsRepository,
users_repository::UsersRepository,
};
pub mod items_repository;
pub mod bikes_repository;
pub mod reservations_repository;
pub mod users_repository;
/// Add every new repository trait here so the controller can use it through `db`
#[async_trait]
pub trait DatabaseRepository: ItemsRepository + Send + Sync {}
pub trait DatabaseRepository:
UsersRepository + BikesRepository + ReservationsRepository + Send + Sync
{
}
#[derive(Error, Debug)]
pub enum RepositoryError {

View file

@ -0,0 +1,15 @@
use async_trait::async_trait;
use crate::{core::repositories::RepositoryError, utils::whiskey};
#[async_trait]
pub trait OidcStatesRepository {
async fn get_whiskey_data(
&self,
csrf_token: String,
) -> Result<whiskey::AuthorizeBackendData, RepositoryError>;
async fn save_whiskey_data(
&self,
data: whiskey::AuthorizeBackendData,
) -> Result<(), RepositoryError>;
}

View file

@ -0,0 +1,37 @@
use async_trait::async_trait;
use crate::core::{
models::{
reservation::{
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
},
unit::UnitId,
},
repositories::RepositoryError,
};
#[async_trait]
pub trait ReservationsRepository {
async fn get_reservations(&self) -> Result<Vec<Reservation>, RepositoryError>;
async fn get_unit_reservations(
&self,
unit: UnitId,
) -> Result<Vec<Reservation>, RepositoryError>;
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError>;
async fn create_reservation(
&self,
reservation: NewReservation,
) -> Result<Reservation, RepositoryError>;
async fn update_reservation(&self, reservation: ReservationEdit)
-> Result<(), RepositoryError>;
async fn set_reservation_status(
&self,
id: ReservationId,
status: ReservationStatus,
) -> Result<(), RepositoryError>;
async fn delete_reservation(&self, id: ReservationId) -> Result<(), RepositoryError>;
}

View file

@ -0,0 +1,18 @@
use async_trait::async_trait;
use crate::core::{
models::user::{NewUser, User, UserId},
repositories::RepositoryError,
};
#[async_trait]
pub trait UsersRepository {
async fn get_user(&self, id: UserId) -> Result<User, RepositoryError>;
async fn get_user_email(&self, email: String) -> Result<User, RepositoryError>;
async fn get_user_external_id(&self, external_id: String) -> Result<User, RepositoryError>;
async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result<User, RepositoryError>;
async fn upsert_user(&self, user: NewUser) -> Result<User, RepositoryError>;
async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>;
}

View file

@ -1,3 +1,8 @@
// The models, repositories and controllers are in place, but no api route is
// mounted on them yet: without this the whole stack is reported as dead code.
// Remove it as soon as `src/api/` exposes the handlers.
#![allow(dead_code)]
use std::sync::Arc;
use tower_http::services::{ServeDir, ServeFile};
@ -32,12 +37,10 @@ async fn main() {
// Anything that is not an api route is served from the built frontend,
// falling back on index.html so the vue router can handle the path.
// (`fallback` and not `not_found_service`, which would force a 404 status)
let app = api::get_router(controller).fallback_service(
ServeDir::new(&config.frontend_dir).fallback(ServeFile::new(format!(
"{}/index.html",
config.frontend_dir
))),
);
let app =
api::get_router(controller).fallback_service(ServeDir::new(&config.frontend_dir).fallback(
ServeFile::new(format!("{}/index.html", config.frontend_dir)),
));
let bind_address = config.get_bind_address();
let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap();

View file

@ -0,0 +1,162 @@
//! The bike fleet. `bike_status` is a postgres enum: `BikeStatusDB` mirrors the
//! core `BikeStatus` so that sqlx stays out of `core/models`.
use async_trait::async_trait;
use sqlx::{query, query_as};
use crate::{
core::{
models::bike::{Bike, BikeId, BikeStatus, NewBike},
repositories::{RepositoryError, bikes_repository::BikesRepository},
},
services::database::SqlxDatabase,
};
#[derive(Debug, Clone, Copy, sqlx::Type)]
#[sqlx(type_name = "bike_status", rename_all = "snake_case")]
enum BikeStatusDB {
InService,
OutOfService,
}
impl From<BikeStatusDB> for BikeStatus {
fn from(value: BikeStatusDB) -> Self {
match value {
BikeStatusDB::InService => BikeStatus::InService,
BikeStatusDB::OutOfService => BikeStatus::OutOfService,
}
}
}
impl From<BikeStatus> for BikeStatusDB {
fn from(value: BikeStatus) -> Self {
match value {
BikeStatus::InService => BikeStatusDB::InService,
BikeStatus::OutOfService => BikeStatusDB::OutOfService,
}
}
}
struct BikeDB {
pub id: i32,
pub name: String,
pub key_number: Option<String>,
pub key_quantity: i32,
pub drivetrain: Option<String>,
pub battery: Option<String>,
pub status: BikeStatusDB,
}
impl From<BikeDB> for Bike {
fn from(value: BikeDB) -> Self {
Bike {
id: value.id,
name: value.name,
key_number: value.key_number,
key_quantity: value.key_quantity,
drivetrain: value.drivetrain,
battery: value.battery,
status: value.status.into(),
}
}
}
#[async_trait]
impl BikesRepository for SqlxDatabase {
async fn get_bikes(&self) -> Result<Vec<Bike>, RepositoryError> {
Ok(query_as!(
BikeDB,
r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery,
status AS "status: BikeStatusDB"
FROM bikes
ORDER BY "name""#
)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(Into::into)
.collect())
}
async fn get_bike(&self, id: BikeId) -> Result<Bike, RepositoryError> {
Ok(query_as!(
BikeDB,
r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery,
status AS "status: BikeStatusDB"
FROM bikes
WHERE id = $1"#,
id
)
.fetch_one(&self.pool)
.await?
.into())
}
async fn create_bike(&self, bike: NewBike) -> Result<Bike, RepositoryError> {
let status: BikeStatusDB = bike.status.into();
Ok(query_as!(
BikeDB,
r#"INSERT INTO bikes ("name", key_number, key_quantity, drivetrain, battery, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, "name", key_number, key_quantity, drivetrain, battery,
status AS "status: BikeStatusDB""#,
bike.name,
bike.key_number,
bike.key_quantity,
bike.drivetrain,
bike.battery,
status as BikeStatusDB
)
.fetch_one(&self.pool)
.await?
.into())
}
async fn update_bike(&self, bike: Bike) -> Result<(), RepositoryError> {
let status: BikeStatusDB = bike.status.into();
let result = query!(
r#"UPDATE bikes
SET "name" = $2, key_number = $3, key_quantity = $4, drivetrain = $5,
battery = $6, status = $7
WHERE id = $1"#,
bike.id,
bike.name,
bike.key_number,
bike.key_quantity,
bike.drivetrain,
bike.battery,
status as BikeStatusDB
)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RepositoryError::NotFound(format!("bike {}", bike.id)));
}
Ok(())
}
async fn set_bike_status(&self, id: BikeId, status: BikeStatus) -> Result<(), RepositoryError> {
let status: BikeStatusDB = status.into();
let result = query!(
r#"UPDATE bikes SET status = $2 WHERE id = $1"#,
id,
status as BikeStatusDB
)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RepositoryError::NotFound(format!("bike {id}")));
}
Ok(())
}
async fn delete_bike(&self, id: BikeId) -> Result<(), RepositoryError> {
let result = query!(r#"DELETE FROM bikes WHERE id = $1"#, id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RepositoryError::NotFound(format!("bike {id}")));
}
Ok(())
}
}

View file

@ -1,46 +0,0 @@
//! Example repository implementation: maps the database rows to the core models.
//! Localized columns are JSONB, so they go through `serde_json`.
use async_trait::async_trait;
use serde_json::Value;
use sqlx::query_as;
use crate::{
core::{
models::item::Item,
repositories::{RepositoryError, items_repository::ItemsRepository},
},
services::database::SqlxDatabase,
};
/// Database representation of an item. Fields must match the columns of the
/// `items` table, in the same order (requirement of `query_as!` with `SELECT *`).
struct ItemDB {
pub id: i32,
pub name: Value,
pub description: Value,
}
impl TryFrom<ItemDB> for Item {
type Error = RepositoryError;
fn try_from(value: ItemDB) -> Result<Self, Self::Error> {
Ok(Item {
id: value.id,
name: serde_json::from_value(value.name)?,
description: serde_json::from_value(value.description)?,
})
}
}
#[async_trait]
impl ItemsRepository for SqlxDatabase {
async fn get_items(&self) -> Result<Vec<Item>, RepositoryError> {
query_as!(ItemDB, r#"SELECT * FROM items ORDER BY id"#)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(TryInto::try_into)
.collect()
}
}

View file

@ -1,10 +1,12 @@
//! Postgres implementation of the repository traits, using sqlx.
//!
//! The `query!`/`query_as!` macros check the sql against a real database at compile
//! time: `dev-db` must be up and migrated, or the `.sqlx` offline data must be present
//! (`cargo sqlx prepare`).
//! time: the development database must be up and migrated, or the `.sqlx` offline
//! data must be present (`cargo sqlx prepare`).
mod items;
mod bikes;
mod reservations;
mod users;
use async_trait::async_trait;
use sqlx::{Pool, Postgres, postgres::PgPoolOptions};

View file

@ -0,0 +1,78 @@
use async_trait::async_trait;
use sqlx::{prelude::FromRow, query, query_as};
use crate::{
core::repositories::{RepositoryError, oidc_states_repository::OidcStatesRepository},
services::database::SqlxDatabase,
utils::whiskey,
};
#[derive(FromRow)]
struct DBOidcStateData {
key: String,
data: String,
}
impl TryFrom<whiskey::AuthorizeBackendData> for DBOidcStateData {
type Error = RepositoryError;
fn try_from(value: whiskey::AuthorizeBackendData) -> Result<Self, Self::Error> {
Ok(DBOidcStateData {
key: value.csrf_token(),
data: serde_json::to_string(&value)?,
})
}
}
impl TryInto<whiskey::AuthorizeBackendData> for DBOidcStateData {
type Error = RepositoryError;
fn try_into(self) -> Result<whiskey::AuthorizeBackendData, Self::Error> {
let value: whiskey::AuthorizeBackendData = serde_json::from_str(&self.data)?;
if value.csrf_token() != self.key {
return Err(RepositoryError::TypeConversion(
"key of whiskey authorize backend data doesn't match".to_owned(),
));
}
Ok(value)
}
}
#[async_trait]
impl OidcStatesRepository for SqlxDatabase {
async fn get_whiskey_data(
&self,
csrf_token: String,
) -> Result<whiskey::AuthorizeBackendData, RepositoryError> {
query_as!(
DBOidcStateData,
r#"SELECT
key,
data
FROM oidc_states
WHERE key = $1"#,
csrf_token
)
.fetch_one(&self.pool)
.await?
.try_into()
}
// TODO: Expire the data and remove it periodically
async fn save_whiskey_data(
&self,
data: whiskey::AuthorizeBackendData,
) -> Result<(), RepositoryError> {
let data: DBOidcStateData = data.try_into()?;
query!(
r#"INSERT INTO oidc_states
(key, data)
VALUES ($1, $2)"#,
data.key,
data.data
)
.execute(&self.pool)
.await?;
Ok(())
}
}

View file

@ -0,0 +1,335 @@
//! Reservations, with their users and their bikes.
//!
//! The two link tables are read back with `ARRAY(SELECT ...)` subqueries rather
//! than joins, so listing reservations stays a single round trip.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::{query, query_as};
use crate::{
core::{
models::{
reservation::{
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
},
unit::UnitId,
},
repositories::{RepositoryError, reservations_repository::ReservationsRepository},
},
services::database::SqlxDatabase,
};
#[derive(Debug, Clone, Copy, sqlx::Type)]
#[sqlx(type_name = "reservation_status", rename_all = "snake_case")]
enum ReservationStatusDB {
Requested,
Refused,
Approved,
Cancelled,
Ongoing,
Archived,
}
impl From<ReservationStatusDB> for ReservationStatus {
fn from(value: ReservationStatusDB) -> Self {
match value {
ReservationStatusDB::Requested => ReservationStatus::Requested,
ReservationStatusDB::Refused => ReservationStatus::Refused,
ReservationStatusDB::Approved => ReservationStatus::Approved,
ReservationStatusDB::Cancelled => ReservationStatus::Cancelled,
ReservationStatusDB::Ongoing => ReservationStatus::Ongoing,
ReservationStatusDB::Archived => ReservationStatus::Archived,
}
}
}
impl From<ReservationStatus> for ReservationStatusDB {
fn from(value: ReservationStatus) -> Self {
match value {
ReservationStatus::Requested => ReservationStatusDB::Requested,
ReservationStatus::Refused => ReservationStatusDB::Refused,
ReservationStatus::Approved => ReservationStatusDB::Approved,
ReservationStatus::Cancelled => ReservationStatusDB::Cancelled,
ReservationStatus::Ongoing => ReservationStatusDB::Ongoing,
ReservationStatus::Archived => ReservationStatusDB::Archived,
}
}
}
struct ReservationDB {
pub id: i32,
pub unit: String,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub requester_id: i32,
pub telegram: String,
pub description: String,
pub status: ReservationStatusDB,
pub users: Vec<i32>,
pub bikes: Vec<i32>,
}
impl From<ReservationDB> for Reservation {
fn from(value: ReservationDB) -> Self {
Reservation {
id: value.id,
unit: value.unit,
start_time: value.start_time,
end_time: value.end_time,
requester: value.requester_id,
users: value.users,
telegram: value.telegram,
description: value.description,
bikes: value.bikes,
status: value.status.into(),
}
}
}
impl SqlxDatabase {
async fn set_reservation_links(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: ReservationId,
users: &[i32],
bikes: &[i32],
) -> Result<(), RepositoryError> {
query!(
r#"DELETE FROM reservations_users WHERE reservation_id = $1"#,
id
)
.execute(&mut **tx)
.await?;
query!(
r#"INSERT INTO reservations_users (reservation_id, user_id)
SELECT $1, UNNEST($2::integer[])"#,
id,
users
)
.execute(&mut **tx)
.await?;
query!(
r#"DELETE FROM reservations_bikes WHERE reservation_id = $1"#,
id
)
.execute(&mut **tx)
.await?;
query!(
r#"INSERT INTO reservations_bikes (reservation_id, bike_id)
SELECT $1, UNNEST($2::integer[])"#,
id,
bikes
)
.execute(&mut **tx)
.await?;
Ok(())
}
}
#[async_trait]
impl ReservationsRepository for SqlxDatabase {
async fn get_reservations(&self) -> Result<Vec<Reservation>, RepositoryError> {
Ok(query_as!(
ReservationDB,
r#"SELECT
r.id,
r.unit,
r.start_time,
r.end_time,
r.requester_id,
r.telegram,
r."description",
r.status AS "status: ReservationStatusDB",
ARRAY(
SELECT user_id FROM reservations_users
WHERE reservation_id = r.id ORDER BY user_id
) AS "users!",
ARRAY(
SELECT bike_id FROM reservations_bikes
WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!"
FROM reservations r
ORDER BY r.start_time DESC"#
)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(Into::into)
.collect())
}
async fn get_unit_reservations(
&self,
unit: UnitId,
) -> Result<Vec<Reservation>, RepositoryError> {
Ok(query_as!(
ReservationDB,
r#"SELECT
r.id,
r.unit,
r.start_time,
r.end_time,
r.requester_id,
r.telegram,
r."description",
r.status AS "status: ReservationStatusDB",
ARRAY(
SELECT user_id FROM reservations_users
WHERE reservation_id = r.id ORDER BY user_id
) AS "users!",
ARRAY(
SELECT bike_id FROM reservations_bikes
WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!"
FROM reservations r
WHERE r.unit = $1
ORDER BY r.start_time DESC"#,
unit
)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(Into::into)
.collect())
}
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError> {
Ok(query_as!(
ReservationDB,
r#"SELECT
r.id,
r.unit,
r.start_time,
r.end_time,
r.requester_id,
r.telegram,
r."description",
r.status AS "status: ReservationStatusDB",
ARRAY(
SELECT user_id FROM reservations_users
WHERE reservation_id = r.id ORDER BY user_id
) AS "users!",
ARRAY(
SELECT bike_id FROM reservations_bikes
WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!"
FROM reservations r
WHERE r.id = $1"#,
id
)
.fetch_one(&self.pool)
.await?
.into())
}
async fn create_reservation(
&self,
reservation: NewReservation,
) -> Result<Reservation, RepositoryError> {
let mut tx = self.pool.begin().await?;
// No status here: the column defaults to 'requested', the start of the
// state machine.
let id = query!(
r#"INSERT INTO reservations (unit, start_time, end_time, requester_id, telegram, "description")
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id"#,
reservation.unit,
reservation.start_time,
reservation.end_time,
reservation.requester,
reservation.telegram,
reservation.description
)
.fetch_one(&mut *tx)
.await?
.id;
Self::set_reservation_links(&mut tx, id, &reservation.users, &reservation.bikes).await?;
tx.commit().await?;
Ok(Reservation {
id,
unit: reservation.unit,
start_time: reservation.start_time,
end_time: reservation.end_time,
requester: reservation.requester,
users: reservation.users,
telegram: reservation.telegram,
description: reservation.description,
bikes: reservation.bikes,
status: ReservationStatus::Requested,
})
}
async fn update_reservation(
&self,
reservation: ReservationEdit,
) -> Result<(), RepositoryError> {
let mut tx = self.pool.begin().await?;
let result = query!(
r#"UPDATE reservations
SET unit = $2, start_time = $3, end_time = $4, telegram = $5, "description" = $6
WHERE id = $1"#,
reservation.id,
reservation.unit,
reservation.start_time,
reservation.end_time,
reservation.telegram,
reservation.description
)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(RepositoryError::NotFound(format!(
"reservation {}",
reservation.id
)));
}
Self::set_reservation_links(
&mut tx,
reservation.id,
&reservation.users,
&reservation.bikes,
)
.await?;
tx.commit().await?;
Ok(())
}
async fn set_reservation_status(
&self,
id: ReservationId,
status: ReservationStatus,
) -> Result<(), RepositoryError> {
let status: ReservationStatusDB = status.into();
let result = query!(
r#"UPDATE reservations SET status = $2 WHERE id = $1"#,
id,
status as ReservationStatusDB
)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RepositoryError::NotFound(format!("reservation {id}")));
}
Ok(())
}
async fn delete_reservation(&self, id: ReservationId) -> Result<(), RepositoryError> {
// The link tables cascade
let result = query!(r#"DELETE FROM reservations WHERE id = $1"#, id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RepositoryError::NotFound(format!("reservation {id}")));
}
Ok(())
}
}

View file

@ -0,0 +1,168 @@
use async_trait::async_trait;
use sqlx::{Executor, Postgres, query, query_as};
use crate::{
core::{
models::{
unit::UnitId,
user::{NewUser, User, UserId},
},
repositories::{RepositoryError, users_repository::UsersRepository},
},
services::database::SqlxDatabase,
};
struct UserDB {
pub id: i32,
pub external_id: Option<String>,
pub firstname: String,
pub name: String,
pub email: String,
pub oidc_sub: String,
pub admin: bool,
}
impl UserDB {
fn into_user(self, units: Vec<UnitIdDB>) -> User {
User {
id: self.id,
external_id: self.external_id,
firstname: self.firstname,
name: self.name,
email: self.email,
oidc_sub: self.oidc_sub,
admin: self.admin,
units: units.into_iter().map(|u| u.name).collect(),
}
}
}
struct UnitIdDB {
pub name: UnitId,
}
impl SqlxDatabase {
async fn user_with_units<'a, E>(user: UserDB, executor: E) -> Result<User, RepositoryError>
where
E: Executor<'a, Database = Postgres>,
{
let units = query_as!(
UnitIdDB,
r#"SELECT unit_name AS "name!" FROM units_users WHERE user_id = $1 ORDER BY unit_name"#,
user.id
)
.fetch_all(executor)
.await?;
Ok(user.into_user(units))
}
}
#[async_trait]
impl UsersRepository for SqlxDatabase {
async fn get_user(&self, id: UserId) -> Result<User, RepositoryError> {
let mut tx = self.pool.begin().await?;
let user = query_as!(UserDB, r#"SELECT * FROM users WHERE id = $1"#, id)
.fetch_one(&mut *tx)
.await?;
let user = Self::user_with_units(user, &mut *tx).await?;
tx.commit().await?;
Ok(user)
}
async fn get_user_email(&self, email: String) -> Result<User, RepositoryError> {
let mut tx = self.pool.begin().await?;
let user = query_as!(UserDB, r#"SELECT * FROM users WHERE email = $1"#, email)
.fetch_one(&mut *tx)
.await?;
let user = Self::user_with_units(user, &mut *tx).await?;
tx.commit().await?;
Ok(user)
}
async fn get_user_external_id(&self, external_id: String) -> Result<User, RepositoryError> {
let mut tx = self.pool.begin().await?;
let user = query_as!(
UserDB,
r#"SELECT * FROM users WHERE external_id = $1"#,
external_id
)
.fetch_one(&mut *tx)
.await?;
let user = Self::user_with_units(user, &mut *tx).await?;
tx.commit().await?;
Ok(user)
}
async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result<User, RepositoryError> {
let mut tx = self.pool.begin().await?;
let user = query_as!(
UserDB,
r#"SELECT * FROM users WHERE oidc_sub = $1"#,
oidc_sub
)
.fetch_one(&mut *tx)
.await?;
let user = Self::user_with_units(user, &mut *tx).await?;
tx.commit().await?;
Ok(user)
}
async fn upsert_user(&self, user: NewUser) -> Result<User, RepositoryError> {
let mut tx = self.pool.begin().await?;
let user_db = query_as!(
UserDB,
r#"INSERT INTO users (external_id, firstname, "name", email, oidc_sub)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (oidc_sub)
DO UPDATE SET
external_id = EXCLUDED.external_id,
firstname = EXCLUDED.firstname,
"name" = EXCLUDED.name,
email = EXCLUDED.email
RETURNING *"#,
user.external_id,
user.firstname,
user.name,
user.email,
user.oidc_sub
)
.fetch_one(&mut *tx)
.await?;
query!(r#"DELETE FROM units_users WHERE user_id = $1"#, user_db.id)
.execute(&mut *tx)
.await?;
query!(
r#"INSERT INTO units_users (user_id, unit_name)
SELECT $1, UNNEST($2::text[])"#,
user_db.id,
&user.units
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(User {
id: user_db.id,
external_id: user_db.external_id,
firstname: user_db.firstname,
name: user_db.name,
email: user_db.email,
oidc_sub: user_db.oidc_sub,
units: user.units,
admin: user_db.admin,
})
}
async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError> {
let result = query!(r#"UPDATE users SET admin = $2 WHERE id = $1"#, id, admin)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RepositoryError::NotFound(format!("user {id}")));
}
Ok(())
}
}

View file

@ -46,12 +46,12 @@ impl AppConfig {
/// Loads the configuration, by decreasing priority:
/// - `APP__SERVER__PORT=3000` style environment variables
/// - `./config.yml`
/// - the file pointed by `$APP_CONFIG` (`/etc/app-template/config.yml` by default)
/// - the file pointed by `$APP_CONFIG` (`/etc/cargagep/config.yml` by default)
pub fn get() -> &'static AppConfig {
static CONFIG: OnceLock<AppConfig> = OnceLock::new();
CONFIG.get_or_init(|| {
let config_path =
std::env::var("APP_CONFIG").unwrap_or("/etc/app-template/config.yml".to_owned());
std::env::var("APP_CONFIG").unwrap_or("/etc/cargagep/config.yml".to_owned());
let config_path = Path::new(&config_path);
let raw = Config::builder()

261
src/utils/whiskey.rs Normal file
View file

@ -0,0 +1,261 @@
use std::sync::OnceLock;
use openidconnect::{
AccessTokenHash, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields,
IdTokenFields, IssuerUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier,
RedirectUrl, Scope, StandardTokenResponse, TokenResponse,
core::{
CoreAuthenticationFlow, CoreGenderClaim, CoreJweContentEncryptionAlgorithm,
CoreJwsSigningAlgorithm, CoreProviderMetadata, CoreTokenType,
},
reqwest,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::OnceCell;
use tracing::{debug, error};
use crate::utils::config;
#[derive(Error, Debug)]
pub enum WhiskeyError {
#[error("Internal error")]
InternalError,
#[error("Protocol error")]
ProtocolError,
}
fn get_http_client() -> &'static reqwest::Client {
static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
HTTP_CLIENT.get_or_init(|| {
reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("Unable to build async http client")
})
}
async fn get_client() -> &'static Client {
static CLIENT: OnceCell<Client> = OnceCell::const_new();
CLIENT
.get_or_init(|| async {
let http_client = get_http_client();
let config = config::get().clone();
let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::new(config.oidc.issuer_url).unwrap(),
http_client,
)
.await
.unwrap();
Client::from_provider_metadata(
provider_metadata,
ClientId::new(config.oidc.client_id),
Some(ClientSecret::new(config.oidc.client_secret)),
)
.set_redirect_uri(
RedirectUrl::new(format!("{}/whiskey/callback", config.server.base_url)).unwrap(),
)
})
.await
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthorizeBackendData {
pub csrf_token: String,
pub pkce_verifier: PkceCodeVerifier,
pub nonce: Nonce,
}
impl AuthorizeBackendData {
pub fn csrf_token(&self) -> String {
self.csrf_token.clone()
}
}
pub async fn authorize() -> Result<(String, AuthorizeBackendData), WhiskeyError> {
let client = get_client().await;
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (redirect_to, csrf_token, nonce) = client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.add_scope(Scope::new("openid".to_owned()))
.add_scope(Scope::new("profile".to_owned()))
.add_scope(Scope::new("email".to_owned()))
.set_pkce_challenge(pkce_challenge)
.url();
Ok((
redirect_to.into(),
AuthorizeBackendData {
csrf_token: csrf_token.secret().to_owned(),
pkce_verifier,
nonce,
},
))
}
#[derive(Debug, Serialize, Clone)]
pub struct UserInfoData {
pub sub: String,
pub sciper: String,
pub name: String,
pub firstname: String,
pub email: String,
}
pub async fn callback(
code: String,
state: String,
backend_data: AuthorizeBackendData,
) -> Result<UserInfoData, WhiskeyError> {
let client = get_client().await;
if state != backend_data.csrf_token {
error!("Wrong csrf token");
return Err(WhiskeyError::ProtocolError);
}
let token_response = client
.exchange_code(AuthorizationCode::new(code))
.map_err(|err| {
error!("Unable to build exchange code url: {err:?}");
WhiskeyError::InternalError
})?
.set_pkce_verifier(backend_data.pkce_verifier)
.request_async(get_http_client())
.await
.map_err(|err| match err {
openidconnect::RequestTokenError::ServerResponse(err) => {
error!("oidc protocol error: {err:?}");
WhiskeyError::ProtocolError
}
_ => {
error!("other oidc server error: {err:?}");
WhiskeyError::InternalError
}
})?;
let id_token = token_response.id_token().ok_or_else(|| {
error!("missing id_token");
WhiskeyError::InternalError
})?;
let id_token_verifier = client.id_token_verifier();
let claims = id_token
.claims(&id_token_verifier, &backend_data.nonce)
.map_err(|err| {
error!("unable to verify claims: {err:?}");
WhiskeyError::InternalError
})?;
if let Some(expected_access_token_hash) = claims.access_token_hash() {
let actual_access_token_hash = AccessTokenHash::from_token(
token_response.access_token(),
id_token.signing_alg().map_err(|err| {
error!("invalid signature algorithm in id_token: {err:?}");
WhiskeyError::InternalError
})?,
id_token.signing_key(&id_token_verifier).map_err(|err| {
error!("invalid signature key in id_token: {err:?}");
WhiskeyError::InternalError
})?,
)
.map_err(|err| {
error!("unable to compute access token hash: {err:?}");
WhiskeyError::InternalError
})?;
if actual_access_token_hash != *expected_access_token_hash {
error!("actual_access_token_hash != expected_access_token_hash");
return Err(WhiskeyError::ProtocolError);
}
} else {
error!("no hash in claims");
return Err(WhiskeyError::ProtocolError);
}
debug!("Whiskey id_token: {id_token:?}");
let firstname = claims
.given_name()
.ok_or_else(|| {
error!("missing given_name claim from claims");
WhiskeyError::InternalError
})?
.get(None)
.ok_or_else(|| {
error!("missing given_name value from claims");
WhiskeyError::InternalError
})?
.to_string();
let name = claims
.family_name()
.ok_or_else(|| {
error!("missing family_name claim from claims");
WhiskeyError::InternalError
})?
.get(None)
.ok_or_else(|| {
error!("missing family_name value from claims");
WhiskeyError::InternalError
})?
.to_string();
let email = claims
.email()
.ok_or_else(|| {
error!("missing email claim from claims");
WhiskeyError::InternalError
})?
.to_string();
let sub = claims.subject().to_string();
let sciper = claims.additional_claims().sciper.clone();
Ok(UserInfoData {
firstname,
name,
sub,
sciper,
email,
})
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct WhiskeyClaims {
pub sciper: String,
}
impl openidconnect::AdditionalClaims for WhiskeyClaims {}
pub type WhiskeyFields = IdTokenFields<
WhiskeyClaims,
EmptyExtraTokenFields,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJwsSigningAlgorithm,
>;
pub type WhiskeyTokenResponse = StandardTokenResponse<WhiskeyFields, CoreTokenType>;
pub type Client = openidconnect::Client<
WhiskeyClaims,
openidconnect::core::CoreAuthDisplay,
openidconnect::core::CoreGenderClaim,
openidconnect::core::CoreJweContentEncryptionAlgorithm,
openidconnect::core::CoreJsonWebKey,
openidconnect::core::CoreAuthPrompt,
openidconnect::StandardErrorResponse<openidconnect::core::CoreErrorResponseType>,
WhiskeyTokenResponse,
openidconnect::core::CoreTokenIntrospectionResponse,
openidconnect::core::CoreRevocableToken,
openidconnect::core::CoreRevocationErrorResponse,
openidconnect::EndpointSet,
openidconnect::EndpointNotSet,
openidconnect::EndpointNotSet,
openidconnect::EndpointNotSet,
openidconnect::EndpointMaybeSet,
openidconnect::EndpointMaybeSet,
>;