From 754d2ddfc40d34287a43286db19c49d5ced105d9 Mon Sep 17 00:00:00 2001 From: Antoine Pelletier Date: Mon, 24 Aug 2026 11:09:59 +0200 Subject: [PATCH] feat: add admin page --- README.md | 4 + .../20260823230000_reservation_free_unit.sql | 27 + db/schema.sql | 10 +- db/seed.sql | 40 +- frontend/src/components/AppHeader.vue | 10 +- frontend/src/components/DatePicker.vue | 4 +- frontend/src/components/TelegramInput.vue | 31 + frontend/src/components/UnitPicker.vue | 74 ++ .../src/components/admin/BikeCalendar.vue | 236 ++++ frontend/src/components/admin/BikeFleet.vue | 109 ++ .../src/components/admin/ReservationAdmin.vue | 119 ++ .../src/components/admin/ReservationCard.vue | 118 ++ frontend/src/lib/api.d.ts | 1015 ++++++++++------- frontend/src/locales/en.yml | 63 + frontend/src/locales/fr.yml | 63 + frontend/src/router/index.ts | 38 +- frontend/src/services/api/auth.ts | 28 +- frontend/src/services/api/bikes.ts | 27 +- frontend/src/services/api/client.ts | 4 + frontend/src/services/api/reservations.ts | 49 + frontend/src/utils/types.ts | 13 + frontend/src/views/AdminView.vue | 64 ++ frontend/src/views/ReservationView.vue | 46 +- src/api/auth.rs | 2 +- src/api/bikes.rs | 6 +- src/api/helpers.rs | 14 +- src/api/mod.rs | 7 - src/api/reservations.rs | 18 +- src/core/controller/mod.rs | 23 +- src/core/controller/reservations.rs | 6 +- src/core/models/reservation.rs | 66 +- src/services/database/reservations.rs | 68 +- 32 files changed, 1915 insertions(+), 487 deletions(-) create mode 100644 db/migrations/20260823230000_reservation_free_unit.sql create mode 100644 frontend/src/components/TelegramInput.vue create mode 100644 frontend/src/components/UnitPicker.vue create mode 100644 frontend/src/components/admin/BikeCalendar.vue create mode 100644 frontend/src/components/admin/BikeFleet.vue create mode 100644 frontend/src/components/admin/ReservationAdmin.vue create mode 100644 frontend/src/components/admin/ReservationCard.vue create mode 100644 frontend/src/services/api/reservations.ts diff --git a/README.md b/README.md index 85c7e6c..51ee89e 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ spot rather than rejected: an unknown unit must never break a login. In a debug build, `dev_users` from the configuration can be logged in through `POST /api/login` without going through the provider — see the login page. +`/reservations` needs a session and `/admin` needs an admin. The router guards are a +convenience only: every protected route answers 401 or 403 on its own, whatever the +frontend does. + ## Getting started ```bash diff --git a/db/migrations/20260823230000_reservation_free_unit.sql b/db/migrations/20260823230000_reservation_free_unit.sql new file mode 100644 index 0000000..24b01a7 --- /dev/null +++ b/db/migrations/20260823230000_reservation_free_unit.sql @@ -0,0 +1,27 @@ +-- migrate:up + +-- A reservation names either a unit we know (Whiskey group, `unit_id`) or a +-- free text the requester typed (`unit_label`). Typing a name must never create +-- a `units` row, so the two are exclusive rather than the second being a +-- fallback name for the first. +ALTER TABLE reservations ALTER COLUMN unit_id DROP NOT NULL; +ALTER TABLE reservations ADD COLUMN unit_label TEXT; + +ALTER TABLE reservations ADD CONSTRAINT reservations_unit_xor CHECK ( + (unit_id IS NULL) <> (unit_label IS NULL) +); + +-- A free label is a name, not an empty string +ALTER TABLE reservations ADD CONSTRAINT reservations_unit_label_not_blank CHECK ( + unit_label IS NULL OR btrim(unit_label) <> '' +); + +-- migrate:down + +ALTER TABLE reservations DROP CONSTRAINT reservations_unit_label_not_blank; +ALTER TABLE reservations DROP CONSTRAINT reservations_unit_xor; + +-- The rows that only had a free label have no unit to point at any more +DELETE FROM reservations WHERE unit_id IS NULL; +ALTER TABLE reservations DROP COLUMN unit_label; +ALTER TABLE reservations ALTER COLUMN unit_id SET NOT NULL; diff --git a/db/schema.sql b/db/schema.sql index 6288684..fec713d 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -102,9 +102,12 @@ CREATE TABLE public.reservations ( telegram text NOT NULL, description text DEFAULT ''::text NOT NULL, status public.reservation_status DEFAULT 'requested'::public.reservation_status NOT NULL, - unit_id integer NOT NULL, + unit_id integer, + unit_label text, 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)) + CONSTRAINT reservations_time_order CHECK ((end_time > start_time)), + CONSTRAINT reservations_unit_label_not_blank CHECK (((unit_label IS NULL) OR (btrim(unit_label) <> ''::text))), + CONSTRAINT reservations_unit_xor CHECK (((unit_id IS NULL) <> (unit_label IS NULL))) ); @@ -486,4 +489,5 @@ INSERT INTO public.schema_migrations (version) VALUES ('20260823153310'), ('20260823153320'), ('20260823170000'), - ('20260823210000'); + ('20260823210000'), + ('20260823230000'); diff --git a/db/seed.sql b/db/seed.sql index f348361..fe7073d 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -39,24 +39,52 @@ INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, batt (5, '5000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service') ON CONFLICT DO NOTHING; +-- Reservations across the current week, one per status, so the admin page and +-- the calendar always have something to show. Times are relative to `now()`. INSERT INTO public.reservations (id, unit_id, start_time, end_time, requester_id, telegram, "description", status) SELECT r.id, u.id, r.start_time, r.end_time, r.requester_id, r.telegram, r."description", r.status FROM (VALUES - (1, 'agepoly', '2026-09-01 08:00:00+02'::timestamptz, '2026-09-01 18:00:00+02'::timestamptz, - 1, '@alice_martin', 'Transport du matériel pour la rentrée', 'approved'::reservation_status), - (2, 'clic', '2026-09-05 09:00:00+02'::timestamptz, '2026-09-06 17:00:00+02'::timestamptz, - 3, '@chloe_favre', 'Déménagement du stock de la commission', 'requested'::reservation_status) + (1, 'agepoly', + date_trunc('day', now()) - interval '1 day' + interval '8 hours', + date_trunc('day', now()) + interval '1 day' + interval '18 hours', + 1, '@alice_martin', 'Transport du matériel pour la rentrée', + 'ongoing'::reservation_status), + (2, 'clic', + date_trunc('day', now()) + interval '1 day' + interval '9 hours', + date_trunc('day', now()) + interval '2 days' + interval '17 hours', + 3, '@chloe_favre', 'Déménagement du stock de la commission', + 'approved'::reservation_status), + (3, 'agepoly', + date_trunc('day', now()) + interval '3 days' + interval '10 hours', + date_trunc('day', now()) + interval '3 days' + interval '19 hours', + 2, '@bob_dupont', 'Livraison des boissons pour la soirée', + 'requested'::reservation_status), + (4, 'clic', + date_trunc('day', now()) + interval '4 days' + interval '7 hours', + date_trunc('day', now()) + interval '4 days' + interval '12 hours', + 3, '@chloe_favre', 'Récupération de matériel informatique', + 'requested'::reservation_status), + (5, 'agepoly', + date_trunc('day', now()) - interval '20 days' + interval '9 hours', + date_trunc('day', now()) - interval '19 days' + interval '18 hours', + 1, '@alice_martin', 'Ancienne sortie, archivée', + 'archived'::reservation_status), + (6, 'clic', + date_trunc('day', now()) + interval '6 days' + interval '14 hours', + date_trunc('day', now()) + interval '6 days' + interval '18 hours', + 2, '@bob_dupont', 'Annulée faute de conducteur', + 'cancelled'::reservation_status) ) AS r (id, unit_name, start_time, end_time, requester_id, telegram, "description", status) JOIN public.units u ON u."name" = r.unit_name ON CONFLICT DO NOTHING; INSERT INTO public.reservations_users (reservation_id, user_id) VALUES - (1, 1), (1, 2), (2, 3) + (1, 1), (1, 2), (2, 3), (3, 2), (3, 1), (4, 3), (5, 1), (6, 2) ON CONFLICT DO NOTHING; INSERT INTO public.reservations_bikes (reservation_id, bike_id) VALUES - (1, 1), (1, 2), (2, 1) + (1, 1), (1, 2), (2, 3), (3, 1), (3, 4), (4, 2), (5, 5), (6, 4) ON CONFLICT DO NOTHING; -- Keep the sequences in sync with the explicit ids inserted above diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 7c98690..496db76 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -1,5 +1,5 @@ + + diff --git a/frontend/src/components/UnitPicker.vue b/frontend/src/components/UnitPicker.vue new file mode 100644 index 0000000..7f83820 --- /dev/null +++ b/frontend/src/components/UnitPicker.vue @@ -0,0 +1,74 @@ + + + diff --git a/frontend/src/components/admin/BikeCalendar.vue b/frontend/src/components/admin/BikeCalendar.vue new file mode 100644 index 0000000..552005e --- /dev/null +++ b/frontend/src/components/admin/BikeCalendar.vue @@ -0,0 +1,236 @@ + + + diff --git a/frontend/src/components/admin/BikeFleet.vue b/frontend/src/components/admin/BikeFleet.vue new file mode 100644 index 0000000..95d9255 --- /dev/null +++ b/frontend/src/components/admin/BikeFleet.vue @@ -0,0 +1,109 @@ + + + diff --git a/frontend/src/components/admin/ReservationAdmin.vue b/frontend/src/components/admin/ReservationAdmin.vue new file mode 100644 index 0000000..052862c --- /dev/null +++ b/frontend/src/components/admin/ReservationAdmin.vue @@ -0,0 +1,119 @@ + + + diff --git a/frontend/src/components/admin/ReservationCard.vue b/frontend/src/components/admin/ReservationCard.vue new file mode 100644 index 0000000..b94794a --- /dev/null +++ b/frontend/src/components/admin/ReservationCard.vue @@ -0,0 +1,118 @@ + + + diff --git a/frontend/src/lib/api.d.ts b/frontend/src/lib/api.d.ts index 0fa44d0..bd8e134 100644 --- a/frontend/src/lib/api.d.ts +++ b/frontend/src/lib/api.d.ts @@ -4,394 +4,633 @@ */ export interface paths { - "/api/version": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get app version */ - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/me": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get the logged in user - * @description Answers `null` when nobody is logged in. - */ - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["User"] | null; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/logout": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Log the user out */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description no content */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description no content */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/whiskey/authorize": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Whiskey - Get the authorization url */ - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["GetAuthorizeResponse"]; - }; - }; - /** @description no content */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/whiskey/callback": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Whiskey - Complete the login */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["PostCallbackParams"]; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["User"]; - }; - }; - /** @description no content */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description no content */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/login": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Log in as a dev user - * @description Debug builds only. Takes the email of one of the `dev_users` of the configuration, creates the row if needed, and opens a session. - */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["LoginDevForm"]; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["User"]; - }; - }; - /** @description no content */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/login/dev-users": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * List the dev users - * @description Debug builds only. - */ - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DevUser"][]; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/bikes": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get the fleet */ - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Bike"][]; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + '/api/version': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get app version */ + get: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': string + } + } + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/api/me': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * Get the logged in user + * @description Answers `null` when nobody is logged in. + */ + get: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['User'] | null + } + } + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/api/logout': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** Log the user out */ + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description no content */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description no content */ + 401: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/api/whiskey/authorize': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Whiskey - Get the authorization url */ + get: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['GetAuthorizeResponse'] + } + } + /** @description no content */ + 400: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/api/whiskey/callback': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** Whiskey - Complete the login */ + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['PostCallbackParams'] + } + } + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['User'] + } + } + /** @description no content */ + 400: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description no content */ + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/api/login': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** + * Log in as a dev user + * @description Debug builds only. Takes the email of one of the `dev_users` of the configuration, creates the row if needed, and opens a session. + */ + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['LoginDevForm'] + } + } + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['User'] + } + } + /** @description no content */ + 404: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/api/login/dev-users': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * List the dev users + * @description Debug builds only. + */ + get: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['DevUser'][] + } + } + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/api/bikes': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get the fleet */ + get: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['Bike'][] + } + } + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/api/bikes/{id}/status': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + /** Put a bike in or out of service */ + put: { + parameters: { + query?: never + header?: never + path: { + id: number + } + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['SetStatusForm'] + } + } + responses: { + /** @description no content */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Unauthenticated - a session is required */ + 401: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Forbidden - the user must be an admin */ + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description no content */ + 404: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/api/reservations': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get every reservation */ + get: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['Reservation'][] + } + } + /** @description Unauthenticated - a session is required */ + 401: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Forbidden - the user must be an admin */ + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/api/reservations/{id}/status': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + /** + * Move a reservation through its state machine + * @description Refuses a transition the state machine does not allow, with a 409. + */ + put: { + parameters: { + query?: never + header?: never + path: { + id: number + } + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['SetStatusForm2'] + } + } + responses: { + /** @description no content */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Unauthenticated - a session is required */ + 401: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Forbidden - the user must be part of the unit, or an admin when the reservation names no known unit */ + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description no content */ + 404: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description no content */ + 409: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } } -export type webhooks = Record; +export type webhooks = Record export interface components { - schemas: { - Bike: { - battery?: string | null; - drivetrain?: string | null; - /** Format: int32 */ - id: number; - key_number?: string | null; - /** Format: int32 */ - key_quantity: number; - name: string; - status: components["schemas"]["BikeStatus"]; - }; - /** @enum {string} */ - BikeStatus: "in_service" | "out_of_service"; - DevUser: { - admin: boolean; - email: string; - firstname: string; - name: string; - }; - GetAuthorizeResponse: { - redirect_to: string; - }; - LoginDevForm: { - /** @description Email of one of the `dev_users` of the configuration */ - user: string; - }; - PostCallbackParams: { - code: string; - state: string; - }; - Unit: { - /** Format: int32 */ - id: number; - /** @description The Whiskey group name */ - name: string; - }; - User: { - admin: boolean; - email: string; - external_id?: string | null; - firstname: string; - /** Format: int32 */ - id: number; - name: string; - oidc_sub: string; - units: components["schemas"]["Unit"][]; - }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; + schemas: { + Bike: { + battery?: string | null + drivetrain?: string | null + /** Format: int32 */ + id: number + key_number?: string | null + /** Format: int32 */ + key_quantity: number + name: string + status: components['schemas']['BikeStatus'] + } + /** @enum {string} */ + BikeStatus: 'in_service' | 'out_of_service' + DevUser: { + admin: boolean + email: string + firstname: string + name: string + } + GetAuthorizeResponse: { + redirect_to: string + } + IdPath: { + /** Format: int32 */ + id: number + } + LoginDevForm: { + /** @description Email of one of the `dev_users` of the configuration */ + user: string + } + PostCallbackParams: { + code: string + state: string + } + Reservation: { + description: string + bikes: number[] + /** Format: date-time */ + end_time: string + /** Format: int32 */ + id: number + /** Format: int32 */ + requester: number + /** Format: date-time */ + start_time: string + status: components['schemas']['ReservationStatus'] + telegram: string + /** @description The one unit the bikes are lent to */ + unit: components['schemas']['ReservationUnit'] + users: components['schemas']['UserSummary'][] + } + /** @enum {string} */ + ReservationStatus: 'requested' | 'refused' | 'approved' | 'cancelled' | 'ongoing' | 'archived' + /** + * @description The unit a reservation is filed for. + * + * Either one we know — a Whiskey group, with its row — or a plain name the + * requester typed. Typing a name must never create a unit, so the two are + * exclusive by construction rather than "a name that may or may not resolve". + */ + ReservationUnit: + | { + /** @constant */ + kind: 'known' + unit: components['schemas']['Unit'] + } + | { + /** @constant */ + kind: 'free' + name: string + } + SetStatusForm: { + status: components['schemas']['BikeStatus'] + } + SetStatusForm2: { + status: components['schemas']['ReservationStatus'] + } + Unit: { + /** Format: int32 */ + id: number + /** @description The Whiskey group name */ + name: string + } + User: { + admin: boolean + email: string + external_id?: string | null + firstname: string + /** Format: int32 */ + id: number + name: string + oidc_sub: string + units: components['schemas']['Unit'][] + } + /** + * @description A user as they appear inside another object (a reservation, ...): enough to + * show who they are, without dragging their units along. + */ + UserSummary: { + email: string + firstname: string + /** Format: int32 */ + id: number + name: string + } + } + responses: never + parameters: never + requestBodies: never + headers: never + pathItems: never } -export type $defs = Record; -export type operations = Record; +export type $defs = Record +export type operations = Record diff --git a/frontend/src/locales/en.yml b/frontend/src/locales/en.yml index eb29e67..5143228 100644 --- a/frontend/src/locales/en.yml +++ b/frontend/src/locales/en.yml @@ -2,6 +2,7 @@ locale: en app: title: Cargobikes header: + admin: Admin logout: Log out logout-error: Unable to log out. reserve: Book @@ -36,6 +37,10 @@ reservation: remove-email: Remove this address submit: Send the request reset: Reset + association-placeholder: Pick or type the association + association-other: Other association… + telegram-placeholder: username + error-too-far: A reservation can be made at most 1 month in advance. error-required: This field is required. error-datetime-required: Pick a date and a time. error-end-before-start: The end must be after the start. @@ -57,3 +62,61 @@ login: calendar: title: Calendar todo: This page is not built yet. +admin: + title: Reservation administration + intro: Approve, refuse and follow the reservations, and see the planning of each cargobike. + refresh: Refresh + updated-at: 'Last loaded: {time}' + bikes: + title: Fleet management + intro: Deactivate a bike so that it can no longer be picked in the form. + activate: Activate + deactivate: Deactivate + empty: No cargobike in the fleet. + load-error: Unable to load the cargobikes. + error: The status change failed. + status: + in_service: In service + out_of_service: Out of service + in_use: In use + reservations: + title: Reservations + intro: Requests are waiting for a decision; approved reservations show up below. + pending: Pending requests + pending-empty: No reservation request for now. + active: Reservations (ongoing and upcoming) + active-empty: No ongoing or upcoming reservation. + show-archived: 'Show archived requests ({n})' + hide-archived: Hide archived requests + archived-empty: No archived request. + period: Period + bikes: Cargobike(s) + telegram: Telegram + people: People + reason: Reason + load-error: Unable to load the reservations. + error: The status change failed. + status: + requested: Pending + refused: Refused + approved: Approved + cancelled: Cancelled + ongoing: Ongoing + archived: Archived + action: + approved: Approve + refused: Refuse + cancelled: Cancel + ongoing: Start + archived: Archive + calendar: + title: Calendar per cargobike + intro: Approved and ongoing reservations are shown in the calendar. + bike: Cargobike + all-bikes: All + previous: Week + next: Week + today: Today + hour: H + no-bike: No cargobike to show. + legend: One bar per booked cargobike, split by day. diff --git a/frontend/src/locales/fr.yml b/frontend/src/locales/fr.yml index 21ffda2..c4ae196 100644 --- a/frontend/src/locales/fr.yml +++ b/frontend/src/locales/fr.yml @@ -2,6 +2,7 @@ locale: fr app: title: Cargobikes header: + admin: Admin logout: Se déconnecter logout-error: Impossible de se déconnecter. reserve: Réserver @@ -37,6 +38,10 @@ reservation: remove-email: Retirer cette adresse submit: Envoyer la demande reset: Réinitialiser + association-placeholder: Choisissez ou saisissez l'association + association-other: Autre association… + telegram-placeholder: username + error-too-far: Une réservation se fait au maximum 1 mois à l'avance. error-required: Ce champ est obligatoire. error-datetime-required: Choisissez une date et une heure. error-end-before-start: La fin doit être après le début. @@ -58,3 +63,61 @@ login: calendar: title: Calendrier todo: Cette page n'est pas encore construite. +admin: + title: Administration des réservations + intro: Validez, refusez et suivez les réservations, et visualisez les plannings par cargobike. + refresh: Rafraîchir + updated-at: 'Dernier chargement : {time}' + bikes: + title: Gestion des vélos + intro: Désactivez un vélo pour qu'il ne soit plus sélectionnable dans le formulaire. + activate: Activer + deactivate: Désactiver + empty: Aucun cargobike dans la flotte. + load-error: Impossible de charger les cargobikes. + error: Le changement de statut a échoué. + status: + in_service: En service + out_of_service: Hors service + in_use: En usage + reservations: + title: Réservations + intro: Les demandes attendent une décision ; les réservations validées apparaissent ensuite. + pending: Demandes en attente + pending-empty: Aucune demande de réservation pour le moment. + active: Réservations (en cours et à venir) + active-empty: Aucune réservation en cours ou à venir. + show-archived: 'Voir les demandes archivées ({n})' + hide-archived: Masquer les demandes archivées + archived-empty: Aucune demande archivée. + period: Période + bikes: Cargobike(s) + telegram: Telegram + people: Personnes + reason: Raison + load-error: Impossible de charger les réservations. + error: Le changement de statut a échoué. + status: + requested: En attente + refused: Refusée + approved: Validée + cancelled: Annulée + ongoing: En cours + archived: Archivée + action: + approved: Valider + refused: Refuser + cancelled: Annuler + ongoing: Démarrer + archived: Archiver + calendar: + title: Calendrier par cargobike + intro: Les réservations validées et en cours sont affichées dans le calendrier. + bike: Cargobike + all-bikes: Tous + previous: Sem. + next: Sem. + today: Aujourd'hui + hour: H + no-bike: Aucun cargobike à afficher. + legend: Une barre par cargobike réservé, découpée par jour. diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 85ab506..bbfc7ac 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,19 +1,55 @@ import { createRouter, createWebHistory } from 'vue-router' +import { ensureSession } from '@/services/api/auth' import LoginView from '@/views/LoginView.vue' import ReservationView from '@/views/ReservationView.vue' import CalendarView from '@/views/CalendarView.vue' +import AdminView from '@/views/AdminView.vue' import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue' +declare module 'vue-router' { + interface RouteMeta { + /** The route needs a session */ + requiresAuth?: boolean + /** ... and the session must belong to an admin */ + requiresAdmin?: boolean + } +} + const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { name: 'login', path: '/', component: LoginView }, - { name: 'reservations', path: '/reservations', component: ReservationView }, + { + name: 'reservations', + path: '/reservations', + component: ReservationView, + meta: { requiresAuth: true }, + }, { name: 'calendar', path: '/calendar', component: CalendarView }, + { + name: 'admin', + path: '/admin', + component: AdminView, + meta: { requiresAuth: true, requiresAdmin: true }, + }, // Registered as the OIDC redirect uri, see `server.base_url` in config.yml { name: 'whiskey-callback', path: '/whiskey/callback', component: WhiskeyCallbackView }, ], }) +/** + * Keeps anonymous visitors off the pages that need a session. This is a + * convenience, not the security boundary: every protected route answers 401 or + * 403 on its own, whatever the frontend does. + */ +router.beforeEach(async (to) => { + if (!to.meta.requiresAuth) return true + + const user = await ensureSession().catch(() => null) + if (!user) return { name: 'login' } + if (to.meta.requiresAdmin && !user.admin) return { name: 'reservations' } + return true +}) + export default router diff --git a/frontend/src/services/api/auth.ts b/frontend/src/services/api/auth.ts index 15a3711..17d709a 100644 --- a/frontend/src/services/api/auth.ts +++ b/frontend/src/services/api/auth.ts @@ -10,7 +10,7 @@ import { HttpStatus } from 'http-status-ts' import { computed } from 'vue' import type { User } from '@/utils/types' -import { getClient } from './client' +import { getClient, getQueryClient } from './client' import { SESSION_KEY } from './keys' export { SESSION_KEY } @@ -19,18 +19,30 @@ export { SESSION_KEY } * `/api/me` is a probe, not a protected route: it answers 200 with `null` when * nobody is logged in, so a page load never looks like an error. */ +async function fetchSession() { + const { data, response } = await getClient().GET('/api/me') + if (response.status !== HttpStatus.OK) { + throw new Error(`Unexpected status code received: ${response.status}`) + } + return data ?? null +} + +/** + * The session as the router guards see it. Reads through the same cache the + * components use, so a navigation costs no extra call. + */ +export async function ensureSession() { + const queryClient = getQueryClient() + if (!queryClient) return fetchSession() + return queryClient.ensureQueryData({ queryKey: SESSION_KEY, queryFn: fetchSession }) +} + export function useSession() { const query = useQuery({ queryKey: SESSION_KEY, staleTime: Infinity, retry: false, - queryFn: async () => { - const { data, response } = await getClient().GET('/api/me') - if (response.status !== HttpStatus.OK) { - throw new Error(`Unexpected status code received: ${response.status}`) - } - return data ?? null - }, + queryFn: fetchSession, }) return { diff --git a/frontend/src/services/api/bikes.ts b/frontend/src/services/api/bikes.ts index fa2462f..5a6ec7f 100644 --- a/frontend/src/services/api/bikes.ts +++ b/frontend/src/services/api/bikes.ts @@ -2,14 +2,17 @@ * The fleet. One file per domain area, exposing vue-query hooks: views never * call `fetch` themselves. */ -import { useQuery } from '@tanstack/vue-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { HttpStatus } from 'http-status-ts' +import type { Bike, BikeStatus } from '@/utils/types' import { getClient } from './client' +export const BIKES_KEY = ['bikes'] + export function useBikes() { return useQuery({ - queryKey: ['bikes'], + queryKey: BIKES_KEY, staleTime: 60 * 1000, queryFn: async () => { const { data, response } = await getClient().GET('/api/bikes') @@ -20,3 +23,23 @@ export function useBikes() { }, }) } + +/** Takes a bike in or out of the fleet. Admin only, the backend enforces it. */ +export function useSetBikeStatus() { + const queryClient = useQueryClient() + return useMutation({ + retry: 0, + mutationFn: async ({ id, status }: { id: number; status: BikeStatus }) => { + await getClient().PUT('/api/bikes/{id}/status', { + params: { path: { id } }, + body: { status }, + }) + return { id, status } + }, + onSuccess: ({ id, status }) => { + queryClient.setQueryData(BIKES_KEY, (bikes) => + bikes?.map((bike) => (bike.id === id ? { ...bike, status } : bike)), + ) + }, + }) +} diff --git a/frontend/src/services/api/client.ts b/frontend/src/services/api/client.ts index 15278e2..df72c68 100644 --- a/frontend/src/services/api/client.ts +++ b/frontend/src/services/api/client.ts @@ -14,6 +14,10 @@ export function setQueryClient(client: QueryClient) { queryClient = client } +export function getQueryClient() { + return queryClient +} + // Types come from src/lib/api.d.ts, generated from the backend: `npm run openapi` const client = createClient({ credentials: 'same-origin', diff --git a/frontend/src/services/api/reservations.ts b/frontend/src/services/api/reservations.ts new file mode 100644 index 0000000..cf7c0c0 --- /dev/null +++ b/frontend/src/services/api/reservations.ts @@ -0,0 +1,49 @@ +/** + * Reservations, from the administration side. Reading the whole list is an + * admin action, so these hooks are only ever mounted on /admin. + */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import { HttpStatus } from 'http-status-ts' + +import type { Reservation, ReservationStatus } from '@/utils/types' +import { getClient } from './client' + +export const RESERVATIONS_KEY = ['reservations'] + +export function useReservations() { + return useQuery({ + queryKey: RESERVATIONS_KEY, + staleTime: 30 * 1000, + queryFn: async () => { + const { data, response } = await getClient().GET('/api/reservations') + if (response.status !== HttpStatus.OK) { + throw new Error(`Unexpected status code received: ${response.status}`) + } + return data ?? [] + }, + }) +} + +/** + * The backend re-checks the state machine and answers 409 on a transition it + * does not allow, so the buttons only have to offer the plausible ones. + */ +export function useSetReservationStatus() { + const queryClient = useQueryClient() + return useMutation({ + retry: 0, + mutationFn: async ({ id, status }: { id: number; status: ReservationStatus }) => { + await getClient().PUT('/api/reservations/{id}/status', { + params: { path: { id } }, + body: { status }, + }) + return { id, status } + }, + onSuccess: ({ id, status }) => { + // Patch the cache so the card moves section immediately + queryClient.setQueryData(RESERVATIONS_KEY, (reservations) => + reservations?.map((r) => (r.id === id ? { ...r, status } : r)), + ) + }, + }) +} diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts index 2275ce0..a8a3840 100644 --- a/frontend/src/utils/types.ts +++ b/frontend/src/utils/types.ts @@ -23,3 +23,16 @@ export type Bike = components['schemas']['Bike'] export type BikeStatus = components['schemas']['BikeStatus'] export type Unit = components['schemas']['Unit'] export type User = components['schemas']['User'] +export type Reservation = components['schemas']['Reservation'] +export type ReservationStatus = components['schemas']['ReservationStatus'] +export type UserSummary = components['schemas']['UserSummary'] +export type ReservationUnit = components['schemas']['ReservationUnit'] + +/** + * What to display for a reservation's unit: the name of the unit it points at, + * or the name the requester typed. One place, so no view has to know which of + * the two shapes it is holding. + */ +export function unitLabel(unit: ReservationUnit): string { + return unit.kind === 'known' ? unit.unit.name : unit.name +} diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index e69de29..a20c4e2 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/views/ReservationView.vue b/frontend/src/views/ReservationView.vue index 0593f41..1eebe7e 100644 --- a/frontend/src/views/ReservationView.vue +++ b/frontend/src/views/ReservationView.vue @@ -6,6 +6,8 @@ import { getLocalTimeZone, today, type DateValue } from '@internationalized/date import { toast } from 'vue-sonner' import DatePicker from '@/components/DatePicker.vue' +import TelegramInput from '@/components/TelegramInput.vue' +import UnitPicker, { type UnitChoice } from '@/components/UnitPicker.vue' import TimePicker from '@/components/TimePicker.vue' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' @@ -14,6 +16,7 @@ import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { Textarea } from '@/components/ui/textarea' import { useBikes } from '@/services/api/bikes' +import { useSession } from '@/services/api/auth' import type { Bike } from '@/utils/types' const { t } = useI18n() @@ -23,7 +26,7 @@ const TELEGRAM_RE = /^@[A-Za-z][A-Za-z0-9_]{4,31}$/ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ type Form = { - association: string + association: UnitChoice | undefined reason: string startTime: string | undefined endTime: string | undefined @@ -34,7 +37,7 @@ type Form = { function emptyForm(): Form { return { - association: '', + association: undefined, reason: '', startTime: undefined, endTime: undefined, @@ -52,6 +55,11 @@ const errors = reactive>({}) const submitted = ref(false) const minDate = today(getLocalTimeZone()) +/** Bookings open one month ahead, no further */ +const maxDate = minDate.add({ months: 1 }) + +const { user } = useSession() +const units = computed(() => user.value?.units ?? []) function toDate(date: DateValue | undefined, time: string | undefined): Date | null { if (!date || !time) return null @@ -97,15 +105,27 @@ function removeEmail(index: number) { function validate(): boolean { Object.keys(errors).forEach((key) => delete errors[key]) - if (!form.association.trim()) errors.association = t('reservation.error-required') + if (!form.association) errors.association = t('reservation.error-required') if (!form.reason.trim()) errors.reason = t('reservation.error-required') if (!start.value) errors.start = t('reservation.error-datetime-required') if (!end.value) errors.end = t('reservation.error-datetime-required') + // Strictly after: a reservation of zero length is not one if (start.value && end.value && end.value <= start.value) { errors.end = t('reservation.error-end-before-start') } + // The pickers already refuse these dates; re-checked in case the model was + // filled another way + if (startDate.value && startDate.value.compare(maxDate) > 0) { + errors.start = t('reservation.error-too-far') + } + if (endDate.value && endDate.value.compare(maxDate) > 0) { + errors.end = t('reservation.error-too-far') + } if (form.bikes.length === 0) errors.bikes = t('reservation.error-no-bike') - if (!TELEGRAM_RE.test(form.telegram)) errors.telegram = t('reservation.error-telegram') + if (!form.telegram) errors.telegram = t('reservation.error-required') + else if (!TELEGRAM_RE.test(`@${form.telegram}`)) { + errors.telegram = t('reservation.error-telegram') + } const emails = form.emails.map((email) => email.trim()).filter(Boolean) if (emails.length === 0) errors.emails = t('reservation.error-required') @@ -160,11 +180,11 @@ function submit() {
-

