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,
|
status public.reservation_status DEFAULT 'requested'::public.reservation_status NOT NULL,
|
||||||
unit_id integer,
|
unit_id integer,
|
||||||
unit_label text,
|
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_telegram_handle CHECK ((telegram ~ '^@[A-Za-z][A-Za-z0-9_]{4,31}$'::text)),
|
||||||
CONSTRAINT reservations_time_order CHECK ((end_time > start_time)),
|
CONSTRAINT reservations_time_order CHECK ((end_time > start_time)),
|
||||||
CONSTRAINT reservations_unit_label_not_blank CHECK (((unit_label IS NULL) OR (btrim(unit_label) <> ''::text))),
|
CONSTRAINT reservations_unit_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'),
|
('20260823170000'),
|
||||||
('20260823210000'),
|
('20260823210000'),
|
||||||
('20260823230000'),
|
('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
|
-- 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()`.
|
-- the calendar always have something to show. Times are relative to `now()`.
|
||||||
INSERT INTO public.reservations
|
INSERT INTO public.reservations
|
||||||
(id, unit_id, start_time, end_time, requester_id, telegram, "description", 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.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
|
FROM (VALUES
|
||||||
(1, 'agepoly',
|
(1, 'agepoly',
|
||||||
date_trunc('day', now()) - interval '1 day' + interval '8 hours',
|
date_trunc('day', now()) - interval '1 day' + interval '8 hours',
|
||||||
date_trunc('day', now()) + interval '1 day' + interval '18 hours',
|
date_trunc('day', now()) + interval '1 day' + interval '18 hours',
|
||||||
1, '@alice_martin', 'Transport du matériel pour la rentrée',
|
1, '@alice_martin', 'Transport du matériel pour la rentrée',
|
||||||
|
ARRAY['alice.martin@epfl.ch'],
|
||||||
'ongoing'::reservation_status),
|
'ongoing'::reservation_status),
|
||||||
(2, 'clic',
|
(2, 'clic',
|
||||||
date_trunc('day', now()) + interval '1 day' + interval '9 hours',
|
date_trunc('day', now()) + interval '1 day' + interval '9 hours',
|
||||||
date_trunc('day', now()) + interval '2 days' + interval '17 hours',
|
date_trunc('day', now()) + interval '2 days' + interval '17 hours',
|
||||||
3, '@chloe_favre', 'Déménagement du stock de la commission',
|
3, '@chloe_favre', 'Déménagement du stock de la commission',
|
||||||
|
ARRAY['chloe.favre@epfl.ch'],
|
||||||
'approved'::reservation_status),
|
'approved'::reservation_status),
|
||||||
(3, 'agepoly',
|
(3, 'agepoly',
|
||||||
date_trunc('day', now()) + interval '3 days' + interval '10 hours',
|
date_trunc('day', now()) + interval '3 days' + interval '10 hours',
|
||||||
date_trunc('day', now()) + interval '3 days' + interval '19 hours',
|
date_trunc('day', now()) + interval '3 days' + interval '19 hours',
|
||||||
2, '@bob_dupont', 'Livraison des boissons pour la soirée',
|
2, '@bob_dupont', 'Livraison des boissons pour la soirée',
|
||||||
|
ARRAY['bob.dupont@epfl.ch','alice.martin@epfl.ch'],
|
||||||
'requested'::reservation_status),
|
'requested'::reservation_status),
|
||||||
(4, 'clic',
|
(4, 'clic',
|
||||||
date_trunc('day', now()) + interval '4 days' + interval '7 hours',
|
date_trunc('day', now()) + interval '4 days' + interval '7 hours',
|
||||||
date_trunc('day', now()) + interval '4 days' + interval '12 hours',
|
date_trunc('day', now()) + interval '4 days' + interval '12 hours',
|
||||||
3, '@chloe_favre', 'Récupération de matériel informatique',
|
3, '@chloe_favre', 'Récupération de matériel informatique',
|
||||||
|
ARRAY['chloe.favre@epfl.ch'],
|
||||||
'requested'::reservation_status),
|
'requested'::reservation_status),
|
||||||
(5, 'agepoly',
|
(5, 'agepoly',
|
||||||
date_trunc('day', now()) - interval '20 days' + interval '9 hours',
|
date_trunc('day', now()) - interval '20 days' + interval '9 hours',
|
||||||
date_trunc('day', now()) - interval '19 days' + interval '18 hours',
|
date_trunc('day', now()) - interval '19 days' + interval '18 hours',
|
||||||
1, '@alice_martin', 'Ancienne sortie, archivée',
|
1, '@alice_martin', 'Ancienne sortie, archivée',
|
||||||
|
ARRAY['alice.martin@epfl.ch'],
|
||||||
'archived'::reservation_status),
|
'archived'::reservation_status),
|
||||||
(6, 'clic',
|
(6, 'clic',
|
||||||
date_trunc('day', now()) + interval '6 days' + interval '14 hours',
|
date_trunc('day', now()) + interval '6 days' + interval '14 hours',
|
||||||
date_trunc('day', now()) + interval '6 days' + interval '18 hours',
|
date_trunc('day', now()) + interval '6 days' + interval '18 hours',
|
||||||
2, '@bob_dupont', 'Annulée faute de conducteur',
|
2, '@bob_dupont', 'Annulée faute de conducteur',
|
||||||
|
ARRAY['bob.dupont@epfl.ch'],
|
||||||
'cancelled'::reservation_status)
|
'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
|
JOIN public.units u ON u."name" = r.unit_name
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,10 @@ function move(status: ReservationStatus) {
|
||||||
{{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }}
|
{{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</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">
|
<div v-if="reservation.description" class="flex gap-2">
|
||||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.reason') }}</dt>
|
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.reason') }}</dt>
|
||||||
<dd class="min-w-0 break-words italic">{{ reservation.description }}</dd>
|
<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
|
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
|
delete?: never
|
||||||
options?: never
|
options?: never
|
||||||
head?: never
|
head?: never
|
||||||
|
|
@ -557,6 +619,31 @@ export interface components {
|
||||||
/** @description Email of one of the `dev_users` of the configuration */
|
/** @description Email of one of the `dev_users` of the configuration */
|
||||||
user: string
|
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: {
|
PostCallbackParams: {
|
||||||
code: string
|
code: string
|
||||||
state: string
|
state: string
|
||||||
|
|
@ -568,6 +655,11 @@ export interface components {
|
||||||
end_time: string
|
end_time: string
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
id: number
|
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 */
|
/** Format: int32 */
|
||||||
requester: number
|
requester: number
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,9 @@ reservation:
|
||||||
error-telegram: "Invalid Telegram username (example: {'@'}my_username)."
|
error-telegram: "Invalid Telegram username (example: {'@'}my_username)."
|
||||||
error-email: One of the email addresses is invalid.
|
error-email: One of the email addresses is invalid.
|
||||||
error-form: The form contains errors.
|
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:
|
login:
|
||||||
title: Log in
|
title: Log in
|
||||||
intro: Log in with your AGEPoly account to book a cargobike.
|
intro: Log in with your AGEPoly account to book a cargobike.
|
||||||
|
|
@ -96,6 +98,7 @@ admin:
|
||||||
bikes: Cargobike(s)
|
bikes: Cargobike(s)
|
||||||
telegram: Telegram
|
telegram: Telegram
|
||||||
people: People
|
people: People
|
||||||
|
linka: Linka Go accounts
|
||||||
reason: Reason
|
reason: Reason
|
||||||
load-error: Unable to load the reservations.
|
load-error: Unable to load the reservations.
|
||||||
error: The status change failed.
|
error: The status change failed.
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,9 @@ reservation:
|
||||||
error-telegram: "Username Telegram invalide (exemple : {'@'}mon_username)."
|
error-telegram: "Username Telegram invalide (exemple : {'@'}mon_username)."
|
||||||
error-email: Une des adresses e-mail est invalide.
|
error-email: Une des adresses e-mail est invalide.
|
||||||
error-form: Le formulaire contient des erreurs.
|
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:
|
login:
|
||||||
title: Connexion
|
title: Connexion
|
||||||
intro: Connectez-vous avec votre compte AGEPoly pour réserver un cargobike.
|
intro: Connectez-vous avec votre compte AGEPoly pour réserver un cargobike.
|
||||||
|
|
@ -97,6 +99,7 @@ admin:
|
||||||
bikes: Cargobike(s)
|
bikes: Cargobike(s)
|
||||||
telegram: Telegram
|
telegram: Telegram
|
||||||
people: Personnes
|
people: Personnes
|
||||||
|
linka: Comptes Linka Go
|
||||||
reason: Raison
|
reason: Raison
|
||||||
load-error: Impossible de charger les réservations.
|
load-error: Impossible de charger les réservations.
|
||||||
error: Le changement de statut a échoué.
|
error: Le changement de statut a échoué.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* Reservations, from the administration side. Reading the whole list is an
|
* Reservations. Filing a request only needs a session; reading the whole list is
|
||||||
* admin action, so these hooks are only ever mounted on /admin.
|
* an admin action, so `useReservations` is only ever mounted on /admin.
|
||||||
*/
|
*/
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { HttpStatus } from 'http-status-ts'
|
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'
|
import { getClient } from './client'
|
||||||
|
|
||||||
export const RESERVATIONS_KEY = ['reservations']
|
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 ReservationStatus = components['schemas']['ReservationStatus']
|
||||||
export type UserSummary = components['schemas']['UserSummary']
|
export type UserSummary = components['schemas']['UserSummary']
|
||||||
export type ReservationUnit = components['schemas']['ReservationUnit']
|
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,
|
* 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 { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { useBikes } from '@/services/api/bikes'
|
import { useBikes } from '@/services/api/bikes'
|
||||||
|
import { useCreateReservation } from '@/services/api/reservations'
|
||||||
import { useSession } from '@/services/api/auth'
|
import { useSession } from '@/services/api/auth'
|
||||||
import type { Bike } from '@/utils/types'
|
import type { Bike, NewReservation } from '@/utils/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|
@ -156,6 +157,27 @@ function reset() {
|
||||||
submitted.value = false
|
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() {
|
function submit() {
|
||||||
submitted.value = true
|
submitted.value = true
|
||||||
if (!validate()) {
|
if (!validate()) {
|
||||||
|
|
@ -163,9 +185,15 @@ function submit() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: replace with a `useCreateReservation` mutation once
|
create.mutate(payload(), {
|
||||||
// `POST /api/reservations` exists.
|
onSuccess: () => {
|
||||||
toast.info(t('reservation.not-implemented'))
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
@ -370,8 +398,15 @@ function submit() {
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button type="submit">{{ $t('reservation.submit') }}</Button>
|
<Button type="submit" :disabled="create.isPending.value">
|
||||||
<Button type="button" variant="outline" @click="reset()">
|
{{ create.isPending.value ? $t('reservation.submitting') : $t('reservation.submit') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="create.isPending.value"
|
||||||
|
@click="reset()"
|
||||||
|
>
|
||||||
{{ $t('reservation.reset') }}
|
{{ $t('reservation.reset') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,7 @@ async fn set_status(
|
||||||
) -> Result<(), (StatusCode, String)> {
|
) -> Result<(), (StatusCode, String)> {
|
||||||
match admin(ac)?.set_bike_status(id, status).await {
|
match admin(ac)?.set_bike_status(id, status).await {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(err) if err.is_not_found() => {
|
Err(err) if err.is_not_found() => Err((StatusCode::NOT_FOUND, "No such bike".to_owned())),
|
||||||
Err((StatusCode::NOT_FOUND, "No such bike".to_owned()))
|
|
||||||
}
|
|
||||||
Err(err) => unexpected_error("set_bike_status", err),
|
Err(err) => unexpected_error("set_bike_status", err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use aide::transform::TransformResponse;
|
use aide::transform::TransformResponse;
|
||||||
|
use axum::http::StatusCode;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use axum::http::StatusCode;
|
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use crate::core::{
|
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
|
//! reserved to the unit the reservation belongs to (an admin manages every
|
||||||
//! unit), which is why the handler resolves the unit before narrowing the
|
//! unit), which is why the handler resolves the unit before narrowing the
|
||||||
//! controller down.
|
//! controller down.
|
||||||
|
|
@ -12,25 +13,25 @@ use aide::{
|
||||||
},
|
},
|
||||||
transform::TransformOperation,
|
transform::TransformOperation,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{Json, extract::Path, http::StatusCode};
|
||||||
Json,
|
|
||||||
extract::Path,
|
|
||||||
http::StatusCode,
|
|
||||||
};
|
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::{
|
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::{
|
core::{
|
||||||
controller::{AppController, ControllerError, reservations::ReservationsControllerError},
|
controller::{AppController, ControllerError, reservations::ReservationsControllerError},
|
||||||
models::reservation::{Reservation, ReservationStatus},
|
models::reservation::{NewReservation, Reservation, ReservationStatus},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> ApiRouter {
|
pub fn routes() -> ApiRouter {
|
||||||
ApiRouter::new()
|
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))
|
.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)
|
.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)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct SetStatusForm {
|
struct SetStatusForm {
|
||||||
status: ReservationStatus,
|
status: ReservationStatus,
|
||||||
|
|
@ -75,9 +117,9 @@ async fn set_status(
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(ControllerError::Reservation(err @ ReservationsControllerError::InvalidTransition(..))) => {
|
Err(ControllerError::Reservation(
|
||||||
Err((StatusCode::CONFLICT, err.to_string()))
|
err @ ReservationsControllerError::InvalidTransition(..),
|
||||||
}
|
)) => Err((StatusCode::CONFLICT, err.to_string())),
|
||||||
Err(err) => unexpected_error("set_status", err),
|
Err(err) => unexpected_error("set_status", err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -85,9 +127,7 @@ async fn set_status(
|
||||||
fn set_status_docs(op: TransformOperation) -> TransformOperation {
|
fn set_status_docs(op: TransformOperation) -> TransformOperation {
|
||||||
op.tag("Reservations")
|
op.tag("Reservations")
|
||||||
.summary("Move a reservation through its state machine")
|
.summary("Move a reservation through its state machine")
|
||||||
.description(
|
.description("Refuses a transition the state machine does not allow, with a 409.")
|
||||||
"Refuses a transition the state machine does not allow, with a 409.",
|
|
||||||
)
|
|
||||||
.response_with::<403, (), _>(manager_desc)
|
.response_with::<403, (), _>(manager_desc)
|
||||||
.response::<404, ()>()
|
.response::<404, ()>()
|
||||||
.response::<409, ()>()
|
.response::<409, ()>()
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,7 @@ impl AnonAppController {
|
||||||
self.db.save_whiskey_data(authorize_backend_data).await?;
|
self.db.save_whiskey_data(authorize_backend_data).await?;
|
||||||
Ok(redirect_to)
|
Ok(redirect_to)
|
||||||
}
|
}
|
||||||
Err(WhiskeyError::ProtocolError) => {
|
Err(WhiskeyError::ProtocolError) => Err(AuthnControllerError::OIDCProtocolError.into()),
|
||||||
Err(AuthnControllerError::OIDCProtocolError.into())
|
|
||||||
}
|
|
||||||
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
|
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
|
||||||
"Originated from Whiskey".to_owned(),
|
"Originated from Whiskey".to_owned(),
|
||||||
)),
|
)),
|
||||||
|
|
@ -93,16 +91,18 @@ impl AnonAppController {
|
||||||
state: String,
|
state: String,
|
||||||
) -> Result<User, ControllerError> {
|
) -> Result<User, ControllerError> {
|
||||||
// Consumes the state: a callback can never be replayed
|
// 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
|
||||||
debug!("unknown, already used or expired oidc state");
|
.db
|
||||||
AuthnControllerError::OIDCProtocolError
|
.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 {
|
match callback(code, state, backend_data).await {
|
||||||
Ok(user_info) => self.upsert_oidc_user(user_info).await,
|
Ok(user_info) => self.upsert_oidc_user(user_info).await,
|
||||||
Err(WhiskeyError::ProtocolError) => {
|
Err(WhiskeyError::ProtocolError) => Err(AuthnControllerError::OIDCProtocolError.into()),
|
||||||
Err(AuthnControllerError::OIDCProtocolError.into())
|
|
||||||
}
|
|
||||||
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
|
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
|
||||||
"originated from Whiskey".to_owned(),
|
"originated from Whiskey".to_owned(),
|
||||||
)),
|
)),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ impl AnonAppController {
|
||||||
pub async fn get_bike(&self, id: BikeId) -> Result<Bike, ControllerError> {
|
pub async fn get_bike(&self, id: BikeId) -> Result<Bike, ControllerError> {
|
||||||
self.db.get_bike(id).await.map_err(Into::into)
|
self.db.get_bike(id).await.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Changing the fleet is an admin action
|
/// Changing the fleet is an admin action
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@ use crate::core::{
|
||||||
models::{
|
models::{
|
||||||
bike::BikeStatus,
|
bike::BikeStatus,
|
||||||
reservation::{
|
reservation::{
|
||||||
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
|
NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId,
|
||||||
|
ReservationStatus,
|
||||||
},
|
},
|
||||||
unit::UnitId,
|
unit::UnitId,
|
||||||
},
|
},
|
||||||
|
repositories::RepositoryError,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Reading the reservations needs no session: the calendar is public.
|
/// 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> {
|
pub async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, ControllerError> {
|
||||||
self.db.get_reservation(id).await.map_err(Into::into)
|
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
|
/// 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() {
|
if !reservation.is_valid() {
|
||||||
return Err(ReservationsControllerError::ReservationInvalid.into());
|
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
|
// A bike out of service cannot be booked
|
||||||
for id in &reservation.bikes {
|
for id in &reservation.bikes {
|
||||||
if self.get_bike(*id).await?.status == BikeStatus::OutOfService {
|
if self.get_bike(*id).await?.status == BikeStatus::OutOfService {
|
||||||
|
|
@ -120,4 +135,6 @@ pub enum ReservationsControllerError {
|
||||||
ReservationFinal(ReservationStatus),
|
ReservationFinal(ReservationStatus),
|
||||||
#[error("Bike {0} is out of service and cannot be booked")]
|
#[error("Bike {0} is out of service and cannot be booked")]
|
||||||
BikeOutOfService(i32),
|
BikeOutOfService(i32),
|
||||||
|
#[error("The requester does not belong to unit {0}")]
|
||||||
|
NotAMemberOfUnit(UnitId),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ impl AnonAppController {
|
||||||
.await
|
.await
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdminAppController {
|
impl AdminAppController {
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ impl ReservationStatus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// The unit a reservation is filed for.
|
/// The unit a reservation is filed for.
|
||||||
///
|
///
|
||||||
/// Either one we know — a Whiskey group, with its row — or a plain name the
|
/// 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 telegram: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub bikes: Vec<BikeId>,
|
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,
|
pub status: ReservationStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,6 +121,7 @@ pub struct NewReservation {
|
||||||
pub telegram: String,
|
pub telegram: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub bikes: Vec<BikeId>,
|
pub bikes: Vec<BikeId>,
|
||||||
|
pub linka_emails: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||||
|
|
@ -131,11 +134,36 @@ pub struct ReservationEdit {
|
||||||
pub telegram: String,
|
pub telegram: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub bikes: Vec<BikeId>,
|
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 {
|
impl NewReservation {
|
||||||
pub fn is_valid(&self) -> bool {
|
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.end_time > self.start_time
|
||||||
&& !self.bikes.is_empty()
|
&& !self.bikes.is_empty()
|
||||||
&& !self.users.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>;
|
async fn upsert_user(&self, user: NewUser) -> Result<User, RepositoryError>;
|
||||||
|
|
||||||
/// Replaces the whole set of units the user belongs to
|
/// Replaces the whole set of units the user belongs to
|
||||||
async fn set_user_units(&self, id: UserId, units: Vec<UnitId>)
|
async fn set_user_units(&self, id: UserId, units: Vec<UnitId>) -> Result<(), RepositoryError>;
|
||||||
-> Result<(), RepositoryError>;
|
|
||||||
|
|
||||||
async fn set_user_admin(&self, id: UserId, admin: bool) -> 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,
|
// 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.
|
// 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)
|
// (`fallback` and not `not_found_service`, which would force a 404 status)
|
||||||
let app = api::get_router(aac).fallback_service(
|
let app = api::get_router(aac).fallback_service(ServeDir::new(&config.frontend_dir).fallback(
|
||||||
ServeDir::new(&config.frontend_dir).fallback(ServeFile::new(format!(
|
ServeFile::new(format!("{}/index.html", config.frontend_dir)),
|
||||||
"{}/index.html",
|
));
|
||||||
config.frontend_dir
|
|
||||||
))),
|
|
||||||
);
|
|
||||||
|
|
||||||
let bind_address = config.get_bind_address();
|
let bind_address = config.get_bind_address();
|
||||||
let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap();
|
let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap();
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ struct ReservationDB {
|
||||||
pub telegram: String,
|
pub telegram: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub status: ReservationStatusDB,
|
pub status: ReservationStatusDB,
|
||||||
|
pub linka_emails: Vec<String>,
|
||||||
pub users: Value,
|
pub users: Value,
|
||||||
pub bikes: Vec<i32>,
|
pub bikes: Vec<i32>,
|
||||||
}
|
}
|
||||||
|
|
@ -100,6 +101,7 @@ impl TryFrom<ReservationDB> for Reservation {
|
||||||
telegram: value.telegram,
|
telegram: value.telegram,
|
||||||
description: value.description,
|
description: value.description,
|
||||||
bikes: value.bikes,
|
bikes: value.bikes,
|
||||||
|
linka_emails: value.linka_emails,
|
||||||
status: value.status.into(),
|
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]
|
#[async_trait]
|
||||||
impl ReservationsRepository for SqlxDatabase {
|
impl ReservationsRepository for SqlxDatabase {
|
||||||
async fn get_reservations(&self) -> Result<Vec<Reservation>, RepositoryError> {
|
async fn get_reservations(&self) -> Result<Vec<Reservation>, RepositoryError> {
|
||||||
|
|
@ -172,6 +181,7 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
r.requester_id,
|
r.requester_id,
|
||||||
r.telegram,
|
r.telegram,
|
||||||
r."description",
|
r."description",
|
||||||
|
r.linka_emails,
|
||||||
r.status AS "status: ReservationStatusDB",
|
r.status AS "status: ReservationStatusDB",
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object(
|
SELECT json_agg(json_build_object(
|
||||||
|
|
@ -217,6 +227,7 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
r.requester_id,
|
r.requester_id,
|
||||||
r.telegram,
|
r.telegram,
|
||||||
r."description",
|
r."description",
|
||||||
|
r.linka_emails,
|
||||||
r.status AS "status: ReservationStatusDB",
|
r.status AS "status: ReservationStatusDB",
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object(
|
SELECT json_agg(json_build_object(
|
||||||
|
|
@ -261,6 +272,7 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
r.requester_id,
|
r.requester_id,
|
||||||
r.telegram,
|
r.telegram,
|
||||||
r."description",
|
r."description",
|
||||||
|
r.linka_emails,
|
||||||
r.status AS "status: ReservationStatusDB",
|
r.status AS "status: ReservationStatusDB",
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object(
|
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
|
// No status here: the column defaults to 'requested', the start of the
|
||||||
// state machine.
|
// state machine.
|
||||||
let (unit_id, unit_label) = split_unit(&reservation.unit);
|
let (unit_id, unit_label) = split_unit(&reservation.unit);
|
||||||
|
let linka_emails = trim_emails(&reservation.linka_emails);
|
||||||
let id = query!(
|
let id = query!(
|
||||||
r#"INSERT INTO reservations
|
r#"INSERT INTO reservations
|
||||||
(unit_id, unit_label, start_time, end_time, requester_id, telegram, "description")
|
(unit_id, unit_label, start_time, end_time, requester_id, telegram,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
"description", linka_emails)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id"#,
|
RETURNING id"#,
|
||||||
unit_id,
|
unit_id,
|
||||||
unit_label,
|
unit_label,
|
||||||
|
|
@ -308,7 +322,8 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
reservation.end_time,
|
reservation.end_time,
|
||||||
requester,
|
requester,
|
||||||
reservation.telegram,
|
reservation.telegram,
|
||||||
reservation.description
|
reservation.description,
|
||||||
|
&linka_emails
|
||||||
)
|
)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await?
|
.await?
|
||||||
|
|
@ -336,10 +351,11 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
let (unit_id, unit_label) = split_unit(&reservation.unit);
|
let (unit_id, unit_label) = split_unit(&reservation.unit);
|
||||||
|
let linka_emails = trim_emails(&reservation.linka_emails);
|
||||||
let result = query!(
|
let result = query!(
|
||||||
r#"UPDATE reservations
|
r#"UPDATE reservations
|
||||||
SET unit_id = $2, unit_label = $3, start_time = $4, end_time = $5,
|
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"#,
|
WHERE id = $1"#,
|
||||||
reservation.id,
|
reservation.id,
|
||||||
unit_id,
|
unit_id,
|
||||||
|
|
@ -347,7 +363,8 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
reservation.start_time,
|
reservation.start_time,
|
||||||
reservation.end_time,
|
reservation.end_time,
|
||||||
reservation.telegram,
|
reservation.telegram,
|
||||||
reservation.description
|
reservation.description,
|
||||||
|
&linka_emails
|
||||||
)
|
)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,7 @@ async fn get_client() -> &'static Client {
|
||||||
ClientId::new(config.oidc.client_id),
|
ClientId::new(config.oidc.client_id),
|
||||||
Some(ClientSecret::new(config.oidc.client_secret)),
|
Some(ClientSecret::new(config.oidc.client_secret)),
|
||||||
)
|
)
|
||||||
.set_redirect_uri(
|
.set_redirect_uri(RedirectUrl::new(redirect_url).unwrap())
|
||||||
RedirectUrl::new(redirect_url).unwrap(),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -247,12 +245,11 @@ async fn groups_from_userinfo(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match request
|
match request.request_async(get_http_client()).await.map(
|
||||||
.request_async(get_http_client())
|
|claims: openidconnect::UserInfoClaims<WhiskeyClaims, CoreGenderClaim>| {
|
||||||
.await
|
|
||||||
.map(|claims: openidconnect::UserInfoClaims<WhiskeyClaims, CoreGenderClaim>| {
|
|
||||||
claims.additional_claims().groups.clone()
|
claims.additional_claims().groups.clone()
|
||||||
}) {
|
},
|
||||||
|
) {
|
||||||
Ok(groups) => groups,
|
Ok(groups) => groups,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
debug!("userinfo request failed: {err:?}");
|
debug!("userinfo request failed: {err:?}");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue