Compare commits
3 commits
1ae4ba3a58
...
035f82cb88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
035f82cb88 | ||
|
|
b50d1fe855 | ||
|
|
754d2ddfc4 |
46 changed files with 2471 additions and 590 deletions
|
|
@ -29,6 +29,10 @@ spot rather than rejected: an unknown unit must never break a login.
|
||||||
In a debug build, `dev_users` from the configuration can be logged in through
|
In a debug build, `dev_users` from the configuration can be logged in through
|
||||||
`POST /api/login` without going through the provider — see the login page.
|
`POST /api/login` without going through the provider — see the login page.
|
||||||
|
|
||||||
|
`/reservations` needs a session and `/admin` needs an admin. The router guards are a
|
||||||
|
convenience only: every protected route answers 401 or 403 on its own, whatever the
|
||||||
|
frontend does.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
27
db/migrations/20260823230000_reservation_free_unit.sql
Normal file
27
db/migrations/20260823230000_reservation_free_unit.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
-- migrate:up
|
||||||
|
|
||||||
|
-- A reservation names either a unit we know (Whiskey group, `unit_id`) or a
|
||||||
|
-- free text the requester typed (`unit_label`). Typing a name must never create
|
||||||
|
-- a `units` row, so the two are exclusive rather than the second being a
|
||||||
|
-- fallback name for the first.
|
||||||
|
ALTER TABLE reservations ALTER COLUMN unit_id DROP NOT NULL;
|
||||||
|
ALTER TABLE reservations ADD COLUMN unit_label TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE reservations ADD CONSTRAINT reservations_unit_xor CHECK (
|
||||||
|
(unit_id IS NULL) <> (unit_label IS NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A free label is a name, not an empty string
|
||||||
|
ALTER TABLE reservations ADD CONSTRAINT reservations_unit_label_not_blank CHECK (
|
||||||
|
unit_label IS NULL OR btrim(unit_label) <> ''
|
||||||
|
);
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
|
||||||
|
ALTER TABLE reservations DROP CONSTRAINT reservations_unit_label_not_blank;
|
||||||
|
ALTER TABLE reservations DROP CONSTRAINT reservations_unit_xor;
|
||||||
|
|
||||||
|
-- The rows that only had a free label have no unit to point at any more
|
||||||
|
DELETE FROM reservations WHERE unit_id IS NULL;
|
||||||
|
ALTER TABLE reservations DROP COLUMN unit_label;
|
||||||
|
ALTER TABLE reservations ALTER COLUMN unit_id SET NOT NULL;
|
||||||
18
db/migrations/20260824120000_bike_size.sql
Normal file
18
db/migrations/20260824120000_bike_size.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- migrate:up
|
||||||
|
|
||||||
|
-- The fleet mixes two frame sizes and the reservation form asks for one of
|
||||||
|
-- them, so the size is an attribute of the bike rather than something guessed
|
||||||
|
-- from its name.
|
||||||
|
CREATE TYPE bike_size AS ENUM ('large', 'small');
|
||||||
|
|
||||||
|
ALTER TABLE bikes ADD COLUMN "size" bike_size NOT NULL DEFAULT 'small';
|
||||||
|
|
||||||
|
-- The current fleet: 1000 and 2000 are the large ones, 3000/4000/5000 the small
|
||||||
|
UPDATE bikes SET "size" = 'large' WHERE "name" IN ('1000', '2000');
|
||||||
|
|
||||||
|
-- Every bike must state its size, so no value is implied from now on
|
||||||
|
ALTER TABLE bikes ALTER COLUMN "size" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
ALTER TABLE bikes DROP COLUMN "size";
|
||||||
|
DROP TYPE bike_size;
|
||||||
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;
|
||||||
|
|
@ -15,6 +15,16 @@ SET xmloption = content;
|
||||||
SET client_min_messages = warning;
|
SET client_min_messages = warning;
|
||||||
SET row_security = off;
|
SET row_security = off;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bike_size; Type: TYPE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TYPE public.bike_size AS ENUM (
|
||||||
|
'large',
|
||||||
|
'small'
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: bike_status; Type: TYPE; Schema: public; Owner: -
|
-- Name: bike_status; Type: TYPE; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
@ -55,6 +65,7 @@ CREATE TABLE public.bikes (
|
||||||
drivetrain text,
|
drivetrain text,
|
||||||
battery text,
|
battery text,
|
||||||
status public.bike_status DEFAULT 'in_service'::public.bike_status NOT NULL,
|
status public.bike_status DEFAULT 'in_service'::public.bike_status NOT NULL,
|
||||||
|
size public.bike_size NOT NULL,
|
||||||
CONSTRAINT bikes_key_quantity_positive CHECK ((key_quantity >= 0))
|
CONSTRAINT bikes_key_quantity_positive CHECK ((key_quantity >= 0))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -102,9 +113,14 @@ CREATE TABLE public.reservations (
|
||||||
telegram text NOT NULL,
|
telegram text NOT NULL,
|
||||||
description text DEFAULT ''::text NOT NULL,
|
description text DEFAULT ''::text NOT NULL,
|
||||||
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 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_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_xor CHECK (((unit_id IS NULL) <> (unit_label IS NULL)))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -486,4 +502,7 @@ INSERT INTO public.schema_migrations (version) VALUES
|
||||||
('20260823153310'),
|
('20260823153310'),
|
||||||
('20260823153320'),
|
('20260823153320'),
|
||||||
('20260823170000'),
|
('20260823170000'),
|
||||||
('20260823210000');
|
('20260823210000'),
|
||||||
|
('20260823230000'),
|
||||||
|
('20260824120000'),
|
||||||
|
('20260824140000');
|
||||||
|
|
|
||||||
66
db/seed.sql
66
db/seed.sql
|
|
@ -31,32 +31,68 @@ FROM (VALUES
|
||||||
JOIN public.units u ON u."name" = membership.unit_name
|
JOIN public.units u ON u."name" = membership.unit_name
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, battery, status) VALUES
|
INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, battery, "size", status) VALUES
|
||||||
(1, '1000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'),
|
(1, '1000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'large', 'in_service'),
|
||||||
(2, '2000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service'),
|
(2, '2000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'large', 'in_service'),
|
||||||
(3, '3000', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'out_of_service'),
|
(3, '3000', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'small', 'out_of_service'),
|
||||||
(4, '4000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'),
|
(4, '4000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'small', 'in_service'),
|
||||||
(5, '5000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service')
|
(5, '5000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'small', 'in_service')
|
||||||
ON CONFLICT DO NOTHING;
|
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
|
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', '2026-09-01 08:00:00+02'::timestamptz, '2026-09-01 18:00:00+02'::timestamptz,
|
(1, 'agepoly',
|
||||||
1, '@alice_martin', 'Transport du matériel pour la rentrée', 'approved'::reservation_status),
|
date_trunc('day', now()) - interval '1 day' + interval '8 hours',
|
||||||
(2, 'clic', '2026-09-05 09:00:00+02'::timestamptz, '2026-09-06 17:00:00+02'::timestamptz,
|
date_trunc('day', now()) + interval '1 day' + interval '18 hours',
|
||||||
3, '@chloe_favre', 'Déménagement du stock de la commission', 'requested'::reservation_status)
|
1, '@alice_martin', 'Transport du matériel pour la rentrée',
|
||||||
) AS r (id, unit_name, start_time, end_time, requester_id, telegram, "description", status)
|
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",
|
||||||
|
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;
|
||||||
|
|
||||||
INSERT INTO public.reservations_users (reservation_id, user_id) VALUES
|
INSERT INTO public.reservations_users (reservation_id, user_id) VALUES
|
||||||
(1, 1), (1, 2), (2, 3)
|
(1, 1), (1, 2), (2, 3), (3, 2), (3, 1), (4, 3), (5, 1), (6, 2)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO public.reservations_bikes (reservation_id, bike_id) VALUES
|
INSERT INTO public.reservations_bikes (reservation_id, bike_id) VALUES
|
||||||
(1, 1), (1, 2), (2, 1)
|
(1, 1), (1, 2), (2, 3), (3, 1), (3, 4), (4, 2), (5, 5), (6, 4)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Keep the sequences in sync with the explicit ids inserted above
|
-- Keep the sequences in sync with the explicit ids inserted above
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { LogOut, Menu, Moon, Sun, User } from '@lucide/vue'
|
import { LogOut, Menu, Moon, Sun, User } from '@lucide/vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
@ -23,14 +23,16 @@ const router = useRouter()
|
||||||
|
|
||||||
const flags: Record<Locale, string> = { fr: '🇫🇷', en: '🇬🇧' }
|
const flags: Record<Locale, string> = { fr: '🇫🇷', en: '🇬🇧' }
|
||||||
|
|
||||||
const nav = [
|
const { user, isLoggedIn } = useSession()
|
||||||
|
|
||||||
|
const nav = computed(() => [
|
||||||
{ name: 'reservations', label: 'header.reserve' },
|
{ name: 'reservations', label: 'header.reserve' },
|
||||||
{ name: 'calendar', label: 'header.calendar' },
|
{ name: 'calendar', label: 'header.calendar' },
|
||||||
]
|
...(user.value?.admin ? [{ name: 'admin', label: 'header.admin' }] : []),
|
||||||
|
])
|
||||||
|
|
||||||
const menuOpen = ref(false)
|
const menuOpen = ref(false)
|
||||||
|
|
||||||
const { user, isLoggedIn } = useSession()
|
|
||||||
const logoutMutation = useLogoutMutation()
|
const logoutMutation = useLogoutMutation()
|
||||||
|
|
||||||
function login() {
|
function login() {
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,10 @@ import { Calendar } from '@/components/ui/calendar'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
|
||||||
const model = defineModel<DateValue | undefined>()
|
const model = defineModel<DateValue | undefined>()
|
||||||
defineProps<{ id?: string; minValue?: DateValue; invalid?: boolean }>()
|
defineProps<{ id?: string; minValue?: DateValue; maxValue?: DateValue; invalid?: boolean }>()
|
||||||
|
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
|
|
||||||
// The popover is controlled so that picking a day closes it
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
|
|
||||||
const intlLocale = computed(() => (locale.value === 'fr' ? 'fr-CH' : 'en-GB'))
|
const intlLocale = computed(() => (locale.value === 'fr' ? 'fr-CH' : 'en-GB'))
|
||||||
|
|
@ -42,6 +41,7 @@ const formatter = computed(() => new DateFormatter(intlLocale.value, { dateStyle
|
||||||
v-model="model"
|
v-model="model"
|
||||||
:locale="intlLocale"
|
:locale="intlLocale"
|
||||||
:min-value="minValue"
|
:min-value="minValue"
|
||||||
|
:max-value="maxValue"
|
||||||
initial-focus
|
initial-focus
|
||||||
@update:model-value="open = false"
|
@update:model-value="open = false"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
31
frontend/src/components/TelegramInput.vue
Normal file
31
frontend/src/components/TelegramInput.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
|
const model = defineModel<string>({ default: '' })
|
||||||
|
defineProps<{ id?: string; invalid?: boolean }>()
|
||||||
|
|
||||||
|
function clean(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/^\s*(?:https?:\/\/)?(?:t\.me\/|telegram\.me\/)?/i, '')
|
||||||
|
.replace(/^@+/, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="border-input focus-within:border-ring focus-within:ring-ring/50 flex h-9 w-full items-center rounded-md border shadow-xs focus-within:ring-[3px]"
|
||||||
|
:class="invalid ? 'border-destructive' : ''"
|
||||||
|
>
|
||||||
|
<span class="text-muted-foreground select-none pl-3 text-sm" aria-hidden="true">@</span>
|
||||||
|
<Input
|
||||||
|
:id="id"
|
||||||
|
:model-value="model"
|
||||||
|
class="h-8 border-0 pl-0.5 shadow-none focus-visible:ring-0"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
:placeholder="$t('reservation.telegram-placeholder')"
|
||||||
|
@update:model-value="model = clean(String($event))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
74
frontend/src/components/UnitPicker.vue
Normal file
74
frontend/src/components/UnitPicker.vue
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import type { Unit } from '@/utils/types'
|
||||||
|
|
||||||
|
export type UnitChoice =
|
||||||
|
| { kind: 'known'; id: number; name: string }
|
||||||
|
| { kind: 'free'; name: string }
|
||||||
|
|
||||||
|
const model = defineModel<UnitChoice | undefined>()
|
||||||
|
const props = defineProps<{ units: Unit[]; id?: string; invalid?: boolean }>()
|
||||||
|
|
||||||
|
const FREE = '__free__'
|
||||||
|
|
||||||
|
const selected = ref<string>(
|
||||||
|
model.value?.kind === 'known' ? String(model.value.id) : model.value ? FREE : '',
|
||||||
|
)
|
||||||
|
const freeName = ref(model.value?.kind === 'free' ? model.value.name : '')
|
||||||
|
|
||||||
|
const onlyFree = computed(() => props.units.length === 0)
|
||||||
|
const showFreeInput = computed(() => onlyFree.value || selected.value === FREE)
|
||||||
|
|
||||||
|
function emitChoice() {
|
||||||
|
if (showFreeInput.value) {
|
||||||
|
const name = freeName.value.trim()
|
||||||
|
model.value = name ? { kind: 'free', name } : undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const unit = props.units.find((u) => String(u.id) === selected.value)
|
||||||
|
model.value = unit ? { kind: 'known', id: unit.id, name: unit.name } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([selected, freeName], emitChoice)
|
||||||
|
|
||||||
|
watch(model, (choice) => {
|
||||||
|
if (choice === undefined && (selected.value || freeName.value)) {
|
||||||
|
selected.value = ''
|
||||||
|
freeName.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Select v-if="!onlyFree" v-model="selected">
|
||||||
|
<SelectTrigger :id="id" :aria-invalid="invalid || undefined" class="w-full">
|
||||||
|
<SelectValue :placeholder="$t('reservation.association-placeholder')" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem v-for="unit in units" :key="unit.id" :value="String(unit.id)">
|
||||||
|
{{ unit.name }}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem :value="FREE">{{ $t('reservation.association-other') }}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
v-if="showFreeInput"
|
||||||
|
:id="onlyFree ? id : undefined"
|
||||||
|
v-model="freeName"
|
||||||
|
:aria-label="$t('reservation.association')"
|
||||||
|
:placeholder="$t('reservation.association-placeholder')"
|
||||||
|
:aria-invalid="invalid || undefined"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
236
frontend/src/components/admin/BikeCalendar.vue
Normal file
236
frontend/src/components/admin/BikeCalendar.vue
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { unitLabel, type Bike, type Reservation } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ reservations: Reservation[]; bikes: Bike[] }>()
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
const FIRST_HOUR = 7
|
||||||
|
const LAST_HOUR = 20
|
||||||
|
const HOUR_HEIGHT = 44
|
||||||
|
|
||||||
|
const weekStart = ref(startOfWeek(new Date()))
|
||||||
|
const selectedBike = ref<string>('all')
|
||||||
|
|
||||||
|
function startOfWeek(date: Date) {
|
||||||
|
const start = new Date(date)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
|
||||||
|
return start
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftWeek(weeks: number) {
|
||||||
|
const next = new Date(weekStart.value)
|
||||||
|
next.setDate(next.getDate() + weeks * 7)
|
||||||
|
weekStart.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = computed(() =>
|
||||||
|
Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const day = new Date(weekStart.value)
|
||||||
|
day.setDate(day.getDate() + i)
|
||||||
|
return day
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const intlLocale = computed(() => (locale.value === 'fr' ? 'fr-CH' : 'en-GB'))
|
||||||
|
const dayFormatter = computed(
|
||||||
|
() =>
|
||||||
|
new Intl.DateTimeFormat(intlLocale.value, {
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const rangeFormatter = computed(
|
||||||
|
() =>
|
||||||
|
new Intl.DateTimeFormat(intlLocale.value, {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const range = computed(() => {
|
||||||
|
const end = new Date(weekStart.value)
|
||||||
|
end.setDate(end.getDate() + 6)
|
||||||
|
return `${rangeFormatter.value.format(weekStart.value)} – ${rangeFormatter.value.format(end)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const hours = computed(() =>
|
||||||
|
Array.from({ length: LAST_HOUR - FIRST_HOUR }, (_, i) => FIRST_HOUR + i),
|
||||||
|
)
|
||||||
|
|
||||||
|
const lanes = computed(() =>
|
||||||
|
selectedBike.value === 'all'
|
||||||
|
? props.bikes
|
||||||
|
: props.bikes.filter((bike) => String(bike.id) === selectedBike.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Only what actually holds a bike ends up on the planning */
|
||||||
|
const booked = computed(() =>
|
||||||
|
props.reservations.filter((r) => r.status === 'approved' || r.status === 'ongoing'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
function isToday(day: Date) {
|
||||||
|
return day.toDateString() === today.toDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
type Block = { id: number; top: number; height: number; label: string }
|
||||||
|
|
||||||
|
function blocksFor(day: Date, bike: Bike): Block[] {
|
||||||
|
const dayStart = new Date(day)
|
||||||
|
dayStart.setHours(FIRST_HOUR, 0, 0, 0)
|
||||||
|
const dayEnd = new Date(day)
|
||||||
|
dayEnd.setHours(LAST_HOUR, 0, 0, 0)
|
||||||
|
|
||||||
|
return booked.value
|
||||||
|
.filter((reservation) => reservation.bikes.includes(bike.id))
|
||||||
|
.flatMap((reservation) => {
|
||||||
|
const start = new Date(reservation.start_time)
|
||||||
|
const end = new Date(reservation.end_time)
|
||||||
|
const from = start > dayStart ? start : dayStart
|
||||||
|
const to = end < dayEnd ? end : dayEnd
|
||||||
|
if (to <= from) return []
|
||||||
|
|
||||||
|
const top = ((from.getTime() - dayStart.getTime()) / 3_600_000) * HOUR_HEIGHT
|
||||||
|
const height = Math.max(((to.getTime() - from.getTime()) / 3_600_000) * HOUR_HEIGHT, 6)
|
||||||
|
return [{ id: reservation.id, top, height, label: unitLabel(reservation.unit) }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="gap-3">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{{ $t('admin.calendar.title') }}</CardTitle>
|
||||||
|
<CardDescription>{{ $t('admin.calendar.intro') }}</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-muted-foreground text-sm">{{ $t('admin.calendar.bike') }}</span>
|
||||||
|
<Select v-model="selectedBike">
|
||||||
|
<SelectTrigger class="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{{ $t('admin.calendar.all-bikes') }}</SelectItem>
|
||||||
|
<SelectItem v-for="bike in bikes" :key="bike.id" :value="String(bike.id)">
|
||||||
|
{{ bike.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" @click="shiftWeek(-1)">
|
||||||
|
<ChevronLeft class="size-4" />
|
||||||
|
{{ $t('admin.calendar.previous') }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" @click="weekStart = startOfWeek(new Date())">
|
||||||
|
{{ $t('admin.calendar.today') }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" @click="shiftWeek(1)">
|
||||||
|
{{ $t('admin.calendar.next') }}
|
||||||
|
<ChevronRight class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<span class="text-muted-foreground text-sm">{{ range }}</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<p v-if="!lanes.length" class="text-muted-foreground text-sm">
|
||||||
|
{{ $t('admin.calendar.no-bike') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- The grid is wide: it scrolls on its own rather than the page -->
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<div class="min-w-[52rem]">
|
||||||
|
<div class="flex border-b">
|
||||||
|
<div class="text-muted-foreground w-12 shrink-0 py-2 text-xs">
|
||||||
|
{{ $t('admin.calendar.hour') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="day in days"
|
||||||
|
:key="day.toISOString()"
|
||||||
|
class="min-w-0 flex-1 border-l px-1 py-2"
|
||||||
|
:class="isToday(day) ? 'bg-primary/5' : ''"
|
||||||
|
>
|
||||||
|
<div class="truncate text-sm font-medium" :class="isToday(day) ? 'text-primary' : ''">
|
||||||
|
{{ dayFormatter.format(day) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground flex gap-px text-[0.6rem]">
|
||||||
|
<span v-for="bike in lanes" :key="bike.id" class="flex-1 truncate text-center">
|
||||||
|
{{ bike.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Hour labels -->
|
||||||
|
<div class="w-12 shrink-0">
|
||||||
|
<div
|
||||||
|
v-for="hour in hours"
|
||||||
|
:key="hour"
|
||||||
|
class="text-muted-foreground border-b text-xs"
|
||||||
|
:style="{ height: `${HOUR_HEIGHT}px` }"
|
||||||
|
>
|
||||||
|
{{ String(hour).padStart(2, '0') }}:00
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="day in days"
|
||||||
|
:key="day.toISOString()"
|
||||||
|
class="relative min-w-0 flex-1 border-l"
|
||||||
|
:class="isToday(day) ? 'bg-primary/5' : ''"
|
||||||
|
>
|
||||||
|
<!-- Hour lines, behind the blocks -->
|
||||||
|
<div
|
||||||
|
v-for="hour in hours"
|
||||||
|
:key="hour"
|
||||||
|
class="border-b"
|
||||||
|
:style="{ height: `${HOUR_HEIGHT}px` }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- One lane per bike, blocks positioned inside -->
|
||||||
|
<div class="absolute inset-0 flex gap-px px-px">
|
||||||
|
<div v-for="bike in lanes" :key="bike.id" class="relative min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
v-for="block in blocksFor(day, bike)"
|
||||||
|
:key="`${block.id}-${bike.id}`"
|
||||||
|
class="bg-primary text-primary-foreground absolute inset-x-0 overflow-hidden rounded-sm px-0.5 text-[0.6rem] leading-tight"
|
||||||
|
:style="{ top: `${block.top}px`, height: `${block.height}px` }"
|
||||||
|
:title="`#${block.id} — ${block.label}`"
|
||||||
|
>
|
||||||
|
{{ block.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted-foreground mt-3 text-xs">{{ $t('admin.calendar.legend') }}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
109
frontend/src/components/admin/BikeFleet.vue
Normal file
109
frontend/src/components/admin/BikeFleet.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* The fleet, one card per bike.
|
||||||
|
*
|
||||||
|
* Three states are shown but only two are stored: `in_service` and
|
||||||
|
* `out_of_service` live in the database, while **in use** is derived from the
|
||||||
|
* reservations that are currently `ongoing`. The button therefore only ever
|
||||||
|
* toggles between the two real ones.
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { useBikes, useSetBikeStatus } from '@/services/api/bikes'
|
||||||
|
import type { Bike, Reservation } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ reservations: Reservation[] }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { data: bikes, isPending, isError } = useBikes()
|
||||||
|
const setStatus = useSetBikeStatus()
|
||||||
|
|
||||||
|
/** Bikes held by a reservation that is under way right now */
|
||||||
|
const inUse = computed(() => {
|
||||||
|
const ids = new Set<number>()
|
||||||
|
for (const reservation of props.reservations) {
|
||||||
|
if (reservation.status === 'ongoing') reservation.bikes.forEach((id) => ids.add(id))
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
})
|
||||||
|
|
||||||
|
type Display = 'in_use' | 'in_service' | 'out_of_service'
|
||||||
|
|
||||||
|
function display(bike: Bike): Display {
|
||||||
|
if (bike.status === 'out_of_service') return 'out_of_service'
|
||||||
|
return inUse.value.has(bike.id) ? 'in_use' : 'in_service'
|
||||||
|
}
|
||||||
|
|
||||||
|
// One place decides the colour of a card, so the three states stay legible in
|
||||||
|
// both themes
|
||||||
|
const CARD_CLASS: Record<Display, string> = {
|
||||||
|
in_use: 'border-primary/40 bg-primary/5',
|
||||||
|
in_service: 'border-emerald-500/40 bg-emerald-500/5',
|
||||||
|
out_of_service: 'border-destructive/40 bg-destructive/5',
|
||||||
|
}
|
||||||
|
const LABEL_CLASS: Record<Display, string> = {
|
||||||
|
in_use: 'text-primary',
|
||||||
|
in_service: 'text-emerald-700 dark:text-emerald-400',
|
||||||
|
out_of_service: 'text-destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(bike: Bike) {
|
||||||
|
const status = bike.status === 'in_service' ? 'out_of_service' : 'in_service'
|
||||||
|
setStatus.mutate({ id: bike.id, status }, { onError: () => toast.error(t('admin.bikes.error')) })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{{ $t('admin.bikes.title') }}</CardTitle>
|
||||||
|
<CardDescription>{{ $t('admin.bikes.intro') }}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="isPending" class="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
<Skeleton v-for="i in 5" :key="i" class="h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="isError" class="text-destructive text-sm">
|
||||||
|
{{ $t('admin.bikes.load-error') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-else-if="!bikes?.length" class="text-muted-foreground text-sm">
|
||||||
|
{{ $t('admin.bikes.empty') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-else class="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
<div
|
||||||
|
v-for="bike in bikes"
|
||||||
|
:key="bike.id"
|
||||||
|
class="flex flex-col items-center gap-3 rounded-lg border p-4 text-center"
|
||||||
|
:class="CARD_CLASS[display(bike)]"
|
||||||
|
>
|
||||||
|
<span class="text-primary text-2xl font-bold">{{ bike.name }}</span>
|
||||||
|
<span class="text-sm font-medium" :class="LABEL_CLASS[display(bike)]">
|
||||||
|
{{ $t(`admin.bikes.status.${display(bike)}`) }}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="mt-auto w-full"
|
||||||
|
:disabled="setStatus.isPending.value"
|
||||||
|
@click="toggle(bike)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
bike.status === 'in_service'
|
||||||
|
? $t('admin.bikes.deactivate')
|
||||||
|
: $t('admin.bikes.activate')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
119
frontend/src/components/admin/ReservationAdmin.vue
Normal file
119
frontend/src/components/admin/ReservationAdmin.vue
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Reservations, split the way an administrator reads them: what is waiting for
|
||||||
|
* a decision, what is live or coming, and — behind a toggle — everything that
|
||||||
|
* is over one way or another.
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import ReservationCard from './ReservationCard.vue'
|
||||||
|
import type { Bike, Reservation } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
reservations: Reservation[]
|
||||||
|
bikes: Bike[]
|
||||||
|
isPending: boolean
|
||||||
|
isError: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const showArchived = ref(false)
|
||||||
|
|
||||||
|
const pending = computed(() => props.reservations.filter((r) => r.status === 'requested'))
|
||||||
|
const active = computed(() =>
|
||||||
|
props.reservations.filter((r) => r.status === 'approved' || r.status === 'ongoing'),
|
||||||
|
)
|
||||||
|
// The three final states of the machine, grouped: nothing more will happen to them
|
||||||
|
const archived = computed(() =>
|
||||||
|
props.reservations.filter(
|
||||||
|
(r) => r.status === 'refused' || r.status === 'cancelled' || r.status === 'archived',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Soonest first for what is coming, most recent first for the history */
|
||||||
|
function byStart(list: Reservation[], descending = false) {
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
const diff = new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
|
||||||
|
return descending ? -diff : diff
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{{ $t('admin.reservations.title') }}</CardTitle>
|
||||||
|
<CardDescription>{{ $t('admin.reservations.intro') }}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="grid gap-6">
|
||||||
|
<div v-if="isPending" class="grid gap-3">
|
||||||
|
<Skeleton class="h-28 w-full" />
|
||||||
|
<Skeleton class="h-28 w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="isError" class="text-destructive text-sm">
|
||||||
|
{{ $t('admin.reservations.load-error') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section class="grid gap-3">
|
||||||
|
<h3 class="font-medium">
|
||||||
|
{{ $t('admin.reservations.pending') }}
|
||||||
|
<span v-if="pending.length" class="text-muted-foreground">({{ pending.length }})</span>
|
||||||
|
</h3>
|
||||||
|
<p v-if="!pending.length" class="text-muted-foreground text-sm">
|
||||||
|
{{ $t('admin.reservations.pending-empty') }}
|
||||||
|
</p>
|
||||||
|
<ReservationCard
|
||||||
|
v-for="reservation in byStart(pending)"
|
||||||
|
:key="reservation.id"
|
||||||
|
:reservation="reservation"
|
||||||
|
:bikes="bikes"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-3">
|
||||||
|
<h3 class="font-medium">
|
||||||
|
{{ $t('admin.reservations.active') }}
|
||||||
|
<span v-if="active.length" class="text-muted-foreground">({{ active.length }})</span>
|
||||||
|
</h3>
|
||||||
|
<p v-if="!active.length" class="text-muted-foreground text-sm">
|
||||||
|
{{ $t('admin.reservations.active-empty') }}
|
||||||
|
</p>
|
||||||
|
<ReservationCard
|
||||||
|
v-for="reservation in byStart(active)"
|
||||||
|
:key="reservation.id"
|
||||||
|
:reservation="reservation"
|
||||||
|
:bikes="bikes"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-3">
|
||||||
|
<div>
|
||||||
|
<Button variant="outline" size="sm" @click="showArchived = !showArchived">
|
||||||
|
{{
|
||||||
|
showArchived
|
||||||
|
? $t('admin.reservations.hide-archived')
|
||||||
|
: $t('admin.reservations.show-archived', { n: archived.length })
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<template v-if="showArchived">
|
||||||
|
<p v-if="!archived.length" class="text-muted-foreground text-sm">
|
||||||
|
{{ $t('admin.reservations.archived-empty') }}
|
||||||
|
</p>
|
||||||
|
<ReservationCard
|
||||||
|
v-for="reservation in byStart(archived, true)"
|
||||||
|
:key="reservation.id"
|
||||||
|
:reservation="reservation"
|
||||||
|
:bikes="bikes"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
122
frontend/src/components/admin/ReservationCard.vue
Normal file
122
frontend/src/components/admin/ReservationCard.vue
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* One reservation, with the transitions its current status allows. The buttons
|
||||||
|
* mirror `ReservationStatus::can_transition_to` on the backend, which re-checks
|
||||||
|
* and answers 409 on anything else.
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useSetReservationStatus } from '@/services/api/reservations'
|
||||||
|
import { unitLabel, type Bike, type Reservation, type ReservationStatus } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ reservation: Reservation; bikes: Bike[] }>()
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const setStatus = useSetReservationStatus()
|
||||||
|
|
||||||
|
/** Kept in step with the state machine drawn on the backend enum */
|
||||||
|
const TRANSITIONS: Record<ReservationStatus, ReservationStatus[]> = {
|
||||||
|
requested: ['approved', 'refused'],
|
||||||
|
approved: ['ongoing', 'cancelled'],
|
||||||
|
ongoing: ['archived', 'cancelled'],
|
||||||
|
refused: [],
|
||||||
|
cancelled: [],
|
||||||
|
archived: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const BADGE_CLASS: Record<ReservationStatus, string> = {
|
||||||
|
requested: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||||
|
approved: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||||
|
ongoing: 'bg-primary/15 text-primary',
|
||||||
|
refused: 'bg-destructive/15 text-destructive',
|
||||||
|
cancelled: 'bg-destructive/15 text-destructive',
|
||||||
|
archived: 'bg-muted text-muted-foreground',
|
||||||
|
}
|
||||||
|
|
||||||
|
const transitions = computed(() => TRANSITIONS[props.reservation.status])
|
||||||
|
|
||||||
|
const bikeNames = computed(() =>
|
||||||
|
props.reservation.bikes
|
||||||
|
.map((id) => props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`)
|
||||||
|
.join(', '),
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatter = computed(
|
||||||
|
() =>
|
||||||
|
new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-CH' : 'en-GB', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function format(iso: string) {
|
||||||
|
return formatter.value.format(new Date(iso))
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(status: ReservationStatus) {
|
||||||
|
setStatus.mutate(
|
||||||
|
{ id: props.reservation.id, status },
|
||||||
|
{ onError: () => toast.error(t('admin.reservations.error')) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<span class="font-semibold">#{{ reservation.id }}</span>
|
||||||
|
<Badge variant="secondary" :class="BADGE_CLASS[reservation.status]">
|
||||||
|
{{ $t(`admin.reservations.status.${reservation.status}`) }}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-muted-foreground text-sm">{{ unitLabel(reservation.unit) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="transitions.length" class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
v-for="status in transitions"
|
||||||
|
:key="status"
|
||||||
|
size="sm"
|
||||||
|
:variant="status === 'approved' ? 'default' : 'outline'"
|
||||||
|
:disabled="setStatus.isPending.value"
|
||||||
|
@click="move(status)"
|
||||||
|
>
|
||||||
|
{{ $t(`admin.reservations.action.${status}`) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="mt-3 grid gap-1 text-sm">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.period') }}</dt>
|
||||||
|
<dd>{{ format(reservation.start_time) }} → {{ format(reservation.end_time) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.bikes') }}</dt>
|
||||||
|
<dd>{{ bikeNames }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.telegram') }}</dt>
|
||||||
|
<dd class="truncate">{{ reservation.telegram }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.people') }}</dt>
|
||||||
|
<dd class="min-w-0 break-words">
|
||||||
|
{{ 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>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -18,7 +18,7 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
|
'border-input dark:bg-input/30 flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|
|
||||||
908
frontend/src/lib/api.d.ts
vendored
908
frontend/src/lib/api.d.ts
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -2,6 +2,7 @@ locale: en
|
||||||
app:
|
app:
|
||||||
title: Cargobikes
|
title: Cargobikes
|
||||||
header:
|
header:
|
||||||
|
admin: Admin
|
||||||
logout: Log out
|
logout: Log out
|
||||||
logout-error: Unable to log out.
|
logout-error: Unable to log out.
|
||||||
reserve: Book
|
reserve: Book
|
||||||
|
|
@ -29,6 +30,9 @@ reservation:
|
||||||
bikes-empty: No cargobike available for this period.
|
bikes-empty: No cargobike available for this period.
|
||||||
bikes-error: Unable to load the cargobikes.
|
bikes-error: Unable to load the cargobikes.
|
||||||
bike-out-of-service: Out of service
|
bike-out-of-service: Out of service
|
||||||
|
bike-size-large: Large cargo bikes
|
||||||
|
bike-size-small: Small cargo bikes
|
||||||
|
bike-size-empty: No bike of this size.
|
||||||
telegram: Telegram username
|
telegram: Telegram username
|
||||||
emails: Email addresses of the Linka Go accounts to authorize
|
emails: Email addresses of the Linka Go accounts to authorize
|
||||||
email-nth: 'Email address {n}'
|
email-nth: 'Email address {n}'
|
||||||
|
|
@ -36,6 +40,10 @@ reservation:
|
||||||
remove-email: Remove this address
|
remove-email: Remove this address
|
||||||
submit: Send the request
|
submit: Send the request
|
||||||
reset: Reset
|
reset: Reset
|
||||||
|
association-placeholder: Pick or type the association
|
||||||
|
association-other: Other association…
|
||||||
|
telegram-placeholder: username
|
||||||
|
error-too-far: A reservation can be made at most 1 month in advance.
|
||||||
error-required: This field is required.
|
error-required: This field is required.
|
||||||
error-datetime-required: Pick a date and a time.
|
error-datetime-required: Pick a date and a time.
|
||||||
error-end-before-start: The end must be after the start.
|
error-end-before-start: The end must be after the start.
|
||||||
|
|
@ -44,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.
|
||||||
|
|
@ -57,3 +67,62 @@ login:
|
||||||
calendar:
|
calendar:
|
||||||
title: Calendar
|
title: Calendar
|
||||||
todo: This page is not built yet.
|
todo: This page is not built yet.
|
||||||
|
admin:
|
||||||
|
title: Reservation administration
|
||||||
|
intro: Approve, refuse and follow the reservations, and see the planning of each cargobike.
|
||||||
|
refresh: Refresh
|
||||||
|
updated-at: 'Last loaded: {time}'
|
||||||
|
bikes:
|
||||||
|
title: Fleet management
|
||||||
|
intro: Deactivate a bike so that it can no longer be picked in the form.
|
||||||
|
activate: Activate
|
||||||
|
deactivate: Deactivate
|
||||||
|
empty: No cargobike in the fleet.
|
||||||
|
load-error: Unable to load the cargobikes.
|
||||||
|
error: The status change failed.
|
||||||
|
status:
|
||||||
|
in_service: In service
|
||||||
|
out_of_service: Out of service
|
||||||
|
in_use: In use
|
||||||
|
reservations:
|
||||||
|
title: Reservations
|
||||||
|
intro: Requests are waiting for a decision; approved reservations show up below.
|
||||||
|
pending: Pending requests
|
||||||
|
pending-empty: No reservation request for now.
|
||||||
|
active: Reservations (ongoing and upcoming)
|
||||||
|
active-empty: No ongoing or upcoming reservation.
|
||||||
|
show-archived: 'Show archived requests ({n})'
|
||||||
|
hide-archived: Hide archived requests
|
||||||
|
archived-empty: No archived request.
|
||||||
|
period: Period
|
||||||
|
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.
|
||||||
|
status:
|
||||||
|
requested: Pending
|
||||||
|
refused: Refused
|
||||||
|
approved: Approved
|
||||||
|
cancelled: Cancelled
|
||||||
|
ongoing: Ongoing
|
||||||
|
archived: Archived
|
||||||
|
action:
|
||||||
|
approved: Approve
|
||||||
|
refused: Refuse
|
||||||
|
cancelled: Cancel
|
||||||
|
ongoing: Start
|
||||||
|
archived: Archive
|
||||||
|
calendar:
|
||||||
|
title: Calendar per cargobike
|
||||||
|
intro: Approved and ongoing reservations are shown in the calendar.
|
||||||
|
bike: Cargobike
|
||||||
|
all-bikes: All
|
||||||
|
previous: Week
|
||||||
|
next: Week
|
||||||
|
today: Today
|
||||||
|
hour: H
|
||||||
|
no-bike: No cargobike to show.
|
||||||
|
legend: One bar per booked cargobike, split by day.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ locale: fr
|
||||||
app:
|
app:
|
||||||
title: Cargobikes
|
title: Cargobikes
|
||||||
header:
|
header:
|
||||||
|
admin: Admin
|
||||||
logout: Se déconnecter
|
logout: Se déconnecter
|
||||||
logout-error: Impossible de se déconnecter.
|
logout-error: Impossible de se déconnecter.
|
||||||
reserve: Réserver
|
reserve: Réserver
|
||||||
|
|
@ -30,6 +31,9 @@ reservation:
|
||||||
bikes-empty: Aucun cargobike disponible pour ce créneau.
|
bikes-empty: Aucun cargobike disponible pour ce créneau.
|
||||||
bikes-error: Impossible de charger les cargobikes.
|
bikes-error: Impossible de charger les cargobikes.
|
||||||
bike-out-of-service: Hors service
|
bike-out-of-service: Hors service
|
||||||
|
bike-size-large: Grands cargos
|
||||||
|
bike-size-small: Petits cargos
|
||||||
|
bike-size-empty: Aucun vélo de cette taille.
|
||||||
telegram: Username Telegram
|
telegram: Username Telegram
|
||||||
emails: Adresses mail du/des comptes Linka Go à autoriser
|
emails: Adresses mail du/des comptes Linka Go à autoriser
|
||||||
email-nth: 'Adresse e-mail {n}'
|
email-nth: 'Adresse e-mail {n}'
|
||||||
|
|
@ -37,6 +41,10 @@ reservation:
|
||||||
remove-email: Retirer cette adresse
|
remove-email: Retirer cette adresse
|
||||||
submit: Envoyer la demande
|
submit: Envoyer la demande
|
||||||
reset: Réinitialiser
|
reset: Réinitialiser
|
||||||
|
association-placeholder: Choisissez ou saisissez l'association
|
||||||
|
association-other: Autre association…
|
||||||
|
telegram-placeholder: username
|
||||||
|
error-too-far: Une réservation se fait au maximum 1 mois à l'avance.
|
||||||
error-required: Ce champ est obligatoire.
|
error-required: Ce champ est obligatoire.
|
||||||
error-datetime-required: Choisissez une date et une heure.
|
error-datetime-required: Choisissez une date et une heure.
|
||||||
error-end-before-start: La fin doit être après le début.
|
error-end-before-start: La fin doit être après le début.
|
||||||
|
|
@ -45,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.
|
||||||
|
|
@ -58,3 +68,62 @@ login:
|
||||||
calendar:
|
calendar:
|
||||||
title: Calendrier
|
title: Calendrier
|
||||||
todo: Cette page n'est pas encore construite.
|
todo: Cette page n'est pas encore construite.
|
||||||
|
admin:
|
||||||
|
title: Administration des réservations
|
||||||
|
intro: Validez, refusez et suivez les réservations, et visualisez les plannings par cargobike.
|
||||||
|
refresh: Rafraîchir
|
||||||
|
updated-at: 'Dernier chargement : {time}'
|
||||||
|
bikes:
|
||||||
|
title: Gestion des vélos
|
||||||
|
intro: Désactivez un vélo pour qu'il ne soit plus sélectionnable dans le formulaire.
|
||||||
|
activate: Activer
|
||||||
|
deactivate: Désactiver
|
||||||
|
empty: Aucun cargobike dans la flotte.
|
||||||
|
load-error: Impossible de charger les cargobikes.
|
||||||
|
error: Le changement de statut a échoué.
|
||||||
|
status:
|
||||||
|
in_service: En service
|
||||||
|
out_of_service: Hors service
|
||||||
|
in_use: En usage
|
||||||
|
reservations:
|
||||||
|
title: Réservations
|
||||||
|
intro: Les demandes attendent une décision ; les réservations validées apparaissent ensuite.
|
||||||
|
pending: Demandes en attente
|
||||||
|
pending-empty: Aucune demande de réservation pour le moment.
|
||||||
|
active: Réservations (en cours et à venir)
|
||||||
|
active-empty: Aucune réservation en cours ou à venir.
|
||||||
|
show-archived: 'Voir les demandes archivées ({n})'
|
||||||
|
hide-archived: Masquer les demandes archivées
|
||||||
|
archived-empty: Aucune demande archivée.
|
||||||
|
period: Période
|
||||||
|
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é.
|
||||||
|
status:
|
||||||
|
requested: En attente
|
||||||
|
refused: Refusée
|
||||||
|
approved: Validée
|
||||||
|
cancelled: Annulée
|
||||||
|
ongoing: En cours
|
||||||
|
archived: Archivée
|
||||||
|
action:
|
||||||
|
approved: Valider
|
||||||
|
refused: Refuser
|
||||||
|
cancelled: Annuler
|
||||||
|
ongoing: Démarrer
|
||||||
|
archived: Archiver
|
||||||
|
calendar:
|
||||||
|
title: Calendrier par cargobike
|
||||||
|
intro: Les réservations validées et en cours sont affichées dans le calendrier.
|
||||||
|
bike: Cargobike
|
||||||
|
all-bikes: Tous
|
||||||
|
previous: Sem.
|
||||||
|
next: Sem.
|
||||||
|
today: Aujourd'hui
|
||||||
|
hour: H
|
||||||
|
no-bike: Aucun cargobike à afficher.
|
||||||
|
legend: Une barre par cargobike réservé, découpée par jour.
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,55 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import { ensureSession } from '@/services/api/auth'
|
||||||
import LoginView from '@/views/LoginView.vue'
|
import LoginView from '@/views/LoginView.vue'
|
||||||
import ReservationView from '@/views/ReservationView.vue'
|
import ReservationView from '@/views/ReservationView.vue'
|
||||||
import CalendarView from '@/views/CalendarView.vue'
|
import CalendarView from '@/views/CalendarView.vue'
|
||||||
|
import AdminView from '@/views/AdminView.vue'
|
||||||
import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue'
|
import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue'
|
||||||
|
|
||||||
|
declare module 'vue-router' {
|
||||||
|
interface RouteMeta {
|
||||||
|
/** The route needs a session */
|
||||||
|
requiresAuth?: boolean
|
||||||
|
/** ... and the session must belong to an admin */
|
||||||
|
requiresAdmin?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{ name: 'login', path: '/', component: LoginView },
|
{ name: 'login', path: '/', component: LoginView },
|
||||||
{ name: 'reservations', path: '/reservations', component: ReservationView },
|
{
|
||||||
|
name: 'reservations',
|
||||||
|
path: '/reservations',
|
||||||
|
component: ReservationView,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{ name: 'calendar', path: '/calendar', component: CalendarView },
|
{ name: 'calendar', path: '/calendar', component: CalendarView },
|
||||||
|
{
|
||||||
|
name: 'admin',
|
||||||
|
path: '/admin',
|
||||||
|
component: AdminView,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true },
|
||||||
|
},
|
||||||
// Registered as the OIDC redirect uri, see `server.base_url` in config.yml
|
// Registered as the OIDC redirect uri, see `server.base_url` in config.yml
|
||||||
{ name: 'whiskey-callback', path: '/whiskey/callback', component: WhiskeyCallbackView },
|
{ name: 'whiskey-callback', path: '/whiskey/callback', component: WhiskeyCallbackView },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps anonymous visitors off the pages that need a session. This is a
|
||||||
|
* convenience, not the security boundary: every protected route answers 401 or
|
||||||
|
* 403 on its own, whatever the frontend does.
|
||||||
|
*/
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
if (!to.meta.requiresAuth) return true
|
||||||
|
|
||||||
|
const user = await ensureSession().catch(() => null)
|
||||||
|
if (!user) return { name: 'login' }
|
||||||
|
if (to.meta.requiresAdmin && !user.admin) return { name: 'reservations' }
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { HttpStatus } from 'http-status-ts'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import type { User } from '@/utils/types'
|
import type { User } from '@/utils/types'
|
||||||
import { getClient } from './client'
|
import { getClient, getQueryClient } from './client'
|
||||||
import { SESSION_KEY } from './keys'
|
import { SESSION_KEY } from './keys'
|
||||||
|
|
||||||
export { SESSION_KEY }
|
export { SESSION_KEY }
|
||||||
|
|
@ -19,18 +19,30 @@ export { SESSION_KEY }
|
||||||
* `/api/me` is a probe, not a protected route: it answers 200 with `null` when
|
* `/api/me` is a probe, not a protected route: it answers 200 with `null` when
|
||||||
* nobody is logged in, so a page load never looks like an error.
|
* nobody is logged in, so a page load never looks like an error.
|
||||||
*/
|
*/
|
||||||
export function useSession() {
|
async function fetchSession() {
|
||||||
const query = useQuery({
|
|
||||||
queryKey: SESSION_KEY,
|
|
||||||
staleTime: Infinity,
|
|
||||||
retry: false,
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data, response } = await getClient().GET('/api/me')
|
const { data, response } = await getClient().GET('/api/me')
|
||||||
if (response.status !== HttpStatus.OK) {
|
if (response.status !== HttpStatus.OK) {
|
||||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||||
}
|
}
|
||||||
return data ?? null
|
return data ?? null
|
||||||
},
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session as the router guards see it. Reads through the same cache the
|
||||||
|
* components use, so a navigation costs no extra call.
|
||||||
|
*/
|
||||||
|
export async function ensureSession() {
|
||||||
|
const queryClient = getQueryClient()
|
||||||
|
if (!queryClient) return fetchSession()
|
||||||
|
return queryClient.ensureQueryData({ queryKey: SESSION_KEY, queryFn: fetchSession })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: SESSION_KEY,
|
||||||
|
staleTime: Infinity,
|
||||||
|
retry: false,
|
||||||
|
queryFn: fetchSession,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,17 @@
|
||||||
* The fleet. One file per domain area, exposing vue-query hooks: views never
|
* The fleet. One file per domain area, exposing vue-query hooks: views never
|
||||||
* call `fetch` themselves.
|
* call `fetch` themselves.
|
||||||
*/
|
*/
|
||||||
import { useQuery } 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 { Bike, BikeStatus } from '@/utils/types'
|
||||||
import { getClient } from './client'
|
import { getClient } from './client'
|
||||||
|
|
||||||
|
export const BIKES_KEY = ['bikes']
|
||||||
|
|
||||||
export function useBikes() {
|
export function useBikes() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['bikes'],
|
queryKey: BIKES_KEY,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, response } = await getClient().GET('/api/bikes')
|
const { data, response } = await getClient().GET('/api/bikes')
|
||||||
|
|
@ -20,3 +23,23 @@ export function useBikes() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Takes a bike in or out of the fleet. Admin only, the backend enforces it. */
|
||||||
|
export function useSetBikeStatus() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
retry: 0,
|
||||||
|
mutationFn: async ({ id, status }: { id: number; status: BikeStatus }) => {
|
||||||
|
await getClient().PUT('/api/bikes/{id}/status', {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: { status },
|
||||||
|
})
|
||||||
|
return { id, status }
|
||||||
|
},
|
||||||
|
onSuccess: ({ id, status }) => {
|
||||||
|
queryClient.setQueryData<Bike[]>(BIKES_KEY, (bikes) =>
|
||||||
|
bikes?.map((bike) => (bike.id === id ? { ...bike, status } : bike)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ export function setQueryClient(client: QueryClient) {
|
||||||
queryClient = client
|
queryClient = client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getQueryClient() {
|
||||||
|
return queryClient
|
||||||
|
}
|
||||||
|
|
||||||
// Types come from src/lib/api.d.ts, generated from the backend: `npm run openapi`
|
// Types come from src/lib/api.d.ts, generated from the backend: `npm run openapi`
|
||||||
const client = createClient<paths>({
|
const client = createClient<paths>({
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
|
|
|
||||||
73
frontend/src/services/api/reservations.ts
Normal file
73
frontend/src/services/api/reservations.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
/**
|
||||||
|
* 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 { NewReservation, Reservation, ReservationStatus } from '@/utils/types'
|
||||||
|
import { getClient } from './client'
|
||||||
|
|
||||||
|
export const RESERVATIONS_KEY = ['reservations']
|
||||||
|
|
||||||
|
export function useReservations() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: RESERVATIONS_KEY,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await getClient().GET('/api/reservations')
|
||||||
|
if (response.status !== HttpStatus.OK) {
|
||||||
|
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||||
|
}
|
||||||
|
return data ?? []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backend re-checks the state machine and answers 409 on a transition it
|
||||||
|
* does not allow, so the buttons only have to offer the plausible ones.
|
||||||
|
*/
|
||||||
|
export function useSetReservationStatus() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
retry: 0,
|
||||||
|
mutationFn: async ({ id, status }: { id: number; status: ReservationStatus }) => {
|
||||||
|
await getClient().PUT('/api/reservations/{id}/status', {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: { status },
|
||||||
|
})
|
||||||
|
return { id, status }
|
||||||
|
},
|
||||||
|
onSuccess: ({ id, status }) => {
|
||||||
|
// Patch the cache so the card moves section immediately
|
||||||
|
queryClient.setQueryData<Reservation[]>(RESERVATIONS_KEY, (reservations) =>
|
||||||
|
reservations?.map((r) => (r.id === id ? { ...r, status } : r)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -120,4 +120,22 @@
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
/* Tailwind v4 dropped the v3 preflight rule that gave buttons a pointer
|
||||||
|
cursor, so anything actionable would otherwise show the arrow. The
|
||||||
|
`[role=]` selectors cover the elements reka-ui builds out of divs:
|
||||||
|
calendar days, select options, popover triggers. */
|
||||||
|
button:not(:disabled),
|
||||||
|
[role='button']:not([aria-disabled='true']),
|
||||||
|
[role='option']:not([aria-disabled='true']),
|
||||||
|
[role='menuitem']:not([aria-disabled='true']),
|
||||||
|
[role='tab']:not([aria-disabled='true']),
|
||||||
|
label[for],
|
||||||
|
summary,
|
||||||
|
a[href] {
|
||||||
|
@apply cursor-pointer;
|
||||||
|
}
|
||||||
|
button:disabled,
|
||||||
|
[aria-disabled='true'] {
|
||||||
|
@apply cursor-not-allowed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,17 @@ export type Bike = components['schemas']['Bike']
|
||||||
export type BikeStatus = components['schemas']['BikeStatus']
|
export type BikeStatus = components['schemas']['BikeStatus']
|
||||||
export type Unit = components['schemas']['Unit']
|
export type Unit = components['schemas']['Unit']
|
||||||
export type User = components['schemas']['User']
|
export type User = components['schemas']['User']
|
||||||
|
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,
|
||||||
|
* or the name the requester typed. One place, so no view has to know which of
|
||||||
|
* the two shapes it is holding.
|
||||||
|
*/
|
||||||
|
export function unitLabel(unit: ReservationUnit): string {
|
||||||
|
return unit.kind === 'known' ? unit.unit.name : unit.name
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Administration: the fleet, the reservations, and the week planning.
|
||||||
|
* The three sections share one fetch of the reservations.
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { RefreshCw } from '@lucide/vue'
|
||||||
|
|
||||||
|
import BikeCalendar from '@/components/admin/BikeCalendar.vue'
|
||||||
|
import BikeFleet from '@/components/admin/BikeFleet.vue'
|
||||||
|
import ReservationAdmin from '@/components/admin/ReservationAdmin.vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { useBikes } from '@/services/api/bikes'
|
||||||
|
import { useReservations } from '@/services/api/reservations'
|
||||||
|
|
||||||
|
const { data: bikes } = useBikes()
|
||||||
|
const {
|
||||||
|
data: reservations,
|
||||||
|
isPending,
|
||||||
|
isError,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
dataUpdatedAt,
|
||||||
|
} = useReservations()
|
||||||
|
|
||||||
|
const list = computed(() => reservations.value ?? [])
|
||||||
|
const fleet = computed(() => bikes.value ?? [])
|
||||||
|
const updatedAt = computed(() =>
|
||||||
|
dataUpdatedAt.value ? new Date(dataUpdatedAt.value).toLocaleTimeString() : '—',
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto grid w-full max-w-6xl gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-xl">{{ $t('admin.title') }}</CardTitle>
|
||||||
|
<CardDescription>{{ $t('admin.intro') }}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="flex flex-wrap items-center gap-3">
|
||||||
|
<Button size="sm" :disabled="isFetching" @click="refetch()">
|
||||||
|
<RefreshCw class="size-4" :class="isFetching ? 'animate-spin' : ''" />
|
||||||
|
{{ $t('admin.refresh') }}
|
||||||
|
</Button>
|
||||||
|
<span class="text-muted-foreground text-sm">
|
||||||
|
{{ $t('admin.updated-at', { time: updatedAt }) }}
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<BikeFleet class="min-w-0" :reservations="list" />
|
||||||
|
|
||||||
|
<ReservationAdmin
|
||||||
|
class="min-w-0"
|
||||||
|
:reservations="list"
|
||||||
|
:bikes="fleet"
|
||||||
|
:is-pending="isPending"
|
||||||
|
:is-error="isError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BikeCalendar class="min-w-0" :reservations="list" :bikes="fleet" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -6,6 +6,8 @@ import { getLocalTimeZone, today, type DateValue } from '@internationalized/date
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
import DatePicker from '@/components/DatePicker.vue'
|
import DatePicker from '@/components/DatePicker.vue'
|
||||||
|
import TelegramInput from '@/components/TelegramInput.vue'
|
||||||
|
import UnitPicker, { type UnitChoice } from '@/components/UnitPicker.vue'
|
||||||
import TimePicker from '@/components/TimePicker.vue'
|
import TimePicker from '@/components/TimePicker.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
@ -14,7 +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 type { Bike } from '@/utils/types'
|
import { useCreateReservation } from '@/services/api/reservations'
|
||||||
|
import { useSession } from '@/services/api/auth'
|
||||||
|
import type { Bike, NewReservation } from '@/utils/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|
@ -23,7 +27,7 @@ const TELEGRAM_RE = /^@[A-Za-z][A-Za-z0-9_]{4,31}$/
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
type Form = {
|
type Form = {
|
||||||
association: string
|
association: UnitChoice | undefined
|
||||||
reason: string
|
reason: string
|
||||||
startTime: string | undefined
|
startTime: string | undefined
|
||||||
endTime: string | undefined
|
endTime: string | undefined
|
||||||
|
|
@ -34,7 +38,7 @@ type Form = {
|
||||||
|
|
||||||
function emptyForm(): Form {
|
function emptyForm(): Form {
|
||||||
return {
|
return {
|
||||||
association: '',
|
association: undefined,
|
||||||
reason: '',
|
reason: '',
|
||||||
startTime: undefined,
|
startTime: undefined,
|
||||||
endTime: undefined,
|
endTime: undefined,
|
||||||
|
|
@ -52,6 +56,11 @@ const errors = reactive<Record<string, string>>({})
|
||||||
const submitted = ref(false)
|
const submitted = ref(false)
|
||||||
|
|
||||||
const minDate = today(getLocalTimeZone())
|
const minDate = today(getLocalTimeZone())
|
||||||
|
/** Bookings open one month ahead, no further */
|
||||||
|
const maxDate = minDate.add({ months: 1 })
|
||||||
|
|
||||||
|
const { user } = useSession()
|
||||||
|
const units = computed(() => user.value?.units ?? [])
|
||||||
|
|
||||||
function toDate(date: DateValue | undefined, time: string | undefined): Date | null {
|
function toDate(date: DateValue | undefined, time: string | undefined): Date | null {
|
||||||
if (!date || !time) return null
|
if (!date || !time) return null
|
||||||
|
|
@ -73,6 +82,18 @@ function isUnavailable(bike: Bike) {
|
||||||
|
|
||||||
const availableBikes = computed(() => (bikes.value ?? []).filter((bike) => !isUnavailable(bike)))
|
const availableBikes = computed(() => (bikes.value ?? []).filter((bike) => !isUnavailable(bike)))
|
||||||
|
|
||||||
|
// The fleet is presented one column per frame size, since that is what the
|
||||||
|
// requester actually chooses. The size lives on the bike, so adding a sixth one
|
||||||
|
// only means giving it a size in the admin, never touching this file.
|
||||||
|
const BIKE_SIZES = ['large', 'small'] as const
|
||||||
|
|
||||||
|
const bikeGroups = computed(() =>
|
||||||
|
BIKE_SIZES.map((size) => ({
|
||||||
|
size,
|
||||||
|
bikes: (bikes.value ?? []).filter((bike) => bike.size === size),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
watch(availableBikes, (available) => {
|
watch(availableBikes, (available) => {
|
||||||
const ids = new Set(available.map((bike) => bike.id))
|
const ids = new Set(available.map((bike) => bike.id))
|
||||||
form.bikes = form.bikes.filter((id) => ids.has(id))
|
form.bikes = form.bikes.filter((id) => ids.has(id))
|
||||||
|
|
@ -97,15 +118,27 @@ function removeEmail(index: number) {
|
||||||
function validate(): boolean {
|
function validate(): boolean {
|
||||||
Object.keys(errors).forEach((key) => delete errors[key])
|
Object.keys(errors).forEach((key) => delete errors[key])
|
||||||
|
|
||||||
if (!form.association.trim()) errors.association = t('reservation.error-required')
|
if (!form.association) errors.association = t('reservation.error-required')
|
||||||
if (!form.reason.trim()) errors.reason = t('reservation.error-required')
|
if (!form.reason.trim()) errors.reason = t('reservation.error-required')
|
||||||
if (!start.value) errors.start = t('reservation.error-datetime-required')
|
if (!start.value) errors.start = t('reservation.error-datetime-required')
|
||||||
if (!end.value) errors.end = t('reservation.error-datetime-required')
|
if (!end.value) errors.end = t('reservation.error-datetime-required')
|
||||||
|
// Strictly after: a reservation of zero length is not one
|
||||||
if (start.value && end.value && end.value <= start.value) {
|
if (start.value && end.value && end.value <= start.value) {
|
||||||
errors.end = t('reservation.error-end-before-start')
|
errors.end = t('reservation.error-end-before-start')
|
||||||
}
|
}
|
||||||
|
// The pickers already refuse these dates; re-checked in case the model was
|
||||||
|
// filled another way
|
||||||
|
if (startDate.value && startDate.value.compare(maxDate) > 0) {
|
||||||
|
errors.start = t('reservation.error-too-far')
|
||||||
|
}
|
||||||
|
if (endDate.value && endDate.value.compare(maxDate) > 0) {
|
||||||
|
errors.end = t('reservation.error-too-far')
|
||||||
|
}
|
||||||
if (form.bikes.length === 0) errors.bikes = t('reservation.error-no-bike')
|
if (form.bikes.length === 0) errors.bikes = t('reservation.error-no-bike')
|
||||||
if (!TELEGRAM_RE.test(form.telegram)) errors.telegram = t('reservation.error-telegram')
|
if (!form.telegram) errors.telegram = t('reservation.error-required')
|
||||||
|
else if (!TELEGRAM_RE.test(`@${form.telegram}`)) {
|
||||||
|
errors.telegram = t('reservation.error-telegram')
|
||||||
|
}
|
||||||
|
|
||||||
const emails = form.emails.map((email) => email.trim()).filter(Boolean)
|
const emails = form.emails.map((email) => email.trim()).filter(Boolean)
|
||||||
if (emails.length === 0) errors.emails = t('reservation.error-required')
|
if (emails.length === 0) errors.emails = t('reservation.error-required')
|
||||||
|
|
@ -124,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()) {
|
||||||
|
|
@ -131,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>
|
||||||
|
|
||||||
|
|
@ -160,11 +220,11 @@ function submit() {
|
||||||
<!-- Association -->
|
<!-- Association -->
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="association">{{ $t('reservation.association') }}</Label>
|
<Label for="association">{{ $t('reservation.association') }}</Label>
|
||||||
<Input
|
<UnitPicker
|
||||||
id="association"
|
id="association"
|
||||||
v-model.trim="form.association"
|
v-model="form.association"
|
||||||
:placeholder="$t('reservation.association')"
|
:units="units"
|
||||||
:aria-invalid="!!errors.association || undefined"
|
:invalid="!!errors.association"
|
||||||
/>
|
/>
|
||||||
<p v-if="errors.association" class="text-destructive text-xs">
|
<p v-if="errors.association" class="text-destructive text-xs">
|
||||||
{{ errors.association }}
|
{{ errors.association }}
|
||||||
|
|
@ -184,14 +244,19 @@ function submit() {
|
||||||
<p v-if="errors.reason" class="text-destructive text-xs">{{ errors.reason }}</p>
|
<p v-if="errors.reason" class="text-destructive text-xs">{{ errors.reason }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Start -->
|
<!-- Start. The caption names the date/time pair rather than one of
|
||||||
|
them: a `<label for>` on the date would make a click on the text
|
||||||
|
open the calendar, since the browser forwards the activation. -->
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="start-date">{{ $t('reservation.start') }}</Label>
|
<span id="start-label" class="text-sm leading-none font-medium">
|
||||||
<div class="grid gap-2 sm:grid-cols-2">
|
{{ $t('reservation.start') }}
|
||||||
|
</span>
|
||||||
|
<div role="group" aria-labelledby="start-label" class="grid gap-2 sm:grid-cols-2">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
id="start-date"
|
id="start-date"
|
||||||
v-model="startDate"
|
v-model="startDate"
|
||||||
:min-value="minDate"
|
:min-value="minDate"
|
||||||
|
:max-value="maxDate"
|
||||||
:invalid="!!errors.start"
|
:invalid="!!errors.start"
|
||||||
/>
|
/>
|
||||||
<TimePicker id="start-time" v-model="form.startTime" :invalid="!!errors.start" />
|
<TimePicker id="start-time" v-model="form.startTime" :invalid="!!errors.start" />
|
||||||
|
|
@ -201,12 +266,15 @@ function submit() {
|
||||||
|
|
||||||
<!-- End -->
|
<!-- End -->
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="end-date">{{ $t('reservation.end') }}</Label>
|
<span id="end-label" class="text-sm leading-none font-medium">
|
||||||
<div class="grid gap-2 sm:grid-cols-2">
|
{{ $t('reservation.end') }}
|
||||||
|
</span>
|
||||||
|
<div role="group" aria-labelledby="end-label" class="grid gap-2 sm:grid-cols-2">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
id="end-date"
|
id="end-date"
|
||||||
v-model="endDate"
|
v-model="endDate"
|
||||||
:min-value="startDate ?? minDate"
|
:min-value="startDate ?? minDate"
|
||||||
|
:max-value="maxDate"
|
||||||
:invalid="!!errors.end"
|
:invalid="!!errors.end"
|
||||||
/>
|
/>
|
||||||
<TimePicker id="end-time" v-model="form.endTime" :invalid="!!errors.end" />
|
<TimePicker id="end-time" v-model="form.endTime" :invalid="!!errors.end" />
|
||||||
|
|
@ -242,9 +310,27 @@ function submit() {
|
||||||
{{ $t('reservation.bikes-empty') }}
|
{{ $t('reservation.bikes-empty') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-else class="grid gap-2 sm:grid-cols-2">
|
<div v-else class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="group in bikeGroups"
|
||||||
|
:key="group.size"
|
||||||
|
role="group"
|
||||||
|
:aria-labelledby="`bike-size-${group.size}`"
|
||||||
|
class="grid content-start gap-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:id="`bike-size-${group.size}`"
|
||||||
|
class="text-muted-foreground text-xs font-semibold tracking-wide uppercase"
|
||||||
|
>
|
||||||
|
{{ $t(`reservation.bike-size-${group.size}`) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<p v-if="group.bikes.length === 0" class="text-muted-foreground text-sm">
|
||||||
|
{{ $t('reservation.bike-size-empty') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-for="bike in bikes"
|
v-for="bike in group.bikes"
|
||||||
:key="bike.id"
|
:key="bike.id"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="isUnavailable(bike)"
|
:disabled="isUnavailable(bike)"
|
||||||
|
|
@ -266,6 +352,7 @@ function submit() {
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p>
|
<p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -273,13 +360,7 @@ function submit() {
|
||||||
<!-- Telegram -->
|
<!-- Telegram -->
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="telegram">{{ $t('reservation.telegram') }}</Label>
|
<Label for="telegram">{{ $t('reservation.telegram') }}</Label>
|
||||||
<Input
|
<TelegramInput id="telegram" v-model="form.telegram" :invalid="!!errors.telegram" />
|
||||||
id="telegram"
|
|
||||||
v-model.trim="form.telegram"
|
|
||||||
placeholder="@username"
|
|
||||||
autocomplete="off"
|
|
||||||
:aria-invalid="!!errors.telegram || undefined"
|
|
||||||
/>
|
|
||||||
<p v-if="errors.telegram" class="text-destructive text-xs">{{ errors.telegram }}</p>
|
<p v-if="errors.telegram" class="text-destructive text-xs">{{ errors.telegram }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -317,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>
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ async fn logout(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn logout_docs(op: TransformOperation) -> TransformOperation {
|
fn logout_docs(op: TransformOperation) -> TransformOperation {
|
||||||
op.tag("Auth").summary("Log the user out").response::<401, ()>()
|
op.tag("Auth").summary("Log the user out")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, JsonSchema, Serialize)]
|
#[derive(Debug, JsonSchema, Serialize)]
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@ use schemars::JsonSchema;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::helpers::{admin, admin_desc, unexpected_error},
|
api::helpers::{IdPath, admin, admin_desc, unexpected_error},
|
||||||
core::{
|
core::{
|
||||||
controller::{AnonAppController, AppController},
|
controller::{AnonAppController, AppController},
|
||||||
models::bike::{Bike, BikeId, BikeStatus},
|
models::bike::{Bike, BikeStatus},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -36,14 +36,12 @@ struct SetStatusForm {
|
||||||
#[axum::debug_handler]
|
#[axum::debug_handler]
|
||||||
async fn set_status(
|
async fn set_status(
|
||||||
ac: AppController,
|
ac: AppController,
|
||||||
Path(id): Path<BikeId>,
|
Path(IdPath { id }): Path<IdPath>,
|
||||||
Json(SetStatusForm { status }): Json<SetStatusForm>,
|
Json(SetStatusForm { status }): Json<SetStatusForm>,
|
||||||
) -> 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,5 +1,7 @@
|
||||||
use aide::transform::TransformResponse;
|
use aide::transform::TransformResponse;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::Deserialize;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use crate::core::{
|
use crate::core::{
|
||||||
|
|
@ -7,11 +9,9 @@ use crate::core::{
|
||||||
models::unit::UnitId,
|
models::unit::UnitId,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Narrows a session down to "member of this unit", or 403.
|
|
||||||
/// `manager(ac, unit)?` in a handler is the whole authorization check.
|
|
||||||
pub fn manager(
|
pub fn manager(
|
||||||
ac: AppController,
|
ac: AppController,
|
||||||
unit: UnitId,
|
unit: Option<UnitId>,
|
||||||
) -> Result<ManagerAppController, (StatusCode, String)> {
|
) -> Result<ManagerAppController, (StatusCode, String)> {
|
||||||
ac.try_into_manager(unit).map_err(|_| {
|
ac.try_into_manager(unit).map_err(|_| {
|
||||||
(
|
(
|
||||||
|
|
@ -22,7 +22,7 @@ pub fn manager(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn manager_desc<T>(op: TransformResponse<'_, T>) -> TransformResponse<'_, T> {
|
pub fn manager_desc<T>(op: TransformResponse<'_, T>) -> TransformResponse<'_, T> {
|
||||||
op.description("Forbidden - the user must be part of the unit")
|
op.description("Forbidden - the user must be part of the unit, or an admin when the reservation names no known unit")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Narrows a session down to "admin", or 403
|
/// Narrows a session down to "admin", or 403
|
||||||
|
|
@ -39,7 +39,6 @@ pub fn admin_desc<T>(op: TransformResponse<'_, T>) -> TransformResponse<'_, T> {
|
||||||
op.description("Forbidden - the user must be an admin")
|
op.description("Forbidden - the user must be an admin")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Last resort branch of a handler `match`: logs the error and answers 500
|
|
||||||
pub fn unexpected_error<T>(fn_name: &str, err: ControllerError) -> Result<T, (StatusCode, String)> {
|
pub fn unexpected_error<T>(fn_name: &str, err: ControllerError) -> Result<T, (StatusCode, String)> {
|
||||||
error!("[HANDLER] {fn_name}: Unexpected error: {err:?}");
|
error!("[HANDLER] {fn_name}: Unexpected error: {err:?}");
|
||||||
Err((
|
Err((
|
||||||
|
|
@ -54,3 +53,8 @@ pub fn desc<T>(
|
||||||
) -> impl FnOnce(TransformResponse<'_, T>) -> TransformResponse<'_, T> {
|
) -> impl FnOnce(TransformResponse<'_, T>) -> TransformResponse<'_, T> {
|
||||||
|op| op.description(description)
|
|op| op.description(description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub struct IdPath {
|
||||||
|
pub id: i32,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,12 +43,8 @@ pub fn get_router(aac: AnonAppController) -> Router {
|
||||||
|
|
||||||
let config = utils::config::get();
|
let config = utils::config::get();
|
||||||
|
|
||||||
// Sessions live in memory: everybody is logged out when the backend
|
|
||||||
// restarts. Swap the store for a persistent one if that becomes a problem.
|
|
||||||
let session_layer = SessionManagerLayer::new(MemoryStore::default())
|
let session_layer = SessionManagerLayer::new(MemoryStore::default())
|
||||||
// Over plain http in development the cookie cannot be `Secure`
|
|
||||||
.with_secure(config.get_base_url().starts_with("https://"))
|
.with_secure(config.get_base_url().starts_with("https://"))
|
||||||
// The provider sends the browser back with a top level navigation
|
|
||||||
.with_same_site(SameSite::Lax)
|
.with_same_site(SameSite::Lax)
|
||||||
.with_expiry(Expiry::OnInactivity(Duration::minutes(
|
.with_expiry(Expiry::OnInactivity(Duration::minutes(
|
||||||
config.get_session_lifetime(),
|
config.get_session_lifetime(),
|
||||||
|
|
@ -64,7 +60,6 @@ pub fn get_router(aac: AnonAppController) -> Router {
|
||||||
|op| op.tag("misc").summary("Get app version"),
|
|op| op.tag("misc").summary("Get app version"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// `auth` carries its own `/api/...` paths, so it is merged, not nested
|
|
||||||
.merge(auth::routes())
|
.merge(auth::routes())
|
||||||
.nest_api_service("/api/bikes", bikes::routes())
|
.nest_api_service("/api/bikes", bikes::routes())
|
||||||
.nest_api_service("/api/reservations", reservations::routes())
|
.nest_api_service("/api/reservations", reservations::routes())
|
||||||
|
|
@ -91,8 +86,6 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lets a handler take an `AppController`, which requires a session: asking for
|
|
||||||
/// it *is* the authentication check.
|
|
||||||
impl<S> FromRequestParts<S> for AppController
|
impl<S> FromRequestParts<S> for AppController
|
||||||
where
|
where
|
||||||
S: Send + Sync,
|
S: Send + Sync,
|
||||||
|
|
@ -118,8 +111,6 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The controllers are not part of the request/response bodies, but asking for
|
|
||||||
// an `AppController` documents the 401 and the cookie requirement.
|
|
||||||
impl OperationOutput for AnonAppController {
|
impl OperationOutput for AnonAppController {
|
||||||
type Inner = Self;
|
type Inner = Self;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,38 @@
|
||||||
//! 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.
|
||||||
|
|
||||||
use aide::{
|
use aide::{
|
||||||
axum::{ApiRouter, routing::get_with},
|
axum::{
|
||||||
|
ApiRouter,
|
||||||
|
routing::{get_with, put_with},
|
||||||
|
},
|
||||||
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::{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, ReservationId, 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(
|
.api_route(
|
||||||
"/{id}/status",
|
"/",
|
||||||
aide::axum::routing::put_with(set_status, set_status_docs),
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[axum::debug_handler]
|
#[axum::debug_handler]
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -58,7 +100,7 @@ struct SetStatusForm {
|
||||||
#[axum::debug_handler]
|
#[axum::debug_handler]
|
||||||
async fn set_status(
|
async fn set_status(
|
||||||
ac: AppController,
|
ac: AppController,
|
||||||
Path(id): Path<ReservationId>,
|
Path(IdPath { id }): Path<IdPath>,
|
||||||
Json(SetStatusForm { status }): Json<SetStatusForm>,
|
Json(SetStatusForm { status }): Json<SetStatusForm>,
|
||||||
) -> Result<(), (StatusCode, String)> {
|
) -> Result<(), (StatusCode, String)> {
|
||||||
// The unit is not in the body: it is the reservation's own
|
// The unit is not in the body: it is the reservation's own
|
||||||
|
|
@ -70,14 +112,14 @@ async fn set_status(
|
||||||
Err(err) => return unexpected_error("set_status", err),
|
Err(err) => return unexpected_error("set_status", err),
|
||||||
};
|
};
|
||||||
|
|
||||||
match manager(ac, reservation.unit.id)?
|
match manager(ac, reservation.unit.scope())?
|
||||||
.set_reservation_status(id, status)
|
.set_reservation_status(id, 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
|
||||||
|
.db
|
||||||
|
.take_whiskey_data(state.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
debug!("unknown, already used or expired oidc state");
|
debug!("unknown, already used or expired oidc state");
|
||||||
AuthnControllerError::OIDCProtocolError
|
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
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,21 @@ impl AppController {
|
||||||
&self.user
|
&self.user
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An admin manages every unit: refusing them here would only produce
|
pub fn try_into_manager(
|
||||||
/// surprising 403s on routes they are otherwise allowed to use.
|
self,
|
||||||
pub fn try_into_manager(self, unit: UnitId) -> Result<ManagerAppController, ControllerError> {
|
unit: Option<UnitId>,
|
||||||
if self.user.admin || self.user.units.iter().any(|u| u.id == unit) {
|
) -> Result<ManagerAppController, ControllerError> {
|
||||||
|
let allowed = match unit {
|
||||||
|
Some(unit) => self.user.admin || self.user.units.iter().any(|u| u.id == unit),
|
||||||
|
None => self.user.admin,
|
||||||
|
};
|
||||||
|
if allowed {
|
||||||
Ok(ManagerAppController { inner: self, unit })
|
Ok(ManagerAppController { inner: self, unit })
|
||||||
} else {
|
} else {
|
||||||
Err(ControllerError::ManagerAuthorizationError(unit))
|
match unit {
|
||||||
|
Some(unit) => Err(ControllerError::ManagerAuthorizationError(unit)),
|
||||||
|
None => Err(ControllerError::AdminAuthorizationError),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,11 +98,10 @@ impl AppController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A member of `unit`, acting for that unit
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ManagerAppController {
|
pub struct ManagerAppController {
|
||||||
inner: AppController,
|
inner: AppController,
|
||||||
pub(crate) unit: UnitId,
|
pub(crate) unit: Option<UnitId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Deref for ManagerAppController {
|
impl Deref for ManagerAppController {
|
||||||
|
|
@ -116,7 +123,7 @@ impl Deref for AdminAppController {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdminAppController {
|
impl AdminAppController {
|
||||||
pub fn into_manager(self, unit: UnitId) -> ManagerAppController {
|
pub fn into_manager(self, unit: Option<UnitId>) -> ManagerAppController {
|
||||||
ManagerAppController {
|
ManagerAppController {
|
||||||
inner: self.inner,
|
inner: self.inner,
|
||||||
unit,
|
unit,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -67,7 +82,7 @@ impl ManagerAppController {
|
||||||
}
|
}
|
||||||
|
|
||||||
let current = self.db.get_reservation(reservation.id).await?;
|
let current = self.db.get_reservation(reservation.id).await?;
|
||||||
if current.unit.id != self.unit {
|
if current.unit.scope() != self.unit {
|
||||||
return Err(ControllerError::ImmutableUnitModificationError);
|
return Err(ControllerError::ImmutableUnitModificationError);
|
||||||
}
|
}
|
||||||
if current.status.is_final() {
|
if current.status.is_final() {
|
||||||
|
|
@ -86,7 +101,7 @@ impl ManagerAppController {
|
||||||
status: ReservationStatus,
|
status: ReservationStatus,
|
||||||
) -> Result<(), ControllerError> {
|
) -> Result<(), ControllerError> {
|
||||||
let current = self.db.get_reservation(id).await?;
|
let current = self.db.get_reservation(id).await?;
|
||||||
if current.unit.id != self.unit {
|
if current.unit.scope() != self.unit {
|
||||||
return Err(ControllerError::ImmutableUnitModificationError);
|
return Err(ControllerError::ImmutableUnitModificationError);
|
||||||
}
|
}
|
||||||
let current = current.status;
|
let current = current.status;
|
||||||
|
|
@ -103,7 +118,7 @@ impl ManagerAppController {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_reservation(&self, id: ReservationId) -> Result<(), ControllerError> {
|
pub async fn delete_reservation(&self, id: ReservationId) -> Result<(), ControllerError> {
|
||||||
if self.db.get_reservation(id).await?.unit.id != self.unit {
|
if self.db.get_reservation(id).await?.unit.scope() != self.unit {
|
||||||
return Err(ControllerError::ImmutableUnitModificationError);
|
return Err(ControllerError::ImmutableUnitModificationError);
|
||||||
}
|
}
|
||||||
self.db.delete_reservation(id).await.map_err(Into::into)
|
self.db.delete_reservation(id).await.map_err(Into::into)
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,15 @@ pub enum BikeStatus {
|
||||||
OutOfService,
|
OutOfService,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Frame size. The reservation form lets a requester ask for one kind or the
|
||||||
|
/// other, so it is part of the bike rather than being read off its name.
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, Copy, JsonSchema, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BikeSize {
|
||||||
|
Large,
|
||||||
|
Small,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||||
pub struct Bike {
|
pub struct Bike {
|
||||||
pub id: BikeId,
|
pub id: BikeId,
|
||||||
|
|
@ -20,6 +29,7 @@ pub struct Bike {
|
||||||
pub key_quantity: i32,
|
pub key_quantity: i32,
|
||||||
pub drivetrain: Option<String>,
|
pub drivetrain: Option<String>,
|
||||||
pub battery: Option<String>,
|
pub battery: Option<String>,
|
||||||
|
pub size: BikeSize,
|
||||||
pub status: BikeStatus,
|
pub status: BikeStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,5 +40,6 @@ pub struct NewBike {
|
||||||
pub key_quantity: i32,
|
pub key_quantity: i32,
|
||||||
pub drivetrain: Option<String>,
|
pub drivetrain: Option<String>,
|
||||||
pub battery: Option<String>,
|
pub battery: Option<String>,
|
||||||
|
pub size: BikeSize,
|
||||||
pub status: BikeStatus,
|
pub status: BikeStatus,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,62 @@ impl ReservationStatus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The unit a reservation is filed for.
|
||||||
|
///
|
||||||
|
/// Either one we know — a Whiskey group, with its row — or a plain name the
|
||||||
|
/// requester typed. Typing a name must never create a unit, so the two are
|
||||||
|
/// exclusive by construction rather than "a name that may or may not resolve".
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum ReservationUnit {
|
||||||
|
/// Picked from the units the requester belongs to
|
||||||
|
Known { unit: Unit },
|
||||||
|
/// Typed by hand, stored on the reservation and nowhere else
|
||||||
|
Free { name: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReservationUnit {
|
||||||
|
/// What to show: the unit's name, or the typed one
|
||||||
|
pub fn label(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
ReservationUnit::Known { unit } => &unit.name,
|
||||||
|
ReservationUnit::Free { name } => name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scope somebody must manage to act on this reservation. `None` for a
|
||||||
|
/// typed name: nobody is the manager of a unit we do not know, so only an
|
||||||
|
/// admin qualifies.
|
||||||
|
pub fn scope(&self) -> Option<UnitId> {
|
||||||
|
match self {
|
||||||
|
ReservationUnit::Known { unit } => Some(unit.id),
|
||||||
|
ReservationUnit::Free { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same choice, as the client sends it: only the id travels for a known unit.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum NewReservationUnit {
|
||||||
|
Known { id: UnitId },
|
||||||
|
Free { name: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewReservationUnit {
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
NewReservationUnit::Known { .. } => true,
|
||||||
|
NewReservationUnit::Free { name } => !name.trim().is_empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||||
pub struct Reservation {
|
pub struct Reservation {
|
||||||
pub id: ReservationId,
|
pub id: ReservationId,
|
||||||
pub unit: Unit,
|
/// The one unit the bikes are lent to
|
||||||
|
pub unit: ReservationUnit,
|
||||||
pub start_time: DateTime<Utc>,
|
pub start_time: DateTime<Utc>,
|
||||||
pub end_time: DateTime<Utc>,
|
pub end_time: DateTime<Utc>,
|
||||||
pub requester: UserId,
|
pub requester: UserId,
|
||||||
|
|
@ -54,40 +106,108 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||||
pub struct NewReservation {
|
pub struct NewReservation {
|
||||||
pub unit: UnitId,
|
pub unit: NewReservationUnit,
|
||||||
pub start_time: DateTime<Utc>,
|
pub start_time: DateTime<Utc>,
|
||||||
pub end_time: DateTime<Utc>,
|
pub end_time: DateTime<Utc>,
|
||||||
pub users: Vec<UserId>,
|
pub users: Vec<UserId>,
|
||||||
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)]
|
||||||
pub struct ReservationEdit {
|
pub struct ReservationEdit {
|
||||||
pub id: ReservationId,
|
pub id: ReservationId,
|
||||||
pub unit: UnitId,
|
pub unit: NewReservationUnit,
|
||||||
pub start_time: DateTime<Utc>,
|
pub start_time: DateTime<Utc>,
|
||||||
pub end_time: DateTime<Utc>,
|
pub end_time: DateTime<Utc>,
|
||||||
pub users: Vec<UserId>,
|
pub users: Vec<UserId>,
|
||||||
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.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReservationEdit {
|
impl ReservationEdit {
|
||||||
pub fn is_valid(&self) -> bool {
|
pub fn is_valid(&self) -> bool {
|
||||||
self.end_time > self.start_time && !self.bikes.is_empty() && !self.users.is_empty()
|
self.unit.is_valid()
|
||||||
|
&& 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>;
|
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();
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
//! The bike fleet. `bike_status` is a postgres enum: `BikeStatusDB` mirrors the
|
//! The bike fleet. `bike_status` and `bike_size` are postgres enums:
|
||||||
//! core `BikeStatus` so that sqlx stays out of `core/models`.
|
//! `BikeStatusDB` and `BikeSizeDB` mirror the core types so that sqlx stays out
|
||||||
|
//! of `core/models`.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sqlx::{query, query_as};
|
use sqlx::{query, query_as};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
core::{
|
core::{
|
||||||
models::bike::{Bike, BikeId, BikeStatus, NewBike},
|
models::bike::{Bike, BikeId, BikeSize, BikeStatus, NewBike},
|
||||||
repositories::{RepositoryError, bikes_repository::BikesRepository},
|
repositories::{RepositoryError, bikes_repository::BikesRepository},
|
||||||
},
|
},
|
||||||
services::database::SqlxDatabase,
|
services::database::SqlxDatabase,
|
||||||
|
|
@ -37,6 +38,31 @@ impl From<BikeStatus> for BikeStatusDB {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, sqlx::Type)]
|
||||||
|
#[sqlx(type_name = "bike_size", rename_all = "snake_case")]
|
||||||
|
enum BikeSizeDB {
|
||||||
|
Large,
|
||||||
|
Small,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BikeSizeDB> for BikeSize {
|
||||||
|
fn from(value: BikeSizeDB) -> Self {
|
||||||
|
match value {
|
||||||
|
BikeSizeDB::Large => BikeSize::Large,
|
||||||
|
BikeSizeDB::Small => BikeSize::Small,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BikeSize> for BikeSizeDB {
|
||||||
|
fn from(value: BikeSize) -> Self {
|
||||||
|
match value {
|
||||||
|
BikeSize::Large => BikeSizeDB::Large,
|
||||||
|
BikeSize::Small => BikeSizeDB::Small,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct BikeDB {
|
struct BikeDB {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
@ -44,6 +70,7 @@ struct BikeDB {
|
||||||
pub key_quantity: i32,
|
pub key_quantity: i32,
|
||||||
pub drivetrain: Option<String>,
|
pub drivetrain: Option<String>,
|
||||||
pub battery: Option<String>,
|
pub battery: Option<String>,
|
||||||
|
pub size: BikeSizeDB,
|
||||||
pub status: BikeStatusDB,
|
pub status: BikeStatusDB,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,6 +83,7 @@ impl From<BikeDB> for Bike {
|
||||||
key_quantity: value.key_quantity,
|
key_quantity: value.key_quantity,
|
||||||
drivetrain: value.drivetrain,
|
drivetrain: value.drivetrain,
|
||||||
battery: value.battery,
|
battery: value.battery,
|
||||||
|
size: value.size.into(),
|
||||||
status: value.status.into(),
|
status: value.status.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +95,7 @@ impl BikesRepository for SqlxDatabase {
|
||||||
Ok(query_as!(
|
Ok(query_as!(
|
||||||
BikeDB,
|
BikeDB,
|
||||||
r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery,
|
r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery,
|
||||||
status AS "status: BikeStatusDB"
|
"size" AS "size: BikeSizeDB", status AS "status: BikeStatusDB"
|
||||||
FROM bikes
|
FROM bikes
|
||||||
ORDER BY "name""#
|
ORDER BY "name""#
|
||||||
)
|
)
|
||||||
|
|
@ -82,7 +110,7 @@ impl BikesRepository for SqlxDatabase {
|
||||||
Ok(query_as!(
|
Ok(query_as!(
|
||||||
BikeDB,
|
BikeDB,
|
||||||
r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery,
|
r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery,
|
||||||
status AS "status: BikeStatusDB"
|
"size" AS "size: BikeSizeDB", status AS "status: BikeStatusDB"
|
||||||
FROM bikes
|
FROM bikes
|
||||||
WHERE id = $1"#,
|
WHERE id = $1"#,
|
||||||
id
|
id
|
||||||
|
|
@ -94,17 +122,19 @@ impl BikesRepository for SqlxDatabase {
|
||||||
|
|
||||||
async fn create_bike(&self, bike: NewBike) -> Result<Bike, RepositoryError> {
|
async fn create_bike(&self, bike: NewBike) -> Result<Bike, RepositoryError> {
|
||||||
let status: BikeStatusDB = bike.status.into();
|
let status: BikeStatusDB = bike.status.into();
|
||||||
|
let size: BikeSizeDB = bike.size.into();
|
||||||
Ok(query_as!(
|
Ok(query_as!(
|
||||||
BikeDB,
|
BikeDB,
|
||||||
r#"INSERT INTO bikes ("name", key_number, key_quantity, drivetrain, battery, status)
|
r#"INSERT INTO bikes ("name", key_number, key_quantity, drivetrain, battery, "size", status)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id, "name", key_number, key_quantity, drivetrain, battery,
|
RETURNING id, "name", key_number, key_quantity, drivetrain, battery,
|
||||||
status AS "status: BikeStatusDB""#,
|
"size" AS "size: BikeSizeDB", status AS "status: BikeStatusDB""#,
|
||||||
bike.name,
|
bike.name,
|
||||||
bike.key_number,
|
bike.key_number,
|
||||||
bike.key_quantity,
|
bike.key_quantity,
|
||||||
bike.drivetrain,
|
bike.drivetrain,
|
||||||
bike.battery,
|
bike.battery,
|
||||||
|
size as BikeSizeDB,
|
||||||
status as BikeStatusDB
|
status as BikeStatusDB
|
||||||
)
|
)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
|
|
@ -114,10 +144,11 @@ impl BikesRepository for SqlxDatabase {
|
||||||
|
|
||||||
async fn update_bike(&self, bike: Bike) -> Result<(), RepositoryError> {
|
async fn update_bike(&self, bike: Bike) -> Result<(), RepositoryError> {
|
||||||
let status: BikeStatusDB = bike.status.into();
|
let status: BikeStatusDB = bike.status.into();
|
||||||
|
let size: BikeSizeDB = bike.size.into();
|
||||||
let result = query!(
|
let result = query!(
|
||||||
r#"UPDATE bikes
|
r#"UPDATE bikes
|
||||||
SET "name" = $2, key_number = $3, key_quantity = $4, drivetrain = $5,
|
SET "name" = $2, key_number = $3, key_quantity = $4, drivetrain = $5,
|
||||||
battery = $6, status = $7
|
battery = $6, "size" = $7, status = $8
|
||||||
WHERE id = $1"#,
|
WHERE id = $1"#,
|
||||||
bike.id,
|
bike.id,
|
||||||
bike.name,
|
bike.name,
|
||||||
|
|
@ -125,6 +156,7 @@ impl BikesRepository for SqlxDatabase {
|
||||||
bike.key_quantity,
|
bike.key_quantity,
|
||||||
bike.drivetrain,
|
bike.drivetrain,
|
||||||
bike.battery,
|
bike.battery,
|
||||||
|
size as BikeSizeDB,
|
||||||
status as BikeStatusDB
|
status as BikeStatusDB
|
||||||
)
|
)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ use crate::{
|
||||||
core::{
|
core::{
|
||||||
models::{
|
models::{
|
||||||
reservation::{
|
reservation::{
|
||||||
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
|
NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId,
|
||||||
|
ReservationStatus, ReservationUnit,
|
||||||
},
|
},
|
||||||
unit::{Unit, UnitId},
|
unit::{Unit, UnitId},
|
||||||
user::UserId,
|
user::UserId,
|
||||||
|
|
@ -61,14 +62,16 @@ impl From<ReservationStatus> for ReservationStatusDB {
|
||||||
|
|
||||||
struct ReservationDB {
|
struct ReservationDB {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub unit_id: i32,
|
pub unit_id: Option<i32>,
|
||||||
pub unit_name: String,
|
pub unit_name: Option<String>,
|
||||||
|
pub unit_label: Option<String>,
|
||||||
pub start_time: DateTime<Utc>,
|
pub start_time: DateTime<Utc>,
|
||||||
pub end_time: DateTime<Utc>,
|
pub end_time: DateTime<Utc>,
|
||||||
pub requester_id: i32,
|
pub requester_id: i32,
|
||||||
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>,
|
||||||
}
|
}
|
||||||
|
|
@ -79,9 +82,17 @@ impl TryFrom<ReservationDB> for Reservation {
|
||||||
fn try_from(value: ReservationDB) -> Result<Self, Self::Error> {
|
fn try_from(value: ReservationDB) -> Result<Self, Self::Error> {
|
||||||
Ok(Reservation {
|
Ok(Reservation {
|
||||||
id: value.id,
|
id: value.id,
|
||||||
unit: Unit {
|
unit: match (value.unit_id, value.unit_name, value.unit_label) {
|
||||||
id: value.unit_id,
|
(Some(id), Some(name), None) => ReservationUnit::Known {
|
||||||
name: value.unit_name,
|
unit: Unit { id, name },
|
||||||
|
},
|
||||||
|
(None, None, Some(name)) => ReservationUnit::Free { name },
|
||||||
|
// The `reservations_unit_xor` check makes this unreachable
|
||||||
|
other => {
|
||||||
|
return Err(RepositoryError::TypeConversion(format!(
|
||||||
|
"reservation with an inconsistent unit: {other:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
start_time: value.start_time,
|
start_time: value.start_time,
|
||||||
end_time: value.end_time,
|
end_time: value.end_time,
|
||||||
|
|
@ -90,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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -136,6 +148,22 @@ impl SqlxDatabase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The two exclusive columns behind `NewReservationUnit`: exactly one is `Some`,
|
||||||
|
/// which is what the `reservations_unit_xor` check enforces.
|
||||||
|
fn split_unit(unit: &NewReservationUnit) -> (Option<i32>, Option<String>) {
|
||||||
|
match unit {
|
||||||
|
NewReservationUnit::Known { id } => (Some(*id), None),
|
||||||
|
NewReservationUnit::Free { name } => (None, Some(name.trim().to_owned())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
|
@ -144,12 +172,16 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
r#"SELECT
|
r#"SELECT
|
||||||
r.id,
|
r.id,
|
||||||
r.unit_id,
|
r.unit_id,
|
||||||
un."name" AS unit_name,
|
-- `?` 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.start_time,
|
||||||
r.end_time,
|
r.end_time,
|
||||||
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(
|
||||||
|
|
@ -167,7 +199,7 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
WHERE reservation_id = r.id ORDER BY bike_id
|
WHERE reservation_id = r.id ORDER BY bike_id
|
||||||
) AS "bikes!"
|
) AS "bikes!"
|
||||||
FROM reservations r
|
FROM reservations r
|
||||||
JOIN units un ON un.id = r.unit_id
|
LEFT JOIN units un ON un.id = r.unit_id
|
||||||
ORDER BY r.start_time DESC"#
|
ORDER BY r.start_time DESC"#
|
||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
|
|
@ -186,12 +218,16 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
r#"SELECT
|
r#"SELECT
|
||||||
r.id,
|
r.id,
|
||||||
r.unit_id,
|
r.unit_id,
|
||||||
un."name" AS unit_name,
|
-- `?` 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.start_time,
|
||||||
r.end_time,
|
r.end_time,
|
||||||
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(
|
||||||
|
|
@ -209,7 +245,7 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
WHERE reservation_id = r.id ORDER BY bike_id
|
WHERE reservation_id = r.id ORDER BY bike_id
|
||||||
) AS "bikes!"
|
) AS "bikes!"
|
||||||
FROM reservations r
|
FROM reservations r
|
||||||
JOIN units un ON un.id = r.unit_id
|
LEFT JOIN units un ON un.id = r.unit_id
|
||||||
WHERE r.unit_id = $1
|
WHERE r.unit_id = $1
|
||||||
ORDER BY r.start_time DESC"#,
|
ORDER BY r.start_time DESC"#,
|
||||||
unit
|
unit
|
||||||
|
|
@ -227,12 +263,16 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
r#"SELECT
|
r#"SELECT
|
||||||
r.id,
|
r.id,
|
||||||
r.unit_id,
|
r.unit_id,
|
||||||
un."name" AS unit_name,
|
-- `?` 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.start_time,
|
||||||
r.end_time,
|
r.end_time,
|
||||||
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(
|
||||||
|
|
@ -250,7 +290,7 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
WHERE reservation_id = r.id ORDER BY bike_id
|
WHERE reservation_id = r.id ORDER BY bike_id
|
||||||
) AS "bikes!"
|
) AS "bikes!"
|
||||||
FROM reservations r
|
FROM reservations r
|
||||||
JOIN units un ON un.id = r.unit_id
|
LEFT JOIN units un ON un.id = r.unit_id
|
||||||
WHERE r.id = $1"#,
|
WHERE r.id = $1"#,
|
||||||
id
|
id
|
||||||
)
|
)
|
||||||
|
|
@ -268,16 +308,22 @@ 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 linka_emails = trim_emails(&reservation.linka_emails);
|
||||||
let id = query!(
|
let id = query!(
|
||||||
r#"INSERT INTO reservations (unit_id, start_time, end_time, requester_id, telegram, "description")
|
r#"INSERT INTO reservations
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
(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"#,
|
RETURNING id"#,
|
||||||
reservation.unit,
|
unit_id,
|
||||||
|
unit_label,
|
||||||
reservation.start_time,
|
reservation.start_time,
|
||||||
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?
|
||||||
|
|
@ -304,16 +350,21 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
) -> Result<(), RepositoryError> {
|
) -> Result<(), RepositoryError> {
|
||||||
let mut tx = self.pool.begin().await?;
|
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!(
|
let result = query!(
|
||||||
r#"UPDATE reservations
|
r#"UPDATE reservations
|
||||||
SET unit_id = $2, start_time = $3, end_time = $4, telegram = $5, "description" = $6
|
SET unit_id = $2, unit_label = $3, start_time = $4, end_time = $5,
|
||||||
|
telegram = $6, "description" = $7, linka_emails = $8
|
||||||
WHERE id = $1"#,
|
WHERE id = $1"#,
|
||||||
reservation.id,
|
reservation.id,
|
||||||
reservation.unit,
|
unit_id,
|
||||||
|
unit_label,
|
||||||
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?;
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ pub struct OidcConfig {
|
||||||
pub issuer_url: String,
|
pub issuer_url: String,
|
||||||
pub client_id: String,
|
pub client_id: String,
|
||||||
pub client_secret: String,
|
pub client_secret: String,
|
||||||
/// How long a session stays valid, in minutes
|
|
||||||
pub session_lifetime: Option<i64>,
|
pub session_lifetime: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,7 +37,6 @@ pub struct OidcConfig {
|
||||||
pub struct DevUserConfig {
|
pub struct DevUserConfig {
|
||||||
pub firstname: String,
|
pub firstname: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Also the handle used to pick the user at login
|
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub external_id: Option<String>,
|
pub external_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
|
||||||
|
|
@ -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