From 46c5edf5bd61ba123252c3b9e565e384dbc968db Mon Sep 17 00:00:00 2001 From: Antoine Pelletier Date: Mon, 24 Aug 2026 12:38:12 +0200 Subject: [PATCH] feat: add personnal page --- frontend/src/components/AppHeader.vue | 15 +- .../src/components/admin/BikeCalendar.vue | 4 +- .../src/components/admin/ReservationCard.vue | 8 +- .../reservation/ReservationDetails.vue | 12 +- .../reservation/ReservationEditDialog.vue | 78 +- frontend/src/lib/api.d.ts | 1703 +++++++++-------- frontend/src/locales/en.yml | 66 +- frontend/src/locales/fr.yml | 66 +- frontend/src/router/index.ts | 7 + frontend/src/services/api/reservations.ts | 27 +- frontend/src/views/MyReservationsView.vue | 151 ++ src/api/reservations.rs | 46 +- src/core/controller/reservations.rs | 91 +- .../repositories/reservations_repository.rs | 10 + src/services/database/reservations.rs | 71 +- 15 files changed, 1413 insertions(+), 942 deletions(-) create mode 100644 frontend/src/views/MyReservationsView.vue diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 496db76..000487b 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -1,7 +1,7 @@ + + diff --git a/src/api/reservations.rs b/src/api/reservations.rs index df13a66..3804dc6 100644 --- a/src/api/reservations.rs +++ b/src/api/reservations.rs @@ -36,6 +36,11 @@ pub fn routes() -> ApiRouter { get_with(get_reservations, get_reservations_docs) .post_with(create_reservation, create_reservation_docs), ) + // Before `/{id}`, which would otherwise be a candidate for "mine" + .api_route( + "/mine", + get_with(get_my_reservations, get_my_reservations_docs), + ) .api_route( "/{id}", put_with(update_reservation, update_reservation_docs), @@ -100,6 +105,28 @@ fn create_reservation_docs(op: TransformOperation) -> TransformOperation { .response_with::<422, (), _>(desc("The unit or one of the bikes does not exist")) } +/// Everything the session user is part of: what they filed, what they were +/// added to, and what lists their address among the Linka Go accounts. +#[axum::debug_handler] +async fn get_my_reservations( + ac: AppController, +) -> Result>, (StatusCode, String)> { + match ac.get_my_reservations().await { + Ok(reservations) => Ok(Json(reservations)), + Err(err) => unexpected_error("get_my_reservations", err), + } +} + +fn get_my_reservations_docs(op: TransformOperation) -> TransformOperation { + op.tag("Reservations") + .summary("Get the reservations the session user is part of") + .description( + "An address listed among the Linka Go accounts is enough, which is how \ + somebody added before they ever logged in finds the reservation waiting \ + for them.", + ) +} + /// Only what the admin page lets somebody change. The unit, the requester, the /// telegram handle and the reason are shown but not editable, so they are not /// in the body at all: the handler reads them back from the stored reservation @@ -138,10 +165,7 @@ async fn update_reservation( linka_emails: form.linka_emails, }; - match manager(ac, current.unit.scope())? - .update_reservation(edit) - .await - { + match ac.update_reservation(edit).await { Ok(()) => Ok(()), Err(ControllerError::Reservation( err @ ReservationsControllerError::ReservationInvalid, @@ -150,6 +174,10 @@ async fn update_reservation( err @ (ReservationsControllerError::BikeOutOfService(_) | ReservationsControllerError::ReservationFinal(_)), )) => Err((StatusCode::CONFLICT, err.to_string())), + Err(ControllerError::Reservation( + err @ (ReservationsControllerError::NotOnTheReservation + | ReservationsControllerError::OnlyEmailsEditable(_)), + )) => Err((StatusCode::FORBIDDEN, err.to_string())), Err(err) if err.is_not_found() => { Err((StatusCode::UNPROCESSABLE_ENTITY, "Unknown bike".to_owned())) } @@ -161,10 +189,14 @@ fn update_reservation_docs(op: TransformOperation) -> TransformOperation { op.tag("Reservations") .summary("Edit the period, the bikes and the Linka Go accounts") .description( - "Everything else is left as stored. A reservation in a final state \ - cannot be edited any more.", + "Everything else is left as stored. A manager may change all three until \ + the reservation is final; anybody else on it may do so only while it is \ + still a request, and afterwards only the Linka Go accounts.", ) - .response_with::<403, (), _>(manager_desc) + .response_with::<403, (), _>(desc( + "The user is not on the reservation, or tried to change more than the \ + Linka Go accounts on one that is already approved", + )) .response::<404, ()>() .response_with::<400, (), _>(desc("The reservation would become malformed")) .response_with::<409, (), _>(desc( diff --git a/src/core/controller/reservations.rs b/src/core/controller/reservations.rs index 9cf9eda..2404e45 100644 --- a/src/core/controller/reservations.rs +++ b/src/core/controller/reservations.rs @@ -37,6 +37,15 @@ impl AnonAppController { /// Filing a request is done in one's own name: the requester is the session /// user, never something the client gets to choose. impl AppController { + /// Everything the session user is part of, whichever way. + pub async fn get_my_reservations(&self) -> Result, ControllerError> { + let user = self.user(); + self.db + .get_involved_reservations(user.id, &user.email) + .await + .map_err(Into::into) + } + pub async fn create_reservation( &self, reservation: NewReservation, @@ -71,8 +80,15 @@ impl AppController { } } -/// Touching an existing reservation is reserved to its unit (or an admin) -impl ManagerAppController { +impl AppController { + /// Who may edit, and how much: + /// + /// - the unit's managers and the admins: everything, until the reservation + /// reaches a final state; + /// - anybody the reservation names — the requester, the people on it, the + /// Linka Go addresses — everything while it is still a request, and only + /// the addresses once it has been approved; + /// - anybody else: nothing. pub async fn update_reservation( &self, reservation: ReservationEdit, @@ -82,13 +98,32 @@ impl ManagerAppController { } let current = self.db.get_reservation(reservation.id).await?; - if current.unit.scope() != self.unit { - return Err(ControllerError::ImmutableUnitModificationError); - } if current.status.is_final() { return Err(ReservationsControllerError::ReservationFinal(current.status).into()); } + let user = self.user(); + let manages = user.admin + || current + .unit + .scope() + .is_some_and(|unit| user.units.iter().any(|own| own.id == unit)); + + if !manages { + if !self.is_involved_in(¤t) { + return Err(ReservationsControllerError::NotOnTheReservation.into()); + } + // Past the request stage the period and the fleet are settled, and + // only the list of accounts allowed to unlock stays open. + if current.status != ReservationStatus::Requested + && (reservation.start_time != current.start_time + || reservation.end_time != current.end_time + || !same_bikes(&reservation.bikes, ¤t.bikes)) + { + return Err(ReservationsControllerError::OnlyEmailsEditable(current.status).into()); + } + } + // A bike already on the reservation may well have broken down since: // only a newly added one has to be in service. for id in &reservation.bikes { @@ -106,6 +141,32 @@ impl ManagerAppController { .map_err(Into::into) } + /// The same three ways `get_involved_reservations` looks for, applied to one + /// reservation already in hand. + fn is_involved_in(&self, reservation: &Reservation) -> bool { + let user = self.user(); + reservation.requester == user.id + || reservation.users.iter().any(|other| other.id == user.id) + || reservation + .linka_emails + .iter() + .any(|listed| listed.eq_ignore_ascii_case(&user.email)) + } +} + +/// Order carries no meaning here, so the two lists are compared as sets +fn same_bikes(left: &[i32], right: &[i32]) -> bool { + let mut left = left.to_vec(); + let mut right = right.to_vec(); + left.sort_unstable(); + left.dedup(); + right.sort_unstable(); + right.dedup(); + left == right +} + +/// Touching an existing reservation is reserved to its unit (or an admin) +impl ManagerAppController { pub async fn set_reservation_status( &self, id: ReservationId, @@ -148,4 +209,24 @@ pub enum ReservationsControllerError { BikeOutOfService(i32), #[error("The requester does not belong to unit {0}")] NotAMemberOfUnit(UnitId), + #[error("The user is not part of this reservation")] + NotOnTheReservation, + #[error("A reservation that is {0:?} only accepts a change of Linka Go accounts")] + OnlyEmailsEditable(ReservationStatus), +} + +#[cfg(test)] +mod tests { + use super::same_bikes; + + #[test] + fn bike_lists_compare_as_sets() { + assert!(same_bikes(&[1, 2], &[2, 1]), "order carries no meaning"); + assert!(same_bikes(&[1, 1, 2], &[2, 1]), "nor do repeats"); + assert!(same_bikes(&[], &[])); + + assert!(!same_bikes(&[1, 2], &[1]), "one was removed"); + assert!(!same_bikes(&[1], &[1, 2]), "one was added"); + assert!(!same_bikes(&[1], &[2])); + } } diff --git a/src/core/repositories/reservations_repository.rs b/src/core/repositories/reservations_repository.rs index af5aa21..875fd0a 100644 --- a/src/core/repositories/reservations_repository.rs +++ b/src/core/repositories/reservations_repository.rs @@ -20,6 +20,16 @@ pub trait ReservationsRepository { ) -> Result, RepositoryError>; async fn get_reservation(&self, id: ReservationId) -> Result; + /// Everything the user is part of: what they filed, what they were added + /// to, and what lists their address among the Linka Go accounts. The email + /// is what binds a reservation to somebody who had never logged in when it + /// was filed. + async fn get_involved_reservations( + &self, + user: UserId, + email: &str, + ) -> Result, RepositoryError>; + /// Always stored as `Requested`: the state machine starts here. The /// requester comes from the session, not from the request body. async fn create_reservation( diff --git a/src/services/database/reservations.rs b/src/services/database/reservations.rs index edc1dae..7f08af2 100644 --- a/src/services/database/reservations.rs +++ b/src/services/database/reservations.rs @@ -159,9 +159,15 @@ 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. +/// check rejects. Duplicates are dropped, ignoring case: authorising the same +/// account twice means nothing, and the first spelling is the one kept. fn trim_emails(emails: &[String]) -> Vec { - emails.iter().map(|email| email.trim().to_owned()).collect() + let mut seen = std::collections::HashSet::new(); + emails + .iter() + .map(|email| email.trim().to_owned()) + .filter(|email| seen.insert(email.to_lowercase())) + .collect() } #[async_trait] @@ -257,6 +263,67 @@ impl ReservationsRepository for SqlxDatabase { .collect::, _>>()?) } + async fn get_involved_reservations( + &self, + user: UserId, + email: &str, + ) -> Result, RepositoryError> { + Ok(query_as!( + ReservationDB, + r#"SELECT + r.id, + r.unit_id, + -- `?` 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, + r.telegram, + r."description", + r.linka_emails, + r.status AS "status: ReservationStatusDB", + COALESCE(( + SELECT json_agg(json_build_object( + 'id', u.id, + 'firstname', u.firstname, + 'name', u."name", + 'email', u.email + ) ORDER BY u."name", u.firstname) + FROM reservations_users ru + JOIN users u ON u.id = ru.user_id + WHERE ru.reservation_id = r.id + ), '[]'::json) AS "users!", + ARRAY( + SELECT bike_id FROM reservations_bikes + WHERE reservation_id = r.id ORDER BY bike_id + ) AS "bikes!" + FROM reservations r + LEFT JOIN units un ON un.id = r.unit_id + -- Three ways to be part of a reservation. The last one is what + -- lets somebody added by address see it the first time they log in, + -- without anything having to be written at login time. + WHERE r.requester_id = $1 + OR EXISTS ( + SELECT 1 FROM reservations_users ru + WHERE ru.reservation_id = r.id AND ru.user_id = $1 + ) + OR EXISTS ( + SELECT 1 FROM unnest(r.linka_emails) AS listed + WHERE lower(listed) = lower($2) + ) + ORDER BY r.start_time DESC"#, + user, + email + ) + .fetch_all(&self.pool) + .await? + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?) + } + async fn get_reservation(&self, id: ReservationId) -> Result { Ok(query_as!( ReservationDB,