feat: reservation page final
This commit is contained in:
parent
b50d1fe855
commit
035f82cb88
22 changed files with 389 additions and 73 deletions
16
db/migrations/20260824140000_reservation_linka_emails.sql
Normal file
16
db/migrations/20260824140000_reservation_linka_emails.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
14
db/seed.sql
14
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,10 @@ function move(status: ReservationStatus) {
|
|||
{{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="reservation.linka_emails.length" class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.linka') }}</dt>
|
||||
<dd class="min-w-0 break-words">{{ reservation.linka_emails.join(', ') }}</dd>
|
||||
</div>
|
||||
<div v-if="reservation.description" class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.reason') }}</dt>
|
||||
<dd class="min-w-0 break-words italic">{{ reservation.description }}</dd>
|
||||
|
|
|
|||
94
frontend/src/lib/api.d.ts
vendored
94
frontend/src/lib/api.d.ts
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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é.
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -370,8 +398,15 @@ function submit() {
|
|||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button type="submit">{{ $t('reservation.submit') }}</Button>
|
||||
<Button type="button" variant="outline" @click="reset()">
|
||||
<Button type="submit" :disabled="create.isPending.value">
|
||||
{{ create.isPending.value ? $t('reservation.submitting') : $t('reservation.submit') }}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="create.isPending.value"
|
||||
@click="reset()"
|
||||
>
|
||||
{{ $t('reservation.reset') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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<NewReservation>,
|
||||
) -> Result<(StatusCode, Json<Reservation>), (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<Reservation>, _>(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, ()>()
|
||||
|
|
|
|||
|
|
@ -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<User, ControllerError> {
|
||||
// Consumes the state: a callback can never be replayed
|
||||
let backend_data = self.db.take_whiskey_data(state.clone()).await.map_err(|_| {
|
||||
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(),
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ impl AnonAppController {
|
|||
pub async fn get_bike(&self, id: BikeId) -> Result<Bike, ControllerError> {
|
||||
self.db.get_bike(id).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Changing the fleet is an admin action
|
||||
|
|
|
|||
|
|
@ -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<Reservation, ControllerError> {
|
||||
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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ impl AnonAppController {
|
|||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl AdminAppController {
|
||||
|
|
|
|||
|
|
@ -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<BikeId>,
|
||||
/// 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<String>,
|
||||
pub status: ReservationStatus,
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +121,7 @@ pub struct NewReservation {
|
|||
pub telegram: String,
|
||||
pub description: String,
|
||||
pub bikes: Vec<BikeId>,
|
||||
pub linka_emails: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||
|
|
@ -131,11 +134,36 @@ pub struct ReservationEdit {
|
|||
pub telegram: String,
|
||||
pub description: String,
|
||||
pub bikes: Vec<BikeId>,
|
||||
pub linka_emails: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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()]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ pub trait UsersRepository {
|
|||
async fn upsert_user(&self, user: NewUser) -> Result<User, RepositoryError>;
|
||||
|
||||
/// Replaces the whole set of units the user belongs to
|
||||
async fn set_user_units(&self, id: UserId, units: Vec<UnitId>)
|
||||
-> Result<(), RepositoryError>;
|
||||
async fn set_user_units(&self, id: UserId, units: Vec<UnitId>) -> Result<(), RepositoryError>;
|
||||
|
||||
async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ struct ReservationDB {
|
|||
pub telegram: String,
|
||||
pub description: String,
|
||||
pub status: ReservationStatusDB,
|
||||
pub linka_emails: Vec<String>,
|
||||
pub users: Value,
|
||||
pub bikes: Vec<i32>,
|
||||
}
|
||||
|
|
@ -100,6 +101,7 @@ impl TryFrom<ReservationDB> 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<i32>, Option<String>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
emails.iter().map(|email| email.trim().to_owned()).collect()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ReservationsRepository for SqlxDatabase {
|
||||
async fn get_reservations(&self) -> Result<Vec<Reservation>, 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?;
|
||||
|
|
|
|||
|
|
@ -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<WhiskeyClaims, CoreGenderClaim>| {
|
||||
match request.request_async(get_http_client()).await.map(
|
||||
|claims: openidconnect::UserInfoClaims<WhiskeyClaims, CoreGenderClaim>| {
|
||||
claims.additional_claims().groups.clone()
|
||||
}) {
|
||||
},
|
||||
) {
|
||||
Ok(groups) => groups,
|
||||
Err(err) => {
|
||||
debug!("userinfo request failed: {err:?}");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue