diff --git a/.env b/.env index f9a79d5..d5cf572 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ # This file is used by dbmate, and by the sqlx macros at compile time. -DATABASE_URL=postgres://postgres:postgres@localhost:5432/app_template?sslmode=disable +DATABASE_URL=postgres://postgres:postgres@localhost:5432/cargagep?sslmode=disable diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bfc0469 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "i18n-ally.localesPaths": [ + "frontend/src/locales" + ] +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index b229547..f5b68eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,25 +60,6 @@ dependencies = [ "libc", ] -[[package]] -name = "app-template" -version = "0.1.0" -dependencies = [ - "aide", - "async-trait", - "axum", - "config", - "schemars", - "serde", - "serde_json", - "sqlx", - "thiserror", - "tokio", - "tower-http", - "tracing", - "tracing-subscriber", -] - [[package]] name = "arraydeque" version = "0.5.1" @@ -229,6 +210,26 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cargagep" +version = "0.1.0" +dependencies = [ + "aide", + "async-trait", + "axum", + "chrono", + "config", + "schemars", + "serde", + "serde_json", + "sqlx", + "thiserror", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + [[package]] name = "cc" version = "1.4.0" @@ -253,6 +254,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", + "serde", "windows-link", ] @@ -1546,6 +1548,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" dependencies = [ + "chrono", "dyn-clone", "indexmap", "ref-cast", diff --git a/Cargo.toml b/Cargo.toml index 225e75e..aca7a08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "app-template" +name = "cargagep" version = "0.1.0" edition = "2024" @@ -13,8 +13,9 @@ aide = { version = "0.15.1", features = [ ] } async-trait = "0.1.89" axum = { version = "0.8.9", features = ["macros"] } +chrono = { version = "0.4.45", default-features = false, features = ["serde", "clock", "std"] } config = "0.15.23" -schemars = "0.9.0" +schemars = { version = "0.9", features = ["chrono04"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sqlx = { version = "0.8.6", features = [ diff --git a/README.md b/README.md index b4d2897..6cb6b03 100644 --- a/README.md +++ b/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** migrations. -The template is deliberately almost empty: one table (`items`), one route -(`GET /api/items`) and one page (home) that displays it. They exist to show how the layers -fit together — rename them, or delete them once your own code is in place. +The data model is in place (users, bikes, reservations) down to the sqlx layer; the api +routes and the frontend views are not written yet. ## What you get @@ -18,30 +17,28 @@ fit together — rename them, or delete them once your own code is in place. - Tailwind 4 and the shadcn-vue components already vendored in `frontend/src/components/ui` - A single binary in production: the backend serves the built frontend -There is **no authentication** in this template: add whatever the project needs. +Authentication is **not wired yet**. Users are mirrored from AGEPoly's OIDC provider +(Whiskey): `users.oidc_sub` is the identity, and `AppController::login` upserts the row from +the claims of the token. Because the units also come from Whiskey, they are stored as plain +text (`units_users.unit_name`, `reservations.unit`) rather than as an enum or a reference +table: a unit unknown to us must never break a login. -## Bootstrap a new project +## Getting started ```bash -cp -r template /path/to/my-project && cd /path/to/my-project -git init - -# 1. Rename the crate and the database (app-template -> my-project) -./rename.sh my-project "My Project" && rm rename.sh - -# 2. Start the development database -cd dev-db && docker compose up -d && cd .. -psql -h localhost -U postgres -c 'CREATE DATABASE my_project' +# 1. Database +cd dev-db && docker compose up -d && cd .. # or use a local postgres +psql -h localhost -U postgres -c 'CREATE DATABASE cargagep' dbmate up psql "$(grep DATABASE_URL .env | cut -d= -f2-)" -f db/seed.sql # optional demo data -# 3. Configure the app +# 2. Configure the app cp config.example.yml config.yml -# 4. Run the backend (port 3000) +# 3. Run the backend (port 3000) cargo run -# 5. Run the frontend (port 5000, proxies /api to the backend) +# 4. Run the frontend (port 5000, proxies /api to the backend) cd frontend && npm install && npm run dev ``` @@ -103,7 +100,9 @@ their OpenAPI documentation sits right next to them (`fn *_docs`). 6. `src/api/things.rs`: the handlers and their docs, mounted in `src/api/mod.rs` 7. `cd frontend && npm run openapi` to regenerate the types, then write the service and the view -The `Item` entity follows exactly these steps: copy it. +Steps 1 to 5 are done for `users`, `bikes` and `reservations`; steps 6 and 7 are not. +Until an api route exists, the whole stack is unused, which is why `src/main.rs` carries a +crate-level `#![allow(dead_code)]` — delete it once the handlers are written. Request bodies are best kept separate from the domain models (an `api/models.rs` holding the `...Api` structs and their `Into` impls), so that the public contract does not @@ -154,3 +153,33 @@ Rules of thumb: `cargo build --release`, `npm run build`, then point `frontend_dir` at the built `frontend/dist`: the backend serves the static files and falls back on `index.html` so the vue router keeps working on a page reload. Only one process to deploy. + +## Data model + +``` +users ──< units_users a user belongs to several units (from Whiskey) + │ + ├──< reservations_users >── reservations ──< reservations_bikes >── bikes + └──── reservations.requester_id +``` + +A reservation moves through a state machine, enforced in +`ReservationsController::set_reservation_status` and documented on +`core::models::reservation::ReservationStatus`: + +``` +requested ──▶ refused + │ + ▼ +approved ──▶ cancelled + │ ▲ + ▼ │ + ongoing ────────┘ + │ + ▼ +archived +``` + +`refused`, `cancelled` and `archived` are final. A bike is `in_service` or `out_of_service`; +a bike that has ever been booked cannot be deleted (`ON DELETE RESTRICT`), take it out of +service instead. diff --git a/config.example.yml b/config.example.yml index de27a26..067184c 100644 --- a/config.example.yml +++ b/config.example.yml @@ -9,7 +9,7 @@ postgres: port: 5432 user: postgres password: postgres - name: app_template + name: cargagep # Directory containing the built frontend (frontend/dist after `npm run build`) frontend_dir: frontend/dist diff --git a/db/migrations/20260101000000_create_items.sql b/db/migrations/20260101000000_create_items.sql deleted file mode 100644 index 47c35c6..0000000 --- a/db/migrations/20260101000000_create_items.sql +++ /dev/null @@ -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; diff --git a/db/migrations/20260823153300_create_users.sql b/db/migrations/20260823153300_create_users.sql new file mode 100644 index 0000000..621bcc3 --- /dev/null +++ b/db/migrations/20260823153300_create_users.sql @@ -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; diff --git a/db/migrations/20260823153310_create_bikes.sql b/db/migrations/20260823153310_create_bikes.sql new file mode 100644 index 0000000..c09e4a9 --- /dev/null +++ b/db/migrations/20260823153310_create_bikes.sql @@ -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; diff --git a/db/migrations/20260823153320_create_reservations.sql b/db/migrations/20260823153320_create_reservations.sql new file mode 100644 index 0000000..7fa8e07 --- /dev/null +++ b/db/migrations/20260823153320_create_reservations.sql @@ -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; diff --git a/db/schema.sql b/db/schema.sql index a514af1..f2912e8 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,7 +1,7 @@ \restrict dbmate --- Dumped from database version 18.4 --- Dumped by pg_dump version 18.4 +-- Dumped from database version 18.6 +-- Dumped by pg_dump version 18.6 SET statement_timeout = 0; SET lock_timeout = 0; @@ -15,26 +15,55 @@ SET xmloption = content; SET client_min_messages = warning; SET row_security = off; +-- +-- Name: bike_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.bike_status AS ENUM ( + 'in_service', + 'out_of_service' +); + + +-- +-- Name: reservation_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.reservation_status AS ENUM ( + 'requested', + 'refused', + 'approved', + 'cancelled', + 'ongoing', + 'archived' +); + + SET default_tablespace = ''; SET default_table_access_method = heap; -- --- Name: items; Type: TABLE; Schema: public; Owner: - +-- Name: bikes; Type: TABLE; Schema: public; Owner: - -- -CREATE TABLE public.items ( +CREATE TABLE public.bikes ( id integer NOT NULL, - name jsonb NOT NULL, - description jsonb NOT NULL + name text NOT NULL, + key_number text, + key_quantity integer DEFAULT 0 NOT NULL, + drivetrain text, + battery text, + status public.bike_status DEFAULT 'in_service'::public.bike_status NOT NULL, + CONSTRAINT bikes_key_quantity_positive CHECK ((key_quantity >= 0)) ); -- --- Name: items_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- Name: bikes_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- -CREATE SEQUENCE public.items_id_seq +CREATE SEQUENCE public.bikes_id_seq AS integer START WITH 1 INCREMENT BY 1 @@ -44,10 +73,68 @@ CREATE SEQUENCE public.items_id_seq -- --- Name: items_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- Name: bikes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - -- -ALTER SEQUENCE public.items_id_seq OWNED BY public.items.id; +ALTER SEQUENCE public.bikes_id_seq OWNED BY public.bikes.id; + + +-- +-- Name: reservations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.reservations ( + id integer NOT NULL, + unit text NOT NULL, + start_time timestamp with time zone NOT NULL, + end_time timestamp with time zone NOT NULL, + requester_id integer NOT NULL, + telegram text NOT NULL, + description text DEFAULT ''::text NOT NULL, + status public.reservation_status DEFAULT 'requested'::public.reservation_status NOT NULL, + CONSTRAINT reservations_telegram_handle CHECK ((telegram ~ '^@[A-Za-z][A-Za-z0-9_]{4,31}$'::text)), + CONSTRAINT reservations_time_order CHECK ((end_time > start_time)) +); + + +-- +-- Name: reservations_bikes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.reservations_bikes ( + reservation_id integer NOT NULL, + bike_id integer NOT NULL +); + + +-- +-- Name: reservations_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.reservations_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: reservations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.reservations_id_seq OWNED BY public.reservations.id; + + +-- +-- Name: reservations_users; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.reservations_users ( + reservation_id integer NOT NULL, + user_id integer NOT NULL +); -- @@ -60,18 +147,101 @@ CREATE TABLE public.schema_migrations ( -- --- Name: items id; Type: DEFAULT; Schema: public; Owner: - +-- Name: units_users; Type: TABLE; Schema: public; Owner: - -- -ALTER TABLE ONLY public.items ALTER COLUMN id SET DEFAULT nextval('public.items_id_seq'::regclass); +CREATE TABLE public.units_users ( + user_id integer NOT NULL, + unit_name text NOT NULL +); -- --- Name: items items_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- Name: users; Type: TABLE; Schema: public; Owner: - -- -ALTER TABLE ONLY public.items - ADD CONSTRAINT items_pkey PRIMARY KEY (id); +CREATE TABLE public.users ( + id integer NOT NULL, + external_id text, + firstname text NOT NULL, + name text NOT NULL, + email text NOT NULL, + oidc_sub text NOT NULL, + admin boolean DEFAULT false NOT NULL +); + + +-- +-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.users_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id; + + +-- +-- Name: bikes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bikes ALTER COLUMN id SET DEFAULT nextval('public.bikes_id_seq'::regclass); + + +-- +-- Name: reservations id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations ALTER COLUMN id SET DEFAULT nextval('public.reservations_id_seq'::regclass); + + +-- +-- Name: users id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass); + + +-- +-- Name: bikes bikes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bikes + ADD CONSTRAINT bikes_pkey PRIMARY KEY (id); + + +-- +-- Name: reservations_bikes reservations_bikes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations_bikes + ADD CONSTRAINT reservations_bikes_pkey PRIMARY KEY (reservation_id, bike_id); + + +-- +-- Name: reservations reservations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations + ADD CONSTRAINT reservations_pkey PRIMARY KEY (id); + + +-- +-- Name: reservations_users reservations_users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations_users + ADD CONSTRAINT reservations_users_pkey PRIMARY KEY (reservation_id, user_id); -- @@ -82,6 +252,129 @@ ALTER TABLE ONLY public.schema_migrations ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); +-- +-- Name: units_users units_users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.units_users + ADD CONSTRAINT units_users_pkey PRIMARY KEY (user_id, unit_name); + + +-- +-- Name: users users_email_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_email_key UNIQUE (email); + + +-- +-- Name: users users_external_id_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_external_id_key UNIQUE (external_id); + + +-- +-- Name: users users_oidc_sub_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_oidc_sub_key UNIQUE (oidc_sub); + + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + +-- +-- Name: reservations_bikes_bike_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX reservations_bikes_bike_id_idx ON public.reservations_bikes USING btree (bike_id); + + +-- +-- Name: reservations_period_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX reservations_period_idx ON public.reservations USING btree (start_time, end_time); + + +-- +-- Name: reservations_status_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX reservations_status_idx ON public.reservations USING btree (status); + + +-- +-- Name: reservations_unit_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX reservations_unit_idx ON public.reservations USING btree (unit); + + +-- +-- Name: units_users_unit_name_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX units_users_unit_name_idx ON public.units_users USING btree (unit_name); + + +-- +-- Name: reservations_bikes reservations_bikes_bike_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations_bikes + ADD CONSTRAINT reservations_bikes_bike_id_fkey FOREIGN KEY (bike_id) REFERENCES public.bikes(id) ON DELETE RESTRICT; + + +-- +-- Name: reservations_bikes reservations_bikes_reservation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations_bikes + ADD CONSTRAINT reservations_bikes_reservation_id_fkey FOREIGN KEY (reservation_id) REFERENCES public.reservations(id) ON DELETE CASCADE; + + +-- +-- Name: reservations reservations_requester_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations + ADD CONSTRAINT reservations_requester_id_fkey FOREIGN KEY (requester_id) REFERENCES public.users(id); + + +-- +-- Name: reservations_users reservations_users_reservation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations_users + ADD CONSTRAINT reservations_users_reservation_id_fkey FOREIGN KEY (reservation_id) REFERENCES public.reservations(id) ON DELETE CASCADE; + + +-- +-- Name: reservations_users reservations_users_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations_users + ADD CONSTRAINT reservations_users_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: units_users units_users_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.units_users + ADD CONSTRAINT units_users_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + -- -- PostgreSQL database dump complete -- @@ -94,4 +387,6 @@ ALTER TABLE ONLY public.schema_migrations -- INSERT INTO public.schema_migrations (version) VALUES - ('20260101000000'); + ('20260823153300'), + ('20260823153310'), + ('20260823153320'); diff --git a/db/seed.sql b/db/seed.sql index 16b32de..22b4286 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -2,12 +2,45 @@ -- psql "$DATABASE_URL" -f db/seed.sql BEGIN; -INSERT INTO public.items (id, "name", "description") VALUES - (1, '{"fr": "Premier objet", "en": "First item"}', '{"fr": "Un objet de démonstration.", "en": "A demo item."}'), - (2, '{"fr": "Deuxième objet", "en": "Second item"}', '{"fr": "Un autre objet de démonstration.", "en": "Another demo item."}') +INSERT INTO public.users (id, external_id, firstname, "name", email, oidc_sub, admin) VALUES + (1, '100001', 'Alice', 'Martin', 'alice.martin@epfl.ch', 'oidc-sub-alice', TRUE), + (2, '100002', 'Bob', 'Dupont', 'bob.dupont@epfl.ch', 'oidc-sub-bob', FALSE), + (3, NULL, 'Chloé', 'Favre', 'chloe.favre@epfl.ch', 'oidc-sub-chloe', FALSE) ON CONFLICT DO NOTHING; --- Keep the sequence in sync with the explicit ids inserted above -SELECT setval('public.items_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.items)); +-- Unit names come from Whiskey; these are placeholders for development +INSERT INTO public.units_users (user_id, unit_name) VALUES + (1, 'agepoly'), + (2, 'agepoly'), + (2, 'clic'), + (3, 'clic') +ON CONFLICT DO NOTHING; + +INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, battery, status) VALUES + (1, 'Cargo 1', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'), + (2, 'Cargo 2', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service'), + (3, 'Cargo 3', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'out_of_service') +ON CONFLICT DO NOTHING; + +INSERT INTO public.reservations + (id, unit, start_time, end_time, requester_id, telegram, "description", status) VALUES + (1, 'agepoly', '2026-09-01 08:00:00+02', '2026-09-01 18:00:00+02', 1, '@alice_martin', + 'Transport du matériel pour la rentrée', 'approved'), + (2, 'clic', '2026-09-05 09:00:00+02', '2026-09-06 17:00:00+02', 3, '@chloe_favre', + 'Déménagement du stock de la commission', 'requested') +ON CONFLICT DO NOTHING; + +INSERT INTO public.reservations_users (reservation_id, user_id) VALUES + (1, 1), (1, 2), (2, 3) +ON CONFLICT DO NOTHING; + +INSERT INTO public.reservations_bikes (reservation_id, bike_id) VALUES + (1, 1), (1, 2), (2, 1) +ON CONFLICT DO NOTHING; + +-- Keep the sequences in sync with the explicit ids inserted above +SELECT setval('public.users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.users)); +SELECT setval('public.bikes_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.bikes)); +SELECT setval('public.reservations_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.reservations)); COMMIT; diff --git a/frontend/index.html b/frontend/index.html index 714f888..2b77d5c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,7 +9,7 @@ href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.3.2/css/flag-icons.min.css" /> - App template + CarGAGEP
diff --git a/frontend/package.json b/frontend/package.json index 12863a1..0e5c4d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "app-template-frontend", + "name": "cargagep-frontend", "version": "0.0.0", "private": true, "type": "module", diff --git a/frontend/public/cargobike-icon.png b/frontend/public/cargobike-icon.png new file mode 100644 index 0000000..3db57cc Binary files /dev/null and b/frontend/public/cargobike-icon.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index df36fcf..f075ae7 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/logo-agep.png b/frontend/public/logo-agep.png new file mode 100644 index 0000000..e9a0926 Binary files /dev/null and b/frontend/public/logo-agep.png differ diff --git a/frontend/src/lib/api.d.ts b/frontend/src/lib/api.d.ts index 1de5ee3..805b4af 100644 --- a/frontend/src/lib/api.d.ts +++ b/frontend/src/lib/api.d.ts @@ -39,57 +39,10 @@ export interface paths { patch?: never trace?: never } - '/api/items': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Get the list of items */ - get: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Item'][] - } - } - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } } export type webhooks = Record export interface components { - schemas: { - Item: { - description: components['schemas']['LocalizedString'] - /** Format: int32 */ - id: number - name: components['schemas']['LocalizedString'] - } - /** @description Translated string, stored as a JSONB column in postgres */ - LocalizedString: { - en: string - fr: string - } - } + schemas: never responses: never parameters: never requestBodies: never diff --git a/frontend/src/locales/en.yml b/frontend/src/locales/en.yml index 7e05917..1b03e30 100644 --- a/frontend/src/locales/en.yml +++ b/frontend/src/locales/en.yml @@ -1,12 +1,9 @@ locale: en app: - title: App template + title: CarGAGEP sidebar: navigation: Navigation home: Home home: welcome: Welcome! version: 'Backend version: {version}' - items: Items - items-empty: No item yet. - items-error: Unable to load the items. diff --git a/frontend/src/locales/fr.yml b/frontend/src/locales/fr.yml index 862a314..1a57ace 100644 --- a/frontend/src/locales/fr.yml +++ b/frontend/src/locales/fr.yml @@ -1,12 +1,9 @@ locale: fr app: - title: App template + title: CarGAGEP sidebar: navigation: Navigation home: Accueil home: welcome: Bienvenue ! version: 'Version du backend : {version}' - items: Objets - items-empty: Aucun objet pour le moment. - items-error: Impossible de charger les objets. diff --git a/frontend/src/services/api/items.ts b/frontend/src/services/api/items.ts deleted file mode 100644 index fdbf815..0000000 --- a/frontend/src/services/api/items.ts +++ /dev/null @@ -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], - })), - ), - } -} diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts index 7c50122..023bda7 100644 --- a/frontend/src/utils/types.ts +++ b/frontend/src/utils/types.ts @@ -1,5 +1,7 @@ import type { components } from '@/lib/api' -// Shorthands over the generated schemas, so views never import `@/lib/api` directly -export type LocalizedString = components['schemas']['LocalizedString'] -export type Item = components['schemas']['Item'] +// Shorthands over the generated schemas, so views never import `@/lib/api` directly. +// Re-export a type here for every schema the views use, e.g. +// export type Bike = components['schemas']['Bike'] +// once `src/api/` exposes the routes and `npm run openapi` has been run again. +export type Schemas = components['schemas'] diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 3b7bb8e..45720a0 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,34 +1,16 @@ diff --git a/rename.sh b/rename.sh deleted file mode 100755 index b3a49f1..0000000 --- a/rename.sh +++ /dev/null @@ -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 [\"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" diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..514fbc6 --- /dev/null +++ b/src/api/auth.rs @@ -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>, +) -> 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, (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>, + Json(PostCallbackParams { code, state }): Json, +) -> Result, (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>, + Json(form): Json, +) -> 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.") +} diff --git a/src/api/docs.rs b/src/api/docs.rs index 2baf206..c7b83fd 100644 --- a/src/api/docs.rs +++ b/src/api/docs.rs @@ -15,10 +15,6 @@ use axum::{Extension, Json, response::IntoResponse, routing::get}; pub fn api_docs_metadata(api: TransformOpenApi) -> TransformOpenApi { api.title(&format!("{} API documentation", env!("CRATE_NAME"))) .description(format!("Build version: {}", env!("GIT_HASH")).as_str()) - .tag(Tag { - name: "Items".to_owned(), - ..Default::default() - }) .tag(Tag { name: "misc".to_owned(), ..Default::default() diff --git a/src/api/items.rs b/src/api/items.rs deleted file mode 100644 index e807be8..0000000 --- a/src/api/items.rs +++ /dev/null @@ -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>, (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") -} diff --git a/src/api/mod.rs b/src/api/mod.rs index 1d4628b..ca3ca31 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -21,7 +21,6 @@ use crate::core::controller::AppController; mod docs; mod helpers; -mod items; pub fn get_router(controller: AppController) -> Router { aide::generate::on_error(|err| error!("aide generated error: {err}")); @@ -36,7 +35,6 @@ pub fn get_router(controller: AppController) -> Router { |op| op.tag("misc").summary("Get app version"), ), ) - .nest_api_service("/api/items", items::routes()) .nest_api_service("/api/docs", docs::routes()) .finish_api_with(&mut api, docs::api_docs_metadata) .layer(Extension(controller)) diff --git a/src/core/controller/authn.rs b/src/core/controller/authn.rs new file mode 100644 index 0000000..32b89f5 --- /dev/null +++ b/src/core/controller/authn.rs @@ -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, Self::Error> { + Ok(None) + } + + async fn get_user( + &self, + user_id: &axum_login::UserId, + ) -> Result, Self::Error> { + Ok(Some(self.db.get_user(user_id.clone()).await?)) + } +} + +impl super::AnonAppController { + pub async fn whiskey_authorize(&self) -> Result { + 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 { + 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 { + 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 { + 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, +} diff --git a/src/core/controller/bikes.rs b/src/core/controller/bikes.rs new file mode 100644 index 0000000..2cbee57 --- /dev/null +++ b/src/core/controller/bikes.rs @@ -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, ControllerError> { + self.db.get_bikes().await.map_err(Into::into) + } + + pub async fn get_bike(&self, id: BikeId) -> Result { + self.db.get_bike(id).await.map_err(Into::into) + } + + pub async fn create_bike(&self, bike: NewBike) -> Result { + 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, +} diff --git a/src/core/controller/items.rs b/src/core/controller/items.rs deleted file mode 100644 index 6387af4..0000000 --- a/src/core/controller/items.rs +++ /dev/null @@ -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, 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, -} diff --git a/src/core/controller/mod.rs b/src/core/controller/mod.rs index cbfc28c..dbbf693 100644 --- a/src/core/controller/mod.rs +++ b/src/core/controller/mod.rs @@ -2,19 +2,21 @@ //! rules of the app. It talks to the outside world through the repository traits, //! so it depends neither on axum nor on sqlx. //! -//! One file per domain area (`items.rs` here), each one adding methods to -//! `AppController` through `impl super::AppController`. +//! One file per domain area, each one adding methods to `AppController` through +//! `impl super::AppController`. use std::sync::Arc; use thiserror::Error; use crate::core::{ - controller::items::ItemsControllerError, + controller::{bikes::BikesControllerError, reservations::ReservationsControllerError}, repositories::{DatabaseRepository, RepositoryError}, }; -pub mod items; +pub mod bikes; +pub mod reservations; +pub mod users; /// Entry point of the business logic. Cheap to clone: handlers get one per request. #[derive(Clone)] @@ -29,23 +31,22 @@ impl AppController { } /// Every error the api may have to translate into a status code. -/// Domain specific errors are nested (`Item`), so each area keeps its own enum. +/// Domain specific errors are nested, so each area keeps its own enum. #[derive(Error, Debug)] -// Skeleton kept for your own logic: the example only performs a read -#[allow(dead_code)] pub enum ControllerError { #[error("Internal error: {0}")] InternalError(String), #[error("Generic repository error")] RepositoryError(#[from] RepositoryError), - #[error("Item specific error: {0}")] - Item(#[from] ItemsControllerError), + #[error("Bike specific error: {0}")] + Bike(#[from] BikesControllerError), + #[error("Reservation specific error: {0}")] + Reservation(#[from] ReservationsControllerError), } impl ControllerError { /// Handy in the handlers: `Err(err) if err.is_not_found() => 404` - #[allow(dead_code)] pub fn is_not_found(&self) -> bool { matches!( self, diff --git a/src/core/controller/reservations.rs b/src/core/controller/reservations.rs new file mode 100644 index 0000000..d8dc821 --- /dev/null +++ b/src/core/controller/reservations.rs @@ -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, ControllerError> { + self.db.get_reservations().await.map_err(Into::into) + } + + pub async fn get_unit_reservations( + &self, + unit: UnitId, + ) -> Result, ControllerError> { + self.db + .get_unit_reservations(unit) + .await + .map_err(Into::into) + } + + pub async fn get_reservation(&self, id: ReservationId) -> Result { + self.db.get_reservation(id).await.map_err(Into::into) + } + + pub async fn create_reservation( + &self, + reservation: NewReservation, + ) -> Result { + 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), +} diff --git a/src/core/controller/users.rs b/src/core/controller/users.rs new file mode 100644 index 0000000..aa57b03 --- /dev/null +++ b/src/core/controller/users.rs @@ -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 { + self.db.upsert_user(user).await.map_err(Into::into) + } + + pub async fn get_user(&self, id: UserId) -> Result { + self.db.get_user(id).await.map_err(Into::into) + } + + pub async fn get_user_email(&self, email: String) -> Result { + self.db.get_user_email(email).await.map_err(Into::into) + } + + pub async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result { + 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) + } +} diff --git a/src/core/models/bike.rs b/src/core/models/bike.rs new file mode 100644 index 0000000..aa372bc --- /dev/null +++ b/src/core/models/bike.rs @@ -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, + pub key_quantity: i32, + pub drivetrain: Option, + pub battery: Option, + pub status: BikeStatus, +} + +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +pub struct NewBike { + pub name: String, + pub key_number: Option, + pub key_quantity: i32, + pub drivetrain: Option, + pub battery: Option, + pub status: BikeStatus, +} diff --git a/src/core/models/item.rs b/src/core/models/item.rs deleted file mode 100644 index cf8fb64..0000000 --- a/src/core/models/item.rs +++ /dev/null @@ -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, -} diff --git a/src/core/models/mod.rs b/src/core/models/mod.rs index 9dae50f..8d90518 100644 --- a/src/core/models/mod.rs +++ b/src/core/models/mod.rs @@ -1,2 +1,5 @@ -pub mod item; +pub mod bike; pub mod localized_string; +pub mod reservation; +pub mod unit; +pub mod user; diff --git a/src/core/models/reservation.rs b/src/core/models/reservation.rs new file mode 100644 index 0000000..6b4f894 --- /dev/null +++ b/src/core/models/reservation.rs @@ -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, + pub end_time: DateTime, + pub requester: UserId, + pub users: Vec, + pub telegram: String, + pub description: String, + pub bikes: Vec, + pub status: ReservationStatus, +} + +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +pub struct NewReservation { + pub unit: UnitId, + pub start_time: DateTime, + pub end_time: DateTime, + pub requester: UserId, + pub users: Vec, + pub telegram: String, + pub description: String, + pub bikes: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +pub struct ReservationEdit { + pub id: ReservationId, + pub unit: UnitId, + pub start_time: DateTime, + pub end_time: DateTime, + pub users: Vec, + pub telegram: String, + pub description: String, + pub bikes: Vec, +} + +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() + } +} diff --git a/src/core/models/unit.rs b/src/core/models/unit.rs new file mode 100644 index 0000000..8cbab10 --- /dev/null +++ b/src/core/models/unit.rs @@ -0,0 +1 @@ +pub type UnitId = String; diff --git a/src/core/models/user.rs b/src/core/models/user.rs new file mode 100644 index 0000000..45b968c --- /dev/null +++ b/src/core/models/user.rs @@ -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, + pub firstname: String, + pub name: String, + pub email: String, + pub oidc_sub: String, + pub units: Vec, + pub admin: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +pub struct NewUser { + pub external_id: Option, + pub firstname: String, + pub name: String, + pub email: String, + pub oidc_sub: String, + pub units: Vec, +} diff --git a/src/core/repositories/bikes_repository.rs b/src/core/repositories/bikes_repository.rs new file mode 100644 index 0000000..fe0c22f --- /dev/null +++ b/src/core/repositories/bikes_repository.rs @@ -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, RepositoryError>; + async fn get_bike(&self, id: BikeId) -> Result; + async fn create_bike(&self, bike: NewBike) -> Result; + 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>; +} diff --git a/src/core/repositories/items_repository.rs b/src/core/repositories/items_repository.rs deleted file mode 100644 index 82b26bb..0000000 --- a/src/core/repositories/items_repository.rs +++ /dev/null @@ -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, RepositoryError>; -} diff --git a/src/core/repositories/mod.rs b/src/core/repositories/mod.rs index 2bd5ec1..ede6268 100644 --- a/src/core/repositories/mod.rs +++ b/src/core/repositories/mod.rs @@ -7,13 +7,21 @@ use async_trait::async_trait; use thiserror::Error; -use crate::core::repositories::items_repository::ItemsRepository; +use crate::core::repositories::{ + bikes_repository::BikesRepository, reservations_repository::ReservationsRepository, + users_repository::UsersRepository, +}; -pub mod items_repository; +pub mod bikes_repository; +pub mod reservations_repository; +pub mod users_repository; /// Add every new repository trait here so the controller can use it through `db` #[async_trait] -pub trait DatabaseRepository: ItemsRepository + Send + Sync {} +pub trait DatabaseRepository: + UsersRepository + BikesRepository + ReservationsRepository + Send + Sync +{ +} #[derive(Error, Debug)] pub enum RepositoryError { diff --git a/src/core/repositories/oidc_states_repository.rs b/src/core/repositories/oidc_states_repository.rs new file mode 100644 index 0000000..01aa650 --- /dev/null +++ b/src/core/repositories/oidc_states_repository.rs @@ -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; + async fn save_whiskey_data( + &self, + data: whiskey::AuthorizeBackendData, + ) -> Result<(), RepositoryError>; +} diff --git a/src/core/repositories/reservations_repository.rs b/src/core/repositories/reservations_repository.rs new file mode 100644 index 0000000..5827632 --- /dev/null +++ b/src/core/repositories/reservations_repository.rs @@ -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, RepositoryError>; + async fn get_unit_reservations( + &self, + unit: UnitId, + ) -> Result, RepositoryError>; + async fn get_reservation(&self, id: ReservationId) -> Result; + + async fn create_reservation( + &self, + reservation: NewReservation, + ) -> Result; + + 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>; +} diff --git a/src/core/repositories/users_repository.rs b/src/core/repositories/users_repository.rs new file mode 100644 index 0000000..bea0f9c --- /dev/null +++ b/src/core/repositories/users_repository.rs @@ -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; + async fn get_user_email(&self, email: String) -> Result; + async fn get_user_external_id(&self, external_id: String) -> Result; + async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result; + + async fn upsert_user(&self, user: NewUser) -> Result; + + async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>; +} diff --git a/src/main.rs b/src/main.rs index 6d82fcb..5c13945 100644 --- a/src/main.rs +++ b/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 tower_http::services::{ServeDir, ServeFile}; @@ -32,12 +37,10 @@ async fn main() { // Anything that is not an api route is served from the built frontend, // falling back on index.html so the vue router can handle the path. // (`fallback` and not `not_found_service`, which would force a 404 status) - let app = api::get_router(controller).fallback_service( - ServeDir::new(&config.frontend_dir).fallback(ServeFile::new(format!( - "{}/index.html", - config.frontend_dir - ))), - ); + let app = + api::get_router(controller).fallback_service(ServeDir::new(&config.frontend_dir).fallback( + ServeFile::new(format!("{}/index.html", config.frontend_dir)), + )); let bind_address = config.get_bind_address(); let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap(); diff --git a/src/services/database/bikes.rs b/src/services/database/bikes.rs new file mode 100644 index 0000000..caf5dc8 --- /dev/null +++ b/src/services/database/bikes.rs @@ -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 for BikeStatus { + fn from(value: BikeStatusDB) -> Self { + match value { + BikeStatusDB::InService => BikeStatus::InService, + BikeStatusDB::OutOfService => BikeStatus::OutOfService, + } + } +} + +impl From 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, + pub key_quantity: i32, + pub drivetrain: Option, + pub battery: Option, + pub status: BikeStatusDB, +} + +impl From 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, 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 { + 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 { + 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(()) + } +} diff --git a/src/services/database/items.rs b/src/services/database/items.rs deleted file mode 100644 index f6b9229..0000000 --- a/src/services/database/items.rs +++ /dev/null @@ -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 for Item { - type Error = RepositoryError; - - fn try_from(value: ItemDB) -> Result { - 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, RepositoryError> { - query_as!(ItemDB, r#"SELECT * FROM items ORDER BY id"#) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(TryInto::try_into) - .collect() - } -} diff --git a/src/services/database/mod.rs b/src/services/database/mod.rs index 09d79e3..c815cf6 100644 --- a/src/services/database/mod.rs +++ b/src/services/database/mod.rs @@ -1,10 +1,12 @@ //! Postgres implementation of the repository traits, using sqlx. //! //! The `query!`/`query_as!` macros check the sql against a real database at compile -//! time: `dev-db` must be up and migrated, or the `.sqlx` offline data must be present -//! (`cargo sqlx prepare`). +//! time: the development database must be up and migrated, or the `.sqlx` offline +//! data must be present (`cargo sqlx prepare`). -mod items; +mod bikes; +mod reservations; +mod users; use async_trait::async_trait; use sqlx::{Pool, Postgres, postgres::PgPoolOptions}; diff --git a/src/services/database/oidc_states.rs b/src/services/database/oidc_states.rs new file mode 100644 index 0000000..a82563e --- /dev/null +++ b/src/services/database/oidc_states.rs @@ -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 for DBOidcStateData { + type Error = RepositoryError; + + fn try_from(value: whiskey::AuthorizeBackendData) -> Result { + Ok(DBOidcStateData { + key: value.csrf_token(), + data: serde_json::to_string(&value)?, + }) + } +} + +impl TryInto for DBOidcStateData { + type Error = RepositoryError; + + fn try_into(self) -> Result { + 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 { + 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(()) + } +} diff --git a/src/services/database/reservations.rs b/src/services/database/reservations.rs new file mode 100644 index 0000000..685ad4d --- /dev/null +++ b/src/services/database/reservations.rs @@ -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 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 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, + pub end_time: DateTime, + pub requester_id: i32, + pub telegram: String, + pub description: String, + pub status: ReservationStatusDB, + pub users: Vec, + pub bikes: Vec, +} + +impl From 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, 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, 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 { + 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 { + 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(()) + } +} diff --git a/src/services/database/users.rs b/src/services/database/users.rs new file mode 100644 index 0000000..e218c2e --- /dev/null +++ b/src/services/database/users.rs @@ -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, + pub firstname: String, + pub name: String, + pub email: String, + pub oidc_sub: String, + pub admin: bool, +} + +impl UserDB { + fn into_user(self, units: Vec) -> 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 + 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 { + 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 { + 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 { + 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 { + 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 { + 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(()) + } +} diff --git a/src/utils/config.rs b/src/utils/config.rs index 1dac1a1..63a8113 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -46,12 +46,12 @@ impl AppConfig { /// Loads the configuration, by decreasing priority: /// - `APP__SERVER__PORT=3000` style environment variables /// - `./config.yml` -/// - the file pointed by `$APP_CONFIG` (`/etc/app-template/config.yml` by default) +/// - the file pointed by `$APP_CONFIG` (`/etc/cargagep/config.yml` by default) pub fn get() -> &'static AppConfig { static CONFIG: OnceLock = OnceLock::new(); CONFIG.get_or_init(|| { let config_path = - std::env::var("APP_CONFIG").unwrap_or("/etc/app-template/config.yml".to_owned()); + std::env::var("APP_CONFIG").unwrap_or("/etc/cargagep/config.yml".to_owned()); let config_path = Path::new(&config_path); let raw = Config::builder() diff --git a/src/utils/whiskey.rs b/src/utils/whiskey.rs new file mode 100644 index 0000000..8a9e528 --- /dev/null +++ b/src/utils/whiskey.rs @@ -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 = 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 = 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 { + 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; + +pub type Client = openidconnect::Client< + WhiskeyClaims, + openidconnect::core::CoreAuthDisplay, + openidconnect::core::CoreGenderClaim, + openidconnect::core::CoreJweContentEncryptionAlgorithm, + openidconnect::core::CoreJsonWebKey, + openidconnect::core::CoreAuthPrompt, + openidconnect::StandardErrorResponse, + WhiskeyTokenResponse, + openidconnect::core::CoreTokenIntrospectionResponse, + openidconnect::core::CoreRevocableToken, + openidconnect::core::CoreRevocationErrorResponse, + openidconnect::EndpointSet, + openidconnect::EndpointNotSet, + openidconnect::EndpointNotSet, + openidconnect::EndpointNotSet, + openidconnect::EndpointMaybeSet, + openidconnect::EndpointMaybeSet, +>;