+
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,