{{ errors.association }} @@ -192,6 +212,7 @@ function submit() { id="start-date" v-model="startDate" :min-value="minDate" + :max-value="maxDate" :invalid="!!errors.start" /> @@ -207,6 +228,7 @@ function submit() { id="end-date" v-model="endDate" :min-value="startDate ?? minDate" + :max-value="maxDate" :invalid="!!errors.end" /> @@ -273,13 +295,7 @@ function submit() {

- +

{{ errors.telegram }}

diff --git a/src/api/auth.rs b/src/api/auth.rs index 83bd23e..50c1857 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -87,7 +87,7 @@ async fn logout( } fn logout_docs(op: TransformOperation) -> TransformOperation { - op.tag("Auth").summary("Log the user out").response::<401, ()>() + op.tag("Auth").summary("Log the user out") } #[derive(Debug, JsonSchema, Serialize)] diff --git a/src/api/bikes.rs b/src/api/bikes.rs index fcc71a3..2cc4dae 100644 --- a/src/api/bikes.rs +++ b/src/api/bikes.rs @@ -13,10 +13,10 @@ use schemars::JsonSchema; use serde::Deserialize; use crate::{ - api::helpers::{admin, admin_desc, unexpected_error}, + api::helpers::{IdPath, admin, admin_desc, unexpected_error}, core::{ controller::{AnonAppController, AppController}, - models::bike::{Bike, BikeId, BikeStatus}, + models::bike::{Bike, BikeStatus}, }, }; @@ -36,7 +36,7 @@ struct SetStatusForm { #[axum::debug_handler] async fn set_status( ac: AppController, - Path(id): Path, + Path(IdPath { id }): Path, Json(SetStatusForm { status }): Json, ) -> Result<(), (StatusCode, String)> { match admin(ac)?.set_bike_status(id, status).await { diff --git a/src/api/helpers.rs b/src/api/helpers.rs index c09aa73..f0d4270 100644 --- a/src/api/helpers.rs +++ b/src/api/helpers.rs @@ -1,4 +1,6 @@ use aide::transform::TransformResponse; +use schemars::JsonSchema; +use serde::Deserialize; use axum::http::StatusCode; use tracing::error; @@ -7,11 +9,9 @@ use crate::core::{ models::unit::UnitId, }; -/// Narrows a session down to "member of this unit", or 403. -/// `manager(ac, unit)?` in a handler is the whole authorization check. pub fn manager( ac: AppController, - unit: UnitId, + unit: Option, ) -> Result { ac.try_into_manager(unit).map_err(|_| { ( @@ -22,7 +22,7 @@ pub fn manager( } pub fn manager_desc(op: TransformResponse<'_, T>) -> TransformResponse<'_, T> { - op.description("Forbidden - the user must be part of the unit") + op.description("Forbidden - the user must be part of the unit, or an admin when the reservation names no known unit") } /// Narrows a session down to "admin", or 403 @@ -39,7 +39,6 @@ pub fn admin_desc(op: TransformResponse<'_, T>) -> TransformResponse<'_, T> { op.description("Forbidden - the user must be an admin") } -/// Last resort branch of a handler `match`: logs the error and answers 500 pub fn unexpected_error(fn_name: &str, err: ControllerError) -> Result { error!("[HANDLER] {fn_name}: Unexpected error: {err:?}"); Err(( @@ -54,3 +53,8 @@ pub fn desc( ) -> impl FnOnce(TransformResponse<'_, T>) -> TransformResponse<'_, T> { |op| op.description(description) } + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct IdPath { + pub id: i32, +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 3db8526..26943c8 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -43,8 +43,6 @@ pub fn get_router(aac: AnonAppController) -> Router { let config = utils::config::get(); - // Sessions live in memory: everybody is logged out when the backend - // restarts. Swap the store for a persistent one if that becomes a problem. let session_layer = SessionManagerLayer::new(MemoryStore::default()) // Over plain http in development the cookie cannot be `Secure` .with_secure(config.get_base_url().starts_with("https://")) @@ -64,7 +62,6 @@ pub fn get_router(aac: AnonAppController) -> Router { |op| op.tag("misc").summary("Get app version"), ), ) - // `auth` carries its own `/api/...` paths, so it is merged, not nested .merge(auth::routes()) .nest_api_service("/api/bikes", bikes::routes()) .nest_api_service("/api/reservations", reservations::routes()) @@ -91,8 +88,6 @@ where } } -/// Lets a handler take an `AppController`, which requires a session: asking for -/// it *is* the authentication check. impl FromRequestParts for AppController where S: Send + Sync, @@ -118,8 +113,6 @@ where } } -// The controllers are not part of the request/response bodies, but asking for -// an `AppController` documents the 401 and the cookie requirement. impl OperationOutput for AnonAppController { type Inner = Self; } diff --git a/src/api/reservations.rs b/src/api/reservations.rs index 977f2dd..e1882b0 100644 --- a/src/api/reservations.rs +++ b/src/api/reservations.rs @@ -6,7 +6,10 @@ //! controller down. use aide::{ - axum::{ApiRouter, routing::get_with}, + axum::{ + ApiRouter, + routing::{get_with, put_with}, + }, transform::TransformOperation, }; use axum::{ @@ -18,20 +21,17 @@ use schemars::JsonSchema; use serde::Deserialize; use crate::{ - api::helpers::{admin, admin_desc, manager, manager_desc, unexpected_error}, + api::helpers::{IdPath, admin, admin_desc, manager, manager_desc, unexpected_error}, core::{ controller::{AppController, ControllerError, reservations::ReservationsControllerError}, - models::reservation::{Reservation, ReservationId, ReservationStatus}, + models::reservation::{Reservation, ReservationStatus}, }, }; pub fn routes() -> ApiRouter { ApiRouter::new() .api_route("/", get_with(get_reservations, get_reservations_docs)) - .api_route( - "/{id}/status", - aide::axum::routing::put_with(set_status, set_status_docs), - ) + .api_route("/{id}/status", put_with(set_status, set_status_docs)) } #[axum::debug_handler] @@ -58,7 +58,7 @@ struct SetStatusForm { #[axum::debug_handler] async fn set_status( ac: AppController, - Path(id): Path, + Path(IdPath { id }): Path, Json(SetStatusForm { status }): Json, ) -> Result<(), (StatusCode, String)> { // The unit is not in the body: it is the reservation's own @@ -70,7 +70,7 @@ async fn set_status( Err(err) => return unexpected_error("set_status", err), }; - match manager(ac, reservation.unit.id)? + match manager(ac, reservation.unit.scope())? .set_reservation_status(id, status) .await { diff --git a/src/core/controller/mod.rs b/src/core/controller/mod.rs index c79449a..c4d77e6 100644 --- a/src/core/controller/mod.rs +++ b/src/core/controller/mod.rs @@ -71,13 +71,21 @@ impl AppController { &self.user } - /// An admin manages every unit: refusing them here would only produce - /// surprising 403s on routes they are otherwise allowed to use. - pub fn try_into_manager(self, unit: UnitId) -> Result { - if self.user.admin || self.user.units.iter().any(|u| u.id == unit) { + pub fn try_into_manager( + self, + unit: Option, + ) -> Result { + let allowed = match unit { + Some(unit) => self.user.admin || self.user.units.iter().any(|u| u.id == unit), + None => self.user.admin, + }; + if allowed { Ok(ManagerAppController { inner: self, unit }) } else { - Err(ControllerError::ManagerAuthorizationError(unit)) + match unit { + Some(unit) => Err(ControllerError::ManagerAuthorizationError(unit)), + None => Err(ControllerError::AdminAuthorizationError), + } } } @@ -90,11 +98,10 @@ impl AppController { } } -/// A member of `unit`, acting for that unit #[derive(Clone)] pub struct ManagerAppController { inner: AppController, - pub(crate) unit: UnitId, + pub(crate) unit: Option, } impl Deref for ManagerAppController { @@ -116,7 +123,7 @@ impl Deref for AdminAppController { } impl AdminAppController { - pub fn into_manager(self, unit: UnitId) -> ManagerAppController { + pub fn into_manager(self, unit: Option) -> ManagerAppController { ManagerAppController { inner: self.inner, unit, diff --git a/src/core/controller/reservations.rs b/src/core/controller/reservations.rs index 111630c..19882a9 100644 --- a/src/core/controller/reservations.rs +++ b/src/core/controller/reservations.rs @@ -67,7 +67,7 @@ impl ManagerAppController { } let current = self.db.get_reservation(reservation.id).await?; - if current.unit.id != self.unit { + if current.unit.scope() != self.unit { return Err(ControllerError::ImmutableUnitModificationError); } if current.status.is_final() { @@ -86,7 +86,7 @@ impl ManagerAppController { status: ReservationStatus, ) -> Result<(), ControllerError> { let current = self.db.get_reservation(id).await?; - if current.unit.id != self.unit { + if current.unit.scope() != self.unit { return Err(ControllerError::ImmutableUnitModificationError); } let current = current.status; @@ -103,7 +103,7 @@ impl ManagerAppController { } pub async fn delete_reservation(&self, id: ReservationId) -> Result<(), ControllerError> { - if self.db.get_reservation(id).await?.unit.id != self.unit { + if self.db.get_reservation(id).await?.unit.scope() != self.unit { return Err(ControllerError::ImmutableUnitModificationError); } self.db.delete_reservation(id).await.map_err(Into::into) diff --git a/src/core/models/reservation.rs b/src/core/models/reservation.rs index 843308e..9a1645b 100644 --- a/src/core/models/reservation.rs +++ b/src/core/models/reservation.rs @@ -43,10 +43,63 @@ impl ReservationStatus { } } + +/// The unit a reservation is filed for. +/// +/// Either one we know — a Whiskey group, with its row — or a plain name the +/// requester typed. Typing a name must never create a unit, so the two are +/// exclusive by construction rather than "a name that may or may not resolve". +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ReservationUnit { + /// Picked from the units the requester belongs to + Known { unit: Unit }, + /// Typed by hand, stored on the reservation and nowhere else + Free { name: String }, +} + +impl ReservationUnit { + /// What to show: the unit's name, or the typed one + pub fn label(&self) -> &str { + match self { + ReservationUnit::Known { unit } => &unit.name, + ReservationUnit::Free { name } => name, + } + } + + /// The scope somebody must manage to act on this reservation. `None` for a + /// typed name: nobody is the manager of a unit we do not know, so only an + /// admin qualifies. + pub fn scope(&self) -> Option { + match self { + ReservationUnit::Known { unit } => Some(unit.id), + ReservationUnit::Free { .. } => None, + } + } +} + +/// The same choice, as the client sends it: only the id travels for a known unit. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum NewReservationUnit { + Known { id: UnitId }, + Free { name: String }, +} + +impl NewReservationUnit { + pub fn is_valid(&self) -> bool { + match self { + NewReservationUnit::Known { .. } => true, + NewReservationUnit::Free { name } => !name.trim().is_empty(), + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] pub struct Reservation { pub id: ReservationId, - pub unit: Unit, + /// The one unit the bikes are lent to + pub unit: ReservationUnit, pub start_time: DateTime, pub end_time: DateTime, pub requester: UserId, @@ -59,7 +112,7 @@ pub struct Reservation { #[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] pub struct NewReservation { - pub unit: UnitId, + pub unit: NewReservationUnit, pub start_time: DateTime, pub end_time: DateTime, pub users: Vec, @@ -71,7 +124,7 @@ pub struct NewReservation { #[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] pub struct ReservationEdit { pub id: ReservationId, - pub unit: UnitId, + pub unit: NewReservationUnit, pub start_time: DateTime, pub end_time: DateTime, pub users: Vec, @@ -82,12 +135,15 @@ pub struct ReservationEdit { impl NewReservation { pub fn is_valid(&self) -> bool { - self.end_time > self.start_time && !self.bikes.is_empty() + self.unit.is_valid() && self.end_time > self.start_time && !self.bikes.is_empty() } } impl ReservationEdit { pub fn is_valid(&self) -> bool { - self.end_time > self.start_time && !self.bikes.is_empty() && !self.users.is_empty() + self.unit.is_valid() + && self.end_time > self.start_time + && !self.bikes.is_empty() + && !self.users.is_empty() } } diff --git a/src/services/database/reservations.rs b/src/services/database/reservations.rs index 6b08118..4048527 100644 --- a/src/services/database/reservations.rs +++ b/src/services/database/reservations.rs @@ -12,7 +12,8 @@ use crate::{ core::{ models::{ reservation::{ - NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus, + NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId, + ReservationStatus, ReservationUnit, }, unit::{Unit, UnitId}, user::UserId, @@ -61,8 +62,9 @@ impl From for ReservationStatusDB { struct ReservationDB { pub id: i32, - pub unit_id: i32, - pub unit_name: String, + pub unit_id: Option, + pub unit_name: Option, + pub unit_label: Option, pub start_time: DateTime, pub end_time: DateTime, pub requester_id: i32, @@ -79,9 +81,17 @@ impl TryFrom for Reservation { fn try_from(value: ReservationDB) -> Result { Ok(Reservation { id: value.id, - unit: Unit { - id: value.unit_id, - name: value.unit_name, + unit: match (value.unit_id, value.unit_name, value.unit_label) { + (Some(id), Some(name), None) => ReservationUnit::Known { + unit: Unit { id, name }, + }, + (None, None, Some(name)) => ReservationUnit::Free { name }, + // The `reservations_unit_xor` check makes this unreachable + other => { + return Err(RepositoryError::TypeConversion(format!( + "reservation with an inconsistent unit: {other:?}" + ))); + } }, start_time: value.start_time, end_time: value.end_time, @@ -136,6 +146,15 @@ impl SqlxDatabase { } } +/// The two exclusive columns behind `NewReservationUnit`: exactly one is `Some`, +/// which is what the `reservations_unit_xor` check enforces. +fn split_unit(unit: &NewReservationUnit) -> (Option, Option) { + match unit { + NewReservationUnit::Known { id } => (Some(*id), None), + NewReservationUnit::Free { name } => (None, Some(name.trim().to_owned())), + } +} + #[async_trait] impl ReservationsRepository for SqlxDatabase { async fn get_reservations(&self) -> Result, RepositoryError> { @@ -144,7 +163,10 @@ impl ReservationsRepository for SqlxDatabase { r#"SELECT r.id, r.unit_id, - un."name" AS unit_name, + -- `?` forces the nullability sqlx cannot infer: `units.name` is + -- NOT NULL, but the LEFT JOIN makes it null for a free label + un."name" AS "unit_name?", + r.unit_label, r.start_time, r.end_time, r.requester_id, @@ -167,7 +189,7 @@ impl ReservationsRepository for SqlxDatabase { WHERE reservation_id = r.id ORDER BY bike_id ) AS "bikes!" FROM reservations r - JOIN units un ON un.id = r.unit_id + LEFT JOIN units un ON un.id = r.unit_id ORDER BY r.start_time DESC"# ) .fetch_all(&self.pool) @@ -186,7 +208,10 @@ impl ReservationsRepository for SqlxDatabase { r#"SELECT r.id, r.unit_id, - un."name" AS unit_name, + -- `?` forces the nullability sqlx cannot infer: `units.name` is + -- NOT NULL, but the LEFT JOIN makes it null for a free label + un."name" AS "unit_name?", + r.unit_label, r.start_time, r.end_time, r.requester_id, @@ -209,7 +234,7 @@ impl ReservationsRepository for SqlxDatabase { WHERE reservation_id = r.id ORDER BY bike_id ) AS "bikes!" FROM reservations r - JOIN units un ON un.id = r.unit_id + LEFT JOIN units un ON un.id = r.unit_id WHERE r.unit_id = $1 ORDER BY r.start_time DESC"#, unit @@ -227,7 +252,10 @@ impl ReservationsRepository for SqlxDatabase { r#"SELECT r.id, r.unit_id, - un."name" AS unit_name, + -- `?` forces the nullability sqlx cannot infer: `units.name` is + -- NOT NULL, but the LEFT JOIN makes it null for a free label + un."name" AS "unit_name?", + r.unit_label, r.start_time, r.end_time, r.requester_id, @@ -250,7 +278,7 @@ impl ReservationsRepository for SqlxDatabase { WHERE reservation_id = r.id ORDER BY bike_id ) AS "bikes!" FROM reservations r - JOIN units un ON un.id = r.unit_id + LEFT JOIN units un ON un.id = r.unit_id WHERE r.id = $1"#, id ) @@ -268,11 +296,14 @@ impl ReservationsRepository for SqlxDatabase { // No status here: the column defaults to 'requested', the start of the // state machine. + let (unit_id, unit_label) = split_unit(&reservation.unit); let id = query!( - r#"INSERT INTO reservations (unit_id, start_time, end_time, requester_id, telegram, "description") - VALUES ($1, $2, $3, $4, $5, $6) + r#"INSERT INTO reservations + (unit_id, unit_label, start_time, end_time, requester_id, telegram, "description") + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id"#, - reservation.unit, + unit_id, + unit_label, reservation.start_time, reservation.end_time, requester, @@ -304,12 +335,15 @@ impl ReservationsRepository for SqlxDatabase { ) -> Result<(), RepositoryError> { let mut tx = self.pool.begin().await?; + let (unit_id, unit_label) = split_unit(&reservation.unit); let result = query!( r#"UPDATE reservations - SET unit_id = $2, start_time = $3, end_time = $4, telegram = $5, "description" = $6 + SET unit_id = $2, unit_label = $3, start_time = $4, end_time = $5, + telegram = $6, "description" = $7 WHERE id = $1"#, reservation.id, - reservation.unit, + unit_id, + unit_label, reservation.start_time, reservation.end_time, reservation.telegram,