diff --git a/db/migrations/20260824140000_reservation_linka_emails.sql b/db/migrations/20260824140000_reservation_linka_emails.sql
new file mode 100644
index 0000000..d42032b
--- /dev/null
+++ b/db/migrations/20260824140000_reservation_linka_emails.sql
@@ -0,0 +1,16 @@
+-- migrate:up
+
+-- The Linka Go accounts allowed to unlock the bikes for this reservation. They
+-- are plain addresses, not app users: somebody who never logs in can still be
+-- authorised, so this is not a link to `users`.
+ALTER TABLE reservations ADD COLUMN linka_emails TEXT[] NOT NULL DEFAULT '{}';
+
+-- A check constraint cannot hold a subquery, so this catches the empty string
+-- rather than any blank one. The repository trims before writing, which turns a
+-- whitespace-only address into the empty string caught here.
+ALTER TABLE reservations ADD CONSTRAINT reservations_linka_emails_filled CHECK (
+ array_position(linka_emails, NULL) IS NULL AND NOT ('' = ANY (linka_emails)));
+
+-- migrate:down
+ALTER TABLE reservations DROP CONSTRAINT reservations_linka_emails_filled;
+ALTER TABLE reservations DROP COLUMN linka_emails;
diff --git a/db/schema.sql b/db/schema.sql
index f3eac39..c182d8d 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -115,6 +115,8 @@ CREATE TABLE public.reservations (
status public.reservation_status DEFAULT 'requested'::public.reservation_status NOT NULL,
unit_id integer,
unit_label text,
+ linka_emails text[] DEFAULT '{}'::text[] NOT NULL,
+ CONSTRAINT reservations_linka_emails_filled CHECK (((array_position(linka_emails, NULL::text) IS NULL) AND (NOT (''::text = ANY (linka_emails))))),
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_unit_label_not_blank CHECK (((unit_label IS NULL) OR (btrim(unit_label) <> ''::text))),
@@ -502,4 +504,5 @@ INSERT INTO public.schema_migrations (version) VALUES
('20260823170000'),
('20260823210000'),
('20260823230000'),
- ('20260824120000');
+ ('20260824120000'),
+ ('20260824140000');
diff --git a/db/seed.sql b/db/seed.sql
index 0048f40..4d537c0 100644
--- a/db/seed.sql
+++ b/db/seed.sql
@@ -42,40 +42,48 @@ 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
+ (id, unit_id, start_time, end_time, requester_id, telegram, "description", linka_emails, status)
+SELECT r.id, u.id, r.start_time, r.end_time, r.requester_id, r.telegram, r."description",
+ r.linka_emails, r.status
FROM (VALUES
(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',
+ ARRAY['alice.martin@epfl.ch'],
'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',
+ ARRAY['chloe.favre@epfl.ch'],
'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',
+ ARRAY['bob.dupont@epfl.ch','alice.martin@epfl.ch'],
'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',
+ ARRAY['chloe.favre@epfl.ch'],
'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',
+ ARRAY['alice.martin@epfl.ch'],
'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',
+ ARRAY['bob.dupont@epfl.ch'],
'cancelled'::reservation_status)
-) AS r (id, unit_name, start_time, end_time, requester_id, telegram, "description", status)
+) AS r (id, unit_name, start_time, end_time, requester_id, telegram, "description",
+ linka_emails, status)
JOIN public.units u ON u."name" = r.unit_name
ON CONFLICT DO NOTHING;
diff --git a/frontend/src/components/admin/ReservationCard.vue b/frontend/src/components/admin/ReservationCard.vue
index b94794a..c24c4d8 100644
--- a/frontend/src/components/admin/ReservationCard.vue
+++ b/frontend/src/components/admin/ReservationCard.vue
@@ -109,6 +109,10 @@ function move(status: ReservationStatus) {
{{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }}
+
+
{{ $t('admin.reservations.linka') }}
+
{{ reservation.linka_emails.join(', ') }}
+
{{ $t('admin.reservations.reason') }}
{{ reservation.description }}
diff --git a/frontend/src/lib/api.d.ts b/frontend/src/lib/api.d.ts
index 4bd6b1e..dbd891a 100644
--- a/frontend/src/lib/api.d.ts
+++ b/frontend/src/lib/api.d.ts
@@ -438,7 +438,69 @@ export interface paths {
}
}
put?: never
- post?: never
+ /**
+ * File a reservation request
+ * @description The requester is the session user and the status starts at `requested`; neither is taken from the body.
+ */
+ post: {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['NewReservation']
+ }
+ }
+ responses: {
+ /** @description The reservation, as stored */
+ 201: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['Reservation']
+ }
+ }
+ /** @description The reservation is malformed */
+ 400: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
+ /** @description Unauthenticated - a session is required */
+ 401: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
+ /** @description The requester does not belong to the unit named */
+ 403: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
+ /** @description One of the bikes is out of service */
+ 409: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
+ /** @description The unit or one of the bikes does not exist */
+ 422: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
+ }
+ }
delete?: never
options?: never
head?: never
@@ -557,6 +619,31 @@ export interface components {
/** @description Email of one of the `dev_users` of the configuration */
user: string
}
+ NewReservation: {
+ description: string
+ bikes: number[]
+ /** Format: date-time */
+ end_time: string
+ linka_emails: string[]
+ /** Format: date-time */
+ start_time: string
+ telegram: string
+ unit: components['schemas']['NewReservationUnit']
+ users: number[]
+ }
+ /** @description The same choice, as the client sends it: only the id travels for a known unit. */
+ NewReservationUnit:
+ | {
+ /** Format: int32 */
+ id: number
+ /** @constant */
+ kind: 'known'
+ }
+ | {
+ /** @constant */
+ kind: 'free'
+ name: string
+ }
PostCallbackParams: {
code: string
state: string
@@ -568,6 +655,11 @@ export interface components {
end_time: string
/** Format: int32 */
id: number
+ /**
+ * @description Linka Go accounts allowed to unlock the bikes. Plain addresses: they
+ * need not belong to anybody who ever logs into this app.
+ */
+ linka_emails: string[]
/** Format: int32 */
requester: number
/** Format: date-time */
diff --git a/frontend/src/locales/en.yml b/frontend/src/locales/en.yml
index 922e6ba..1a8e77c 100644
--- a/frontend/src/locales/en.yml
+++ b/frontend/src/locales/en.yml
@@ -52,7 +52,9 @@ reservation:
error-telegram: "Invalid Telegram username (example: {'@'}my_username)."
error-email: One of the email addresses is invalid.
error-form: The form contains errors.
- not-implemented: Submitting is not wired to the backend yet.
+ submitted: Request sent. It will show up in the pending requests.
+ submitting: Sending…
+ submit-error: Could not send the request.
login:
title: Log in
intro: Log in with your AGEPoly account to book a cargobike.
@@ -96,6 +98,7 @@ admin:
bikes: Cargobike(s)
telegram: Telegram
people: People
+ linka: Linka Go accounts
reason: Reason
load-error: Unable to load the reservations.
error: The status change failed.
diff --git a/frontend/src/locales/fr.yml b/frontend/src/locales/fr.yml
index 1fca4b6..a70412a 100644
--- a/frontend/src/locales/fr.yml
+++ b/frontend/src/locales/fr.yml
@@ -53,7 +53,9 @@ reservation:
error-telegram: "Username Telegram invalide (exemple : {'@'}mon_username)."
error-email: Une des adresses e-mail est invalide.
error-form: Le formulaire contient des erreurs.
- not-implemented: L'envoi n'est pas encore branché sur le backend.
+ submitted: Demande envoyée. Elle apparaîtra dans les demandes en attente.
+ submitting: Envoi…
+ submit-error: L'envoi de la demande a échoué.
login:
title: Connexion
intro: Connectez-vous avec votre compte AGEPoly pour réserver un cargobike.
@@ -97,6 +99,7 @@ admin:
bikes: Cargobike(s)
telegram: Telegram
people: Personnes
+ linka: Comptes Linka Go
reason: Raison
load-error: Impossible de charger les réservations.
error: Le changement de statut a échoué.
diff --git a/frontend/src/services/api/reservations.ts b/frontend/src/services/api/reservations.ts
index cf7c0c0..b7bd8bb 100644
--- a/frontend/src/services/api/reservations.ts
+++ b/frontend/src/services/api/reservations.ts
@@ -1,11 +1,11 @@
/**
- * Reservations, from the administration side. Reading the whole list is an
- * admin action, so these hooks are only ever mounted on /admin.
+ * Reservations. Filing a request only needs a session; reading the whole list is
+ * an admin action, so `useReservations` is 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 type { NewReservation, Reservation, ReservationStatus } from '@/utils/types'
import { getClient } from './client'
export const RESERVATIONS_KEY = ['reservations']
@@ -47,3 +47,27 @@ export function useSetReservationStatus() {
},
})
}
+
+/**
+ * Files a request. The backend fills in the requester and the `requested`
+ * status, so neither is part of the body.
+ */
+export function useCreateReservation() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ retry: 0,
+ mutationFn: async (reservation: NewReservation) => {
+ const { data, response, error } = await getClient().POST('/api/reservations', {
+ body: reservation,
+ })
+ if (response.status !== HttpStatus.CREATED) {
+ // The handler answers with a plain string, which is what to show
+ throw new Error(typeof error === 'string' ? error : `Unexpected status: ${response.status}`)
+ }
+ return data as Reservation
+ },
+ // The admin list is a different query: let it refetch rather than guessing
+ // where the new reservation belongs in its ordering.
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }),
+ })
+}
diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts
index a8a3840..4557f5d 100644
--- a/frontend/src/utils/types.ts
+++ b/frontend/src/utils/types.ts
@@ -27,6 +27,7 @@ export type Reservation = components['schemas']['Reservation']
export type ReservationStatus = components['schemas']['ReservationStatus']
export type UserSummary = components['schemas']['UserSummary']
export type ReservationUnit = components['schemas']['ReservationUnit']
+export type NewReservation = components['schemas']['NewReservation']
/**
* What to display for a reservation's unit: the name of the unit it points at,
diff --git a/frontend/src/views/ReservationView.vue b/frontend/src/views/ReservationView.vue
index a38f9c6..1a92c1c 100644
--- a/frontend/src/views/ReservationView.vue
+++ b/frontend/src/views/ReservationView.vue
@@ -16,8 +16,9 @@ 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 { useCreateReservation } from '@/services/api/reservations'
import { useSession } from '@/services/api/auth'
-import type { Bike } from '@/utils/types'
+import type { Bike, NewReservation } from '@/utils/types'
const { t } = useI18n()
@@ -156,6 +157,27 @@ function reset() {
submitted.value = false
}
+const create = useCreateReservation()
+
+/** The form, as the api wants it. Both are non-null once `validate()` passed. */
+function payload(): NewReservation {
+ const association = form.association!
+ return {
+ unit:
+ association.kind === 'known'
+ ? { kind: 'known', id: association.id }
+ : { kind: 'free', name: association.name.trim() },
+ start_time: start.value!.toISOString(),
+ end_time: end.value!.toISOString(),
+ // The backend adds the requester itself; nobody else is picked here yet
+ users: [],
+ telegram: `@${form.telegram}`,
+ description: form.reason.trim(),
+ bikes: [...form.bikes],
+ linka_emails: form.emails.map((email) => email.trim()).filter(Boolean),
+ }
+}
+
function submit() {
submitted.value = true
if (!validate()) {
@@ -163,9 +185,15 @@ function submit() {
return
}
- // TODO: replace with a `useCreateReservation` mutation once
- // `POST /api/reservations` exists.
- toast.info(t('reservation.not-implemented'))
+ create.mutate(payload(), {
+ onSuccess: () => {
+ toast.success(t('reservation.submitted'))
+ reset()
+ },
+ // The message is the backend's own: "bike 3 is out of service" is worth
+ // reading, and a generic failure would hide it.
+ onError: (error) => toast.error(error.message || t('reservation.submit-error')),
+ })
}
@@ -370,8 +398,15 @@ function submit() {
-
-
diff --git a/src/api/bikes.rs b/src/api/bikes.rs
index 2cc4dae..908ecc3 100644
--- a/src/api/bikes.rs
+++ b/src/api/bikes.rs
@@ -41,9 +41,7 @@ async fn set_status(
) -> Result<(), (StatusCode, String)> {
match admin(ac)?.set_bike_status(id, status).await {
Ok(()) => Ok(()),
- Err(err) if err.is_not_found() => {
- Err((StatusCode::NOT_FOUND, "No such bike".to_owned()))
- }
+ Err(err) if err.is_not_found() => Err((StatusCode::NOT_FOUND, "No such bike".to_owned())),
Err(err) => unexpected_error("set_bike_status", err),
}
}
diff --git a/src/api/helpers.rs b/src/api/helpers.rs
index f0d4270..f070143 100644
--- a/src/api/helpers.rs
+++ b/src/api/helpers.rs
@@ -1,7 +1,7 @@
use aide::transform::TransformResponse;
+use axum::http::StatusCode;
use schemars::JsonSchema;
use serde::Deserialize;
-use axum::http::StatusCode;
use tracing::error;
use crate::core::{
diff --git a/src/api/reservations.rs b/src/api/reservations.rs
index e1882b0..3a412bb 100644
--- a/src/api/reservations.rs
+++ b/src/api/reservations.rs
@@ -1,6 +1,7 @@
-//! Reservations, from the administration side.
+//! Reservations.
//!
-//! Reading the whole list is an admin action for now; changing a status is
+//! Filing a request only needs a session — the requester is taken from it, never
+//! from the body. Reading the whole list is an admin action for now; changing a status is
//! reserved to the unit the reservation belongs to (an admin manages every
//! unit), which is why the handler resolves the unit before narrowing the
//! controller down.
@@ -12,25 +13,25 @@ use aide::{
},
transform::TransformOperation,
};
-use axum::{
- Json,
- extract::Path,
- http::StatusCode,
-};
+use axum::{Json, extract::Path, http::StatusCode};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{
- api::helpers::{IdPath, admin, admin_desc, manager, manager_desc, unexpected_error},
+ api::helpers::{IdPath, admin, admin_desc, desc, manager, manager_desc, unexpected_error},
core::{
controller::{AppController, ControllerError, reservations::ReservationsControllerError},
- models::reservation::{Reservation, ReservationStatus},
+ models::reservation::{NewReservation, Reservation, ReservationStatus},
},
};
pub fn routes() -> ApiRouter {
ApiRouter::new()
- .api_route("/", get_with(get_reservations, get_reservations_docs))
+ .api_route(
+ "/",
+ get_with(get_reservations, get_reservations_docs)
+ .post_with(create_reservation, create_reservation_docs),
+ )
.api_route("/{id}/status", put_with(set_status, set_status_docs))
}
@@ -50,6 +51,47 @@ fn get_reservations_docs(op: TransformOperation) -> TransformOperation {
.response_with::<403, (), _>(admin_desc)
}
+/// Files a request. The reservation always starts in `requested`: the status is
+/// not part of the body, so a requester cannot approve their own booking.
+#[axum::debug_handler]
+async fn create_reservation(
+ ac: AppController,
+ Json(reservation): Json,
+) -> Result<(StatusCode, Json), (StatusCode, String)> {
+ match ac.create_reservation(reservation).await {
+ Ok(reservation) => Ok((StatusCode::CREATED, Json(reservation))),
+ Err(ControllerError::Reservation(
+ err @ ReservationsControllerError::ReservationInvalid,
+ )) => Err((StatusCode::BAD_REQUEST, err.to_string())),
+ Err(ControllerError::Reservation(
+ err @ ReservationsControllerError::BikeOutOfService(_),
+ )) => Err((StatusCode::CONFLICT, err.to_string())),
+ Err(ControllerError::Reservation(
+ err @ ReservationsControllerError::NotAMemberOfUnit(_),
+ )) => Err((StatusCode::FORBIDDEN, err.to_string())),
+ // An unknown unit id or bike id: the client named something that is gone
+ Err(err) if err.is_not_found() => Err((
+ StatusCode::UNPROCESSABLE_ENTITY,
+ "Unknown unit or bike".to_owned(),
+ )),
+ Err(err) => unexpected_error("create_reservation", err),
+ }
+}
+
+fn create_reservation_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Reservations")
+ .summary("File a reservation request")
+ .description(
+ "The requester is the session user and the status starts at `requested`; \
+ neither is taken from the body.",
+ )
+ .response_with::<201, Json, _>(desc("The reservation, as stored"))
+ .response_with::<400, (), _>(desc("The reservation is malformed"))
+ .response_with::<403, (), _>(desc("The requester does not belong to the unit named"))
+ .response_with::<409, (), _>(desc("One of the bikes is out of service"))
+ .response_with::<422, (), _>(desc("The unit or one of the bikes does not exist"))
+}
+
#[derive(Debug, Deserialize, JsonSchema)]
struct SetStatusForm {
status: ReservationStatus,
@@ -75,9 +117,9 @@ async fn set_status(
.await
{
Ok(()) => Ok(()),
- Err(ControllerError::Reservation(err @ ReservationsControllerError::InvalidTransition(..))) => {
- Err((StatusCode::CONFLICT, err.to_string()))
- }
+ Err(ControllerError::Reservation(
+ err @ ReservationsControllerError::InvalidTransition(..),
+ )) => Err((StatusCode::CONFLICT, err.to_string())),
Err(err) => unexpected_error("set_status", err),
}
}
@@ -85,9 +127,7 @@ async fn set_status(
fn set_status_docs(op: TransformOperation) -> TransformOperation {
op.tag("Reservations")
.summary("Move a reservation through its state machine")
- .description(
- "Refuses a transition the state machine does not allow, with a 409.",
- )
+ .description("Refuses a transition the state machine does not allow, with a 409.")
.response_with::<403, (), _>(manager_desc)
.response::<404, ()>()
.response::<409, ()>()
diff --git a/src/core/controller/authn.rs b/src/core/controller/authn.rs
index ca30590..176cc88 100644
--- a/src/core/controller/authn.rs
+++ b/src/core/controller/authn.rs
@@ -76,9 +76,7 @@ impl AnonAppController {
self.db.save_whiskey_data(authorize_backend_data).await?;
Ok(redirect_to)
}
- Err(WhiskeyError::ProtocolError) => {
- Err(AuthnControllerError::OIDCProtocolError.into())
- }
+ Err(WhiskeyError::ProtocolError) => Err(AuthnControllerError::OIDCProtocolError.into()),
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
"Originated from Whiskey".to_owned(),
)),
@@ -93,16 +91,18 @@ impl AnonAppController {
state: String,
) -> Result {
// Consumes the state: a callback can never be replayed
- let backend_data = self.db.take_whiskey_data(state.clone()).await.map_err(|_| {
- debug!("unknown, already used or expired oidc state");
- AuthnControllerError::OIDCProtocolError
- })?;
+ let backend_data = self
+ .db
+ .take_whiskey_data(state.clone())
+ .await
+ .map_err(|_| {
+ debug!("unknown, already used or expired oidc state");
+ AuthnControllerError::OIDCProtocolError
+ })?;
match callback(code, state, backend_data).await {
Ok(user_info) => self.upsert_oidc_user(user_info).await,
- Err(WhiskeyError::ProtocolError) => {
- Err(AuthnControllerError::OIDCProtocolError.into())
- }
+ Err(WhiskeyError::ProtocolError) => Err(AuthnControllerError::OIDCProtocolError.into()),
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
"originated from Whiskey".to_owned(),
)),
diff --git a/src/core/controller/bikes.rs b/src/core/controller/bikes.rs
index 3eb046c..04b5218 100644
--- a/src/core/controller/bikes.rs
+++ b/src/core/controller/bikes.rs
@@ -15,7 +15,6 @@ impl AnonAppController {
pub async fn get_bike(&self, id: BikeId) -> Result {
self.db.get_bike(id).await.map_err(Into::into)
}
-
}
/// Changing the fleet is an admin action
diff --git a/src/core/controller/reservations.rs b/src/core/controller/reservations.rs
index 19882a9..0f41ac0 100644
--- a/src/core/controller/reservations.rs
+++ b/src/core/controller/reservations.rs
@@ -5,10 +5,12 @@ use crate::core::{
models::{
bike::BikeStatus,
reservation::{
- NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
+ NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId,
+ ReservationStatus,
},
unit::UnitId,
},
+ repositories::RepositoryError,
};
/// Reading the reservations needs no session: the calendar is public.
@@ -30,7 +32,6 @@ impl AnonAppController {
pub async fn get_reservation(&self, id: ReservationId) -> Result {
self.db.get_reservation(id).await.map_err(Into::into)
}
-
}
/// Filing a request is done in one's own name: the requester is the session
@@ -43,6 +44,20 @@ impl AppController {
if !reservation.is_valid() {
return Err(ReservationsControllerError::ReservationInvalid.into());
}
+ // Naming a known unit means claiming to act for it. An admin may file
+ // for any unit, but it still has to exist: without this the foreign key
+ // would be what rejects the insert, and that surfaces as a 500.
+ if let NewReservationUnit::Known { id } = reservation.unit {
+ let user = self.user();
+ if !user.units.iter().any(|unit| unit.id == id) {
+ if !user.admin {
+ return Err(ReservationsControllerError::NotAMemberOfUnit(id).into());
+ }
+ if !self.db.get_units().await?.iter().any(|unit| unit.id == id) {
+ return Err(RepositoryError::NotFound(format!("unit {id}")).into());
+ }
+ }
+ }
// A bike out of service cannot be booked
for id in &reservation.bikes {
if self.get_bike(*id).await?.status == BikeStatus::OutOfService {
@@ -120,4 +135,6 @@ pub enum ReservationsControllerError {
ReservationFinal(ReservationStatus),
#[error("Bike {0} is out of service and cannot be booked")]
BikeOutOfService(i32),
+ #[error("The requester does not belong to unit {0}")]
+ NotAMemberOfUnit(UnitId),
}
diff --git a/src/core/controller/users.rs b/src/core/controller/users.rs
index 594599c..28b0300 100644
--- a/src/core/controller/users.rs
+++ b/src/core/controller/users.rs
@@ -21,7 +21,6 @@ impl AnonAppController {
.await
.map_err(Into::into)
}
-
}
impl AdminAppController {
diff --git a/src/core/models/reservation.rs b/src/core/models/reservation.rs
index 9a1645b..c617ae5 100644
--- a/src/core/models/reservation.rs
+++ b/src/core/models/reservation.rs
@@ -43,7 +43,6 @@ impl ReservationStatus {
}
}
-
/// The unit a reservation is filed for.
///
/// Either one we know — a Whiskey group, with its row — or a plain name the
@@ -107,6 +106,9 @@ pub struct Reservation {
pub telegram: String,
pub description: String,
pub bikes: Vec,
+ /// Linka Go accounts allowed to unlock the bikes. Plain addresses: they
+ /// need not belong to anybody who ever logs into this app.
+ pub linka_emails: Vec,
pub status: ReservationStatus,
}
@@ -119,6 +121,7 @@ pub struct NewReservation {
pub telegram: String,
pub description: String,
pub bikes: Vec,
+ pub linka_emails: Vec,
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
@@ -131,11 +134,36 @@ pub struct ReservationEdit {
pub telegram: String,
pub description: String,
pub bikes: Vec,
+ pub linka_emails: Vec,
+}
+
+/// At least one address, none of them blank: a reservation nobody can unlock
+/// is of no use to the admin who has to authorise it.
+fn linka_emails_valid(emails: &[String]) -> bool {
+ !emails.is_empty() && emails.iter().all(|email| !email.trim().is_empty())
+}
+
+/// Mirrors the `reservations_telegram_handle` check constraint. Duplicated on
+/// purpose: without it a bad handle only fails once it reaches postgres, which
+/// surfaces as a 500 instead of "your handle is malformed".
+fn telegram_valid(handle: &str) -> bool {
+ let Some(rest) = handle.strip_prefix('@') else {
+ return false;
+ };
+ let mut chars = rest.chars();
+ if !matches!(chars.next(), Some(c) if c.is_ascii_alphabetic()) {
+ return false;
+ }
+ (5..=32).contains(&rest.len()) && rest.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
impl NewReservation {
pub fn is_valid(&self) -> bool {
- self.unit.is_valid() && self.end_time > self.start_time && !self.bikes.is_empty()
+ self.unit.is_valid()
+ && self.end_time > self.start_time
+ && !self.bikes.is_empty()
+ && telegram_valid(&self.telegram)
+ && linka_emails_valid(&self.linka_emails)
}
}
@@ -145,5 +173,41 @@ impl ReservationEdit {
&& self.end_time > self.start_time
&& !self.bikes.is_empty()
&& !self.users.is_empty()
+ && telegram_valid(&self.telegram)
+ && linka_emails_valid(&self.linka_emails)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn telegram_handles_match_the_database_constraint() {
+ assert!(telegram_valid("@milan_hyenne"));
+ assert!(telegram_valid("@abcde"));
+ assert!(telegram_valid(&format!("@a{}", "b".repeat(31))));
+
+ assert!(!telegram_valid("milan_hyenne"), "missing @");
+ assert!(!telegram_valid("@abcd"), "too short");
+ assert!(
+ !telegram_valid(&format!("@a{}", "b".repeat(32))),
+ "too long"
+ );
+ assert!(!telegram_valid("@1abcde"), "must start with a letter");
+ assert!(!telegram_valid("@abc-de"), "no dash");
+ assert!(!telegram_valid("@"), "nothing after the @");
+ assert!(
+ !telegram_valid("@élodie"),
+ "ascii only, as in the constraint"
+ );
+ }
+
+ #[test]
+ fn linka_emails_must_hold_at_least_one_filled_address() {
+ assert!(linka_emails_valid(&["a@b.ch".to_owned()]));
+ assert!(!linka_emails_valid(&[]));
+ assert!(!linka_emails_valid(&[" ".to_owned()]));
+ assert!(!linka_emails_valid(&["a@b.ch".to_owned(), "".to_owned()]));
}
}
diff --git a/src/core/repositories/users_repository.rs b/src/core/repositories/users_repository.rs
index d049e90..3e3d40a 100644
--- a/src/core/repositories/users_repository.rs
+++ b/src/core/repositories/users_repository.rs
@@ -21,8 +21,7 @@ pub trait UsersRepository {
async fn upsert_user(&self, user: NewUser) -> Result;
/// Replaces the whole set of units the user belongs to
- async fn set_user_units(&self, id: UserId, units: Vec)
- -> Result<(), RepositoryError>;
+ async fn set_user_units(&self, id: UserId, units: Vec) -> Result<(), RepositoryError>;
async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>;
}
diff --git a/src/main.rs b/src/main.rs
index e0d6c39..e639032 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -37,12 +37,9 @@ 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(aac).fallback_service(
- ServeDir::new(&config.frontend_dir).fallback(ServeFile::new(format!(
- "{}/index.html",
- config.frontend_dir
- ))),
- );
+ let app = api::get_router(aac).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/reservations.rs b/src/services/database/reservations.rs
index 4048527..edc1dae 100644
--- a/src/services/database/reservations.rs
+++ b/src/services/database/reservations.rs
@@ -71,6 +71,7 @@ struct ReservationDB {
pub telegram: String,
pub description: String,
pub status: ReservationStatusDB,
+ pub linka_emails: Vec,
pub users: Value,
pub bikes: Vec,
}
@@ -100,6 +101,7 @@ impl TryFrom for Reservation {
telegram: value.telegram,
description: value.description,
bikes: value.bikes,
+ linka_emails: value.linka_emails,
status: value.status.into(),
})
}
@@ -155,6 +157,13 @@ fn split_unit(unit: &NewReservationUnit) -> (Option, Option) {
}
}
+/// Surrounding spaces never belong to an address, and trimming is what turns a
+/// whitespace-only one into the empty string the `reservations_linka_emails_filled`
+/// check rejects.
+fn trim_emails(emails: &[String]) -> Vec {
+ emails.iter().map(|email| email.trim().to_owned()).collect()
+}
+
#[async_trait]
impl ReservationsRepository for SqlxDatabase {
async fn get_reservations(&self) -> Result, RepositoryError> {
@@ -172,6 +181,7 @@ impl ReservationsRepository for SqlxDatabase {
r.requester_id,
r.telegram,
r."description",
+ r.linka_emails,
r.status AS "status: ReservationStatusDB",
COALESCE((
SELECT json_agg(json_build_object(
@@ -217,6 +227,7 @@ impl ReservationsRepository for SqlxDatabase {
r.requester_id,
r.telegram,
r."description",
+ r.linka_emails,
r.status AS "status: ReservationStatusDB",
COALESCE((
SELECT json_agg(json_build_object(
@@ -261,6 +272,7 @@ impl ReservationsRepository for SqlxDatabase {
r.requester_id,
r.telegram,
r."description",
+ r.linka_emails,
r.status AS "status: ReservationStatusDB",
COALESCE((
SELECT json_agg(json_build_object(
@@ -297,10 +309,12 @@ 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 linka_emails = trim_emails(&reservation.linka_emails);
let id = query!(
r#"INSERT INTO reservations
- (unit_id, unit_label, start_time, end_time, requester_id, telegram, "description")
- VALUES ($1, $2, $3, $4, $5, $6, $7)
+ (unit_id, unit_label, start_time, end_time, requester_id, telegram,
+ "description", linka_emails)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id"#,
unit_id,
unit_label,
@@ -308,7 +322,8 @@ impl ReservationsRepository for SqlxDatabase {
reservation.end_time,
requester,
reservation.telegram,
- reservation.description
+ reservation.description,
+ &linka_emails
)
.fetch_one(&mut *tx)
.await?
@@ -336,10 +351,11 @@ impl ReservationsRepository for SqlxDatabase {
let mut tx = self.pool.begin().await?;
let (unit_id, unit_label) = split_unit(&reservation.unit);
+ let linka_emails = trim_emails(&reservation.linka_emails);
let result = query!(
r#"UPDATE reservations
SET unit_id = $2, unit_label = $3, start_time = $4, end_time = $5,
- telegram = $6, "description" = $7
+ telegram = $6, "description" = $7, linka_emails = $8
WHERE id = $1"#,
reservation.id,
unit_id,
@@ -347,7 +363,8 @@ impl ReservationsRepository for SqlxDatabase {
reservation.start_time,
reservation.end_time,
reservation.telegram,
- reservation.description
+ reservation.description,
+ &linka_emails
)
.execute(&mut *tx)
.await?;
diff --git a/src/utils/whiskey.rs b/src/utils/whiskey.rs
index 064c989..47516bf 100644
--- a/src/utils/whiskey.rs
+++ b/src/utils/whiskey.rs
@@ -55,9 +55,7 @@ async fn get_client() -> &'static Client {
ClientId::new(config.oidc.client_id),
Some(ClientSecret::new(config.oidc.client_secret)),
)
- .set_redirect_uri(
- RedirectUrl::new(redirect_url).unwrap(),
- )
+ .set_redirect_uri(RedirectUrl::new(redirect_url).unwrap())
})
.await
}
@@ -247,12 +245,11 @@ async fn groups_from_userinfo(
}
};
- match request
- .request_async(get_http_client())
- .await
- .map(|claims: openidconnect::UserInfoClaims| {
+ match request.request_async(get_http_client()).await.map(
+ |claims: openidconnect::UserInfoClaims| {
claims.additional_claims().groups.clone()
- }) {
+ },
+ ) {
Ok(groups) => groups,
Err(err) => {
debug!("userinfo request failed: {err:?}");