feat: add bikes, reservations, users tables
This commit is contained in:
parent
25b8d726e0
commit
5169cad8ef
55 changed files with 2361 additions and 398 deletions
2
.env
2
.env
|
|
@ -1,2 +1,2 @@
|
||||||
# This file is used by dbmate, and by the sqlx macros at compile time.
|
# 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
5
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"i18n-ally.localesPaths": [
|
||||||
|
"frontend/src/locales"
|
||||||
|
]
|
||||||
|
}
|
||||||
41
Cargo.lock
generated
41
Cargo.lock
generated
|
|
@ -60,25 +60,6 @@ dependencies = [
|
||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "arraydeque"
|
name = "arraydeque"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|
@ -229,6 +210,26 @@ version = "1.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
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]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
|
|
@ -253,6 +254,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"serde",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1546,6 +1548,7 @@ version = "0.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
|
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"dyn-clone",
|
"dyn-clone",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"ref-cast",
|
"ref-cast",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[package]
|
[package]
|
||||||
name = "app-template"
|
name = "cargagep"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
|
@ -13,8 +13,9 @@ aide = { version = "0.15.1", features = [
|
||||||
] }
|
] }
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
axum = { version = "0.8.9", features = ["macros"] }
|
axum = { version = "0.8.9", features = ["macros"] }
|
||||||
|
chrono = { version = "0.4.45", default-features = false, features = ["serde", "clock", "std"] }
|
||||||
config = "0.15.23"
|
config = "0.15.23"
|
||||||
schemars = "0.9.0"
|
schemars = { version = "0.9", features = ["chrono04"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
sqlx = { version = "0.8.6", features = [
|
sqlx = { version = "0.8.6", features = [
|
||||||
|
|
|
||||||
69
README.md
69
README.md
|
|
@ -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**
|
**Vue 3** frontend (TypeScript + vue-query + shadcn-vue), on **PostgreSQL** with **dbmate**
|
||||||
migrations.
|
migrations.
|
||||||
|
|
||||||
The template is deliberately almost empty: one table (`items`), one route
|
The data model is in place (users, bikes, reservations) down to the sqlx layer; the api
|
||||||
(`GET /api/items`) and one page (home) that displays it. They exist to show how the layers
|
routes and the frontend views are not written yet.
|
||||||
fit together — rename them, or delete them once your own code is in place.
|
|
||||||
|
|
||||||
## What you get
|
## 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`
|
- 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
|
- 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
|
```bash
|
||||||
cp -r template /path/to/my-project && cd /path/to/my-project
|
# 1. Database
|
||||||
git init
|
cd dev-db && docker compose up -d && cd .. # or use a local postgres
|
||||||
|
psql -h localhost -U postgres -c 'CREATE DATABASE cargagep'
|
||||||
# 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'
|
|
||||||
dbmate up
|
dbmate up
|
||||||
psql "$(grep DATABASE_URL .env | cut -d= -f2-)" -f db/seed.sql # optional demo data
|
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
|
cp config.example.yml config.yml
|
||||||
|
|
||||||
# 4. Run the backend (port 3000)
|
# 3. Run the backend (port 3000)
|
||||||
cargo run
|
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
|
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`
|
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
|
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
|
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
|
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
|
`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
|
`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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ postgres:
|
||||||
port: 5432
|
port: 5432
|
||||||
user: postgres
|
user: postgres
|
||||||
password: postgres
|
password: postgres
|
||||||
name: app_template
|
name: cargagep
|
||||||
|
|
||||||
# Directory containing the built frontend (frontend/dist after `npm run build`)
|
# Directory containing the built frontend (frontend/dist after `npm run build`)
|
||||||
frontend_dir: frontend/dist
|
frontend_dir: frontend/dist
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
28
db/migrations/20260823153300_create_users.sql
Normal file
28
db/migrations/20260823153300_create_users.sql
Normal 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;
|
||||||
22
db/migrations/20260823153310_create_bikes.sql
Normal file
22
db/migrations/20260823153310_create_bikes.sql
Normal 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;
|
||||||
73
db/migrations/20260823153320_create_reservations.sql
Normal file
73
db/migrations/20260823153320_create_reservations.sql
Normal 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;
|
||||||
327
db/schema.sql
327
db/schema.sql
|
|
@ -1,7 +1,7 @@
|
||||||
\restrict dbmate
|
\restrict dbmate
|
||||||
|
|
||||||
-- Dumped from database version 18.4
|
-- Dumped from database version 18.6
|
||||||
-- Dumped by pg_dump version 18.4
|
-- Dumped by pg_dump version 18.6
|
||||||
|
|
||||||
SET statement_timeout = 0;
|
SET statement_timeout = 0;
|
||||||
SET lock_timeout = 0;
|
SET lock_timeout = 0;
|
||||||
|
|
@ -15,26 +15,55 @@ SET xmloption = content;
|
||||||
SET client_min_messages = warning;
|
SET client_min_messages = warning;
|
||||||
SET row_security = off;
|
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_tablespace = '';
|
||||||
|
|
||||||
SET default_table_access_method = heap;
|
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,
|
id integer NOT NULL,
|
||||||
name jsonb NOT NULL,
|
name text NOT NULL,
|
||||||
description jsonb 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
|
AS integer
|
||||||
START WITH 1
|
START WITH 1
|
||||||
INCREMENT BY 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
|
CREATE TABLE public.users (
|
||||||
ADD CONSTRAINT items_pkey PRIMARY KEY (id);
|
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);
|
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
|
-- PostgreSQL database dump complete
|
||||||
--
|
--
|
||||||
|
|
@ -94,4 +387,6 @@ ALTER TABLE ONLY public.schema_migrations
|
||||||
--
|
--
|
||||||
|
|
||||||
INSERT INTO public.schema_migrations (version) VALUES
|
INSERT INTO public.schema_migrations (version) VALUES
|
||||||
('20260101000000');
|
('20260823153300'),
|
||||||
|
('20260823153310'),
|
||||||
|
('20260823153320');
|
||||||
|
|
|
||||||
43
db/seed.sql
43
db/seed.sql
|
|
@ -2,12 +2,45 @@
|
||||||
-- psql "$DATABASE_URL" -f db/seed.sql
|
-- psql "$DATABASE_URL" -f db/seed.sql
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
INSERT INTO public.items (id, "name", "description") VALUES
|
INSERT INTO public.users (id, external_id, firstname, "name", email, oidc_sub, admin) VALUES
|
||||||
(1, '{"fr": "Premier objet", "en": "First item"}', '{"fr": "Un objet de démonstration.", "en": "A demo item."}'),
|
(1, '100001', 'Alice', 'Martin', 'alice.martin@epfl.ch', 'oidc-sub-alice', TRUE),
|
||||||
(2, '{"fr": "Deuxième objet", "en": "Second item"}', '{"fr": "Un autre objet de démonstration.", "en": "Another demo item."}')
|
(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;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Keep the sequence in sync with the explicit ids inserted above
|
-- Unit names come from Whiskey; these are placeholders for development
|
||||||
SELECT setval('public.items_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.items));
|
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;
|
COMMIT;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.3.2/css/flag-icons.min.css"
|
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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>App template</title>
|
<title>CarGAGEP</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "app-template-frontend",
|
"name": "cargagep-frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
BIN
frontend/public/cargobike-icon.png
Normal file
BIN
frontend/public/cargobike-icon.png
Normal file
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 |
BIN
frontend/public/logo-agep.png
Normal file
BIN
frontend/public/logo-agep.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
49
frontend/src/lib/api.d.ts
vendored
49
frontend/src/lib/api.d.ts
vendored
|
|
@ -39,57 +39,10 @@ export interface paths {
|
||||||
patch?: never
|
patch?: never
|
||||||
trace?: 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 type webhooks = Record<string, never>
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: never
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
responses: never
|
responses: never
|
||||||
parameters: never
|
parameters: never
|
||||||
requestBodies: never
|
requestBodies: never
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
locale: en
|
locale: en
|
||||||
app:
|
app:
|
||||||
title: App template
|
title: CarGAGEP
|
||||||
sidebar:
|
sidebar:
|
||||||
navigation: Navigation
|
navigation: Navigation
|
||||||
home: Home
|
home: Home
|
||||||
home:
|
home:
|
||||||
welcome: Welcome!
|
welcome: Welcome!
|
||||||
version: 'Backend version: {version}'
|
version: 'Backend version: {version}'
|
||||||
items: Items
|
|
||||||
items-empty: No item yet.
|
|
||||||
items-error: Unable to load the items.
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
locale: fr
|
locale: fr
|
||||||
app:
|
app:
|
||||||
title: App template
|
title: CarGAGEP
|
||||||
sidebar:
|
sidebar:
|
||||||
navigation: Navigation
|
navigation: Navigation
|
||||||
home: Accueil
|
home: Accueil
|
||||||
home:
|
home:
|
||||||
welcome: Bienvenue !
|
welcome: Bienvenue !
|
||||||
version: 'Version du backend : {version}'
|
version: 'Version du backend : {version}'
|
||||||
items: Objets
|
|
||||||
items-empty: Aucun objet pour le moment.
|
|
||||||
items-error: Impossible de charger les objets.
|
|
||||||
|
|
|
||||||
|
|
@ -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],
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import type { components } from '@/lib/api'
|
import type { components } from '@/lib/api'
|
||||||
|
|
||||||
// Shorthands over the generated schemas, so views never import `@/lib/api` directly
|
// Shorthands over the generated schemas, so views never import `@/lib/api` directly.
|
||||||
export type LocalizedString = components['schemas']['LocalizedString']
|
// Re-export a type here for every schema the views use, e.g.
|
||||||
export type Item = components['schemas']['Item']
|
// 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']
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,16 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Only page of the template: shows the backend version and the example route.
|
* Only page so far: shows the backend version.
|
||||||
* Replace its content with your own, and add a view per route in `router/index.ts`.
|
* 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'
|
import { useVersion } from '@/services/api/misc'
|
||||||
|
|
||||||
const { data: version } = useVersion()
|
const { data: version } = useVersion()
|
||||||
const { data: items, isPending, isError } = useItems()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<h1 class="text-2xl font-semibold">{{ $t('home.welcome') }}</h1>
|
<h1 class="text-2xl font-semibold">{{ $t('home.welcome') }}</h1>
|
||||||
<p class="text-muted-foreground text-sm">{{ $t('home.version', { version }) }}</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
56
rename.sh
56
rename.sh
|
|
@ -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
180
src/api/auth.rs
Normal 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.")
|
||||||
|
}
|
||||||
|
|
@ -15,10 +15,6 @@ use axum::{Extension, Json, response::IntoResponse, routing::get};
|
||||||
pub fn api_docs_metadata(api: TransformOpenApi) -> TransformOpenApi {
|
pub fn api_docs_metadata(api: TransformOpenApi) -> TransformOpenApi {
|
||||||
api.title(&format!("{} API documentation", env!("CRATE_NAME")))
|
api.title(&format!("{} API documentation", env!("CRATE_NAME")))
|
||||||
.description(format!("Build version: {}", env!("GIT_HASH")).as_str())
|
.description(format!("Build version: {}", env!("GIT_HASH")).as_str())
|
||||||
.tag(Tag {
|
|
||||||
name: "Items".to_owned(),
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.tag(Tag {
|
.tag(Tag {
|
||||||
name: "misc".to_owned(),
|
name: "misc".to_owned(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
|
|
@ -21,7 +21,6 @@ use crate::core::controller::AppController;
|
||||||
|
|
||||||
mod docs;
|
mod docs;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
mod items;
|
|
||||||
|
|
||||||
pub fn get_router(controller: AppController) -> Router {
|
pub fn get_router(controller: AppController) -> Router {
|
||||||
aide::generate::on_error(|err| error!("aide generated error: {err}"));
|
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"),
|
|op| op.tag("misc").summary("Get app version"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.nest_api_service("/api/items", items::routes())
|
|
||||||
.nest_api_service("/api/docs", docs::routes())
|
.nest_api_service("/api/docs", docs::routes())
|
||||||
.finish_api_with(&mut api, docs::api_docs_metadata)
|
.finish_api_with(&mut api, docs::api_docs_metadata)
|
||||||
.layer(Extension(controller))
|
.layer(Extension(controller))
|
||||||
|
|
|
||||||
141
src/core/controller/authn.rs
Normal file
141
src/core/controller/authn.rs
Normal 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,
|
||||||
|
}
|
||||||
52
src/core/controller/bikes.rs
Normal file
52
src/core/controller/bikes.rs
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
@ -2,19 +2,21 @@
|
||||||
//! rules of the app. It talks to the outside world through the repository traits,
|
//! rules of the app. It talks to the outside world through the repository traits,
|
||||||
//! so it depends neither on axum nor on sqlx.
|
//! so it depends neither on axum nor on sqlx.
|
||||||
//!
|
//!
|
||||||
//! One file per domain area (`items.rs` here), each one adding methods to
|
//! One file per domain area, each one adding methods to `AppController` through
|
||||||
//! `AppController` through `impl super::AppController`.
|
//! `impl super::AppController`.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::core::{
|
use crate::core::{
|
||||||
controller::items::ItemsControllerError,
|
controller::{bikes::BikesControllerError, reservations::ReservationsControllerError},
|
||||||
repositories::{DatabaseRepository, RepositoryError},
|
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.
|
/// Entry point of the business logic. Cheap to clone: handlers get one per request.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -29,23 +31,22 @@ impl AppController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every error the api may have to translate into a status code.
|
/// 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)]
|
#[derive(Error, Debug)]
|
||||||
// Skeleton kept for your own logic: the example only performs a read
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum ControllerError {
|
pub enum ControllerError {
|
||||||
#[error("Internal error: {0}")]
|
#[error("Internal error: {0}")]
|
||||||
InternalError(String),
|
InternalError(String),
|
||||||
#[error("Generic repository error")]
|
#[error("Generic repository error")]
|
||||||
RepositoryError(#[from] RepositoryError),
|
RepositoryError(#[from] RepositoryError),
|
||||||
|
|
||||||
#[error("Item specific error: {0}")]
|
#[error("Bike specific error: {0}")]
|
||||||
Item(#[from] ItemsControllerError),
|
Bike(#[from] BikesControllerError),
|
||||||
|
#[error("Reservation specific error: {0}")]
|
||||||
|
Reservation(#[from] ReservationsControllerError),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ControllerError {
|
impl ControllerError {
|
||||||
/// Handy in the handlers: `Err(err) if err.is_not_found() => 404`
|
/// Handy in the handlers: `Err(err) if err.is_not_found() => 404`
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn is_not_found(&self) -> bool {
|
pub fn is_not_found(&self) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
104
src/core/controller/reservations.rs
Normal file
104
src/core/controller/reservations.rs
Normal 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),
|
||||||
|
}
|
||||||
32
src/core/controller/users.rs
Normal file
32
src/core/controller/users.rs
Normal 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
34
src/core/models/bike.rs
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +1,5 @@
|
||||||
pub mod item;
|
pub mod bike;
|
||||||
pub mod localized_string;
|
pub mod localized_string;
|
||||||
|
pub mod reservation;
|
||||||
|
pub mod unit;
|
||||||
|
pub mod user;
|
||||||
|
|
|
||||||
92
src/core/models/reservation.rs
Normal file
92
src/core/models/reservation.rs
Normal 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
1
src/core/models/unit.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub type UnitId = String;
|
||||||
28
src/core/models/user.rs
Normal file
28
src/core/models/user.rs
Normal 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>,
|
||||||
|
}
|
||||||
16
src/core/repositories/bikes_repository.rs
Normal file
16
src/core/repositories/bikes_repository.rs
Normal 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>;
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
|
||||||
}
|
|
||||||
|
|
@ -7,13 +7,21 @@
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use thiserror::Error;
|
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`
|
/// Add every new repository trait here so the controller can use it through `db`
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait DatabaseRepository: ItemsRepository + Send + Sync {}
|
pub trait DatabaseRepository:
|
||||||
|
UsersRepository + BikesRepository + ReservationsRepository + Send + Sync
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum RepositoryError {
|
pub enum RepositoryError {
|
||||||
|
|
|
||||||
15
src/core/repositories/oidc_states_repository.rs
Normal file
15
src/core/repositories/oidc_states_repository.rs
Normal 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>;
|
||||||
|
}
|
||||||
37
src/core/repositories/reservations_repository.rs
Normal file
37
src/core/repositories/reservations_repository.rs
Normal 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>;
|
||||||
|
}
|
||||||
18
src/core/repositories/users_repository.rs
Normal file
18
src/core/repositories/users_repository.rs
Normal 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>;
|
||||||
|
}
|
||||||
15
src/main.rs
15
src/main.rs
|
|
@ -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 std::sync::Arc;
|
||||||
|
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
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,
|
// 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.
|
// 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)
|
// (`fallback` and not `not_found_service`, which would force a 404 status)
|
||||||
let app = api::get_router(controller).fallback_service(
|
let app =
|
||||||
ServeDir::new(&config.frontend_dir).fallback(ServeFile::new(format!(
|
api::get_router(controller).fallback_service(ServeDir::new(&config.frontend_dir).fallback(
|
||||||
"{}/index.html",
|
ServeFile::new(format!("{}/index.html", config.frontend_dir)),
|
||||||
config.frontend_dir
|
));
|
||||||
))),
|
|
||||||
);
|
|
||||||
|
|
||||||
let bind_address = config.get_bind_address();
|
let bind_address = config.get_bind_address();
|
||||||
let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap();
|
let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap();
|
||||||
|
|
|
||||||
162
src/services/database/bikes.rs
Normal file
162
src/services/database/bikes.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
//! Postgres implementation of the repository traits, using sqlx.
|
//! Postgres implementation of the repository traits, using sqlx.
|
||||||
//!
|
//!
|
||||||
//! The `query!`/`query_as!` macros check the sql against a real database at compile
|
//! 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
|
//! time: the development database must be up and migrated, or the `.sqlx` offline
|
||||||
//! (`cargo sqlx prepare`).
|
//! data must be present (`cargo sqlx prepare`).
|
||||||
|
|
||||||
mod items;
|
mod bikes;
|
||||||
|
mod reservations;
|
||||||
|
mod users;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sqlx::{Pool, Postgres, postgres::PgPoolOptions};
|
use sqlx::{Pool, Postgres, postgres::PgPoolOptions};
|
||||||
|
|
|
||||||
78
src/services/database/oidc_states.rs
Normal file
78
src/services/database/oidc_states.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
335
src/services/database/reservations.rs
Normal file
335
src/services/database/reservations.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/services/database/users.rs
Normal file
168
src/services/database/users.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,12 +46,12 @@ impl AppConfig {
|
||||||
/// Loads the configuration, by decreasing priority:
|
/// Loads the configuration, by decreasing priority:
|
||||||
/// - `APP__SERVER__PORT=3000` style environment variables
|
/// - `APP__SERVER__PORT=3000` style environment variables
|
||||||
/// - `./config.yml`
|
/// - `./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 {
|
pub fn get() -> &'static AppConfig {
|
||||||
static CONFIG: OnceLock<AppConfig> = OnceLock::new();
|
static CONFIG: OnceLock<AppConfig> = OnceLock::new();
|
||||||
CONFIG.get_or_init(|| {
|
CONFIG.get_or_init(|| {
|
||||||
let config_path =
|
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 config_path = Path::new(&config_path);
|
||||||
|
|
||||||
let raw = Config::builder()
|
let raw = Config::builder()
|
||||||
|
|
|
||||||
261
src/utils/whiskey.rs
Normal file
261
src/utils/whiskey.rs
Normal 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,
|
||||||
|
>;
|
||||||
Loading…
Add table
Reference in a new issue