From 2d6ef8de6c9e72e340db0f5c2ed5db5b19bbc6f9 Mon Sep 17 00:00:00 2001 From: Antoine Pelletier Date: Tue, 25 Aug 2026 12:10:53 +0200 Subject: [PATCH] feat: add linka --- .gitignore | 1 - README.md | 32 ++ config.example.yml | 4 + .../20260825120000_linka_whitelist.sql | 16 + db/schema.sql | 21 +- frontend/src/components/admin/BikeFleet.vue | 127 ++++-- frontend/src/lib/api.d.ts | 125 ++++++ frontend/src/locales/en.yml | 10 + frontend/src/locales/fr.yml | 10 + frontend/src/services/api/bikes.ts | 30 +- frontend/src/services/api/client.ts | 4 +- frontend/src/utils/types.ts | 3 + frontend/src/views/AdminView.vue | 2 +- src/api/bikes.rs | 37 +- src/core/controller/bikes.rs | 51 ++- src/core/controller/linka.rs | 385 ++++++++++++++++++ src/core/controller/mod.rs | 1 + src/core/controller/reservations.rs | 7 + src/core/models/linka.rs | 110 +++++ src/core/models/mod.rs | 1 + src/core/repositories/linka_repository.rs | 27 ++ src/core/repositories/mod.rs | 8 +- src/main.rs | 20 + src/services/database/linka.rs | 92 +++++ src/services/database/mod.rs | 1 + src/services/linka/locks.rs | 115 ++++++ src/services/linka/mod.rs | 279 +++++++++++++ src/services/linka/rentals.rs | 121 ++++++ src/services/linka/whitelist.rs | 86 ++++ src/services/mod.rs | 1 + .../bike_ridden_outside_reservation.rs | 39 ++ .../bike_ridden_without_reservation.rs | 29 ++ src/services/telegram/messages/mod.rs | 47 ++- src/services/telegram/mod.rs | 82 +++- 34 files changed, 1873 insertions(+), 51 deletions(-) create mode 100644 db/migrations/20260825120000_linka_whitelist.sql create mode 100644 src/core/controller/linka.rs create mode 100644 src/core/models/linka.rs create mode 100644 src/core/repositories/linka_repository.rs create mode 100644 src/services/database/linka.rs create mode 100644 src/services/linka/locks.rs create mode 100644 src/services/linka/mod.rs create mode 100644 src/services/linka/rentals.rs create mode 100644 src/services/linka/whitelist.rs create mode 100644 src/services/telegram/messages/bike_ridden_outside_reservation.rs create mode 100644 src/services/telegram/messages/bike_ridden_without_reservation.rs diff --git a/.gitignore b/.gitignore index 76c8f59..3c37637 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,3 @@ config.yaml /LEGACY summary.ai .env -.env.example \ No newline at end of file diff --git a/README.md b/README.md index edda67e..0515197 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,38 @@ cargo install sqlx-cli cargo sqlx prepare # writes .sqlx/, commit it ``` +### Linka Go + +The locks are Linka Go's, and so is the list of who may open them. `services/linka` is the +whole of what this app knows about the platform: the access token and its refresh, then one +file per family of calls (`locks`, `rentals`, `whitelist`). The wording of what comes back +is translated into the app's own terms (`core/models/linka.rs`) before anything else sees +it — no serial number or lock id leaves that module. + +A ticker runs one pass a minute (`sync_linka`): + +1. **the fleet is read** — lock state, battery, and who has a bike out. *In use* means a + ride under way on that very bike, not a reservation that covers it; +2. **service state follows the platform**: a bike out of service there is out of service + here. The other way round is pushed as it happens, and a lock that refuses the change + leaves the app unchanged (the api answers 502, and says so); +3. **the access list is reconciled**: everybody entitled by a live reservation is on it, + nobody else. Riders are let in 30 minutes before their booking and taken off 30 minutes + after it. Approving, editing or cancelling reconciles straight away, without waiting for + the tick. + +The platform offers no way to read that list back, which is why `linka_whitelist` records +what has actually been asked of it: the difference between "should be allowed" and "has +been allowed" is what gets called, so a failed call is retried at the next tick and an +edited reservation never leaves somebody behind. + +A bike taken out with nothing entitling its rider to it raises an alert — on the admin page +and in the Telegram group, once per episode. + +**On a development machine, set `LINKA_DRY_RUN=1`.** The fleet is still read, but nothing is +written: without it, the made-up addresses of `db/seed.sql` would be granted access to the +real bikes. + ### Migrations ```bash diff --git a/config.example.yml b/config.example.yml index d7224c1..ac4d83e 100644 --- a/config.example.yml +++ b/config.example.yml @@ -32,6 +32,10 @@ oidc: # # Only to point the sender somewhere else than the real bot api # api_url: https://api.telegram.org +# Linka Go — the locks, the rides and the access list — is configured through plain +# `LINKA_*` variables in `.env` rather than here: they are the names the platform +# itself documents. See `.env.example`, and set LINKA_DRY_RUN=1 in development. + # Logged in without the provider, debug builds only (POST /api/login) dev_users: - firstname: Milan diff --git a/db/migrations/20260825120000_linka_whitelist.sql b/db/migrations/20260825120000_linka_whitelist.sql new file mode 100644 index 0000000..125e8e2 --- /dev/null +++ b/db/migrations/20260825120000_linka_whitelist.sql @@ -0,0 +1,16 @@ +-- migrate:up + +-- What the app has actually put on the Linka Go restriction list. +-- +-- The platform offers no way to read that list back, so the only honest record +-- of it is the one kept here: each tick compares the addresses that *should* be +-- allowed right now against this table, and calls the api for the difference. +-- A row is written only once the call has succeeded, so a failed call is simply +-- retried at the next tick. +CREATE TABLE linka_whitelist ( + email text PRIMARY KEY, + added_at timestamptz NOT NULL DEFAULT now() +); + +-- migrate:down +DROP TABLE linka_whitelist; diff --git a/db/schema.sql b/db/schema.sql index 8f44085..aa2e26b 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -90,6 +90,16 @@ CREATE SEQUENCE public.bikes_id_seq ALTER SEQUENCE public.bikes_id_seq OWNED BY public.bikes.id; +-- +-- Name: linka_whitelist; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.linka_whitelist ( + email text NOT NULL, + added_at timestamp with time zone DEFAULT now() NOT NULL +); + + -- -- Name: oidc_states; Type: TABLE; Schema: public; Owner: - -- @@ -287,6 +297,14 @@ ALTER TABLE ONLY public.bikes ADD CONSTRAINT bikes_pkey PRIMARY KEY (id); +-- +-- Name: linka_whitelist linka_whitelist_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.linka_whitelist + ADD CONSTRAINT linka_whitelist_pkey PRIMARY KEY (email); + + -- -- Name: oidc_states oidc_states_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -509,4 +527,5 @@ INSERT INTO public.schema_migrations (version) VALUES ('20260823230000'), ('20260824120000'), ('20260824140000'), - ('20260824180000'); + ('20260824180000'), + ('20260825120000'); diff --git a/frontend/src/components/admin/BikeFleet.vue b/frontend/src/components/admin/BikeFleet.vue index 075e262..35039ad 100644 --- a/frontend/src/components/admin/BikeFleet.vue +++ b/frontend/src/components/admin/BikeFleet.vue @@ -2,47 +2,46 @@ /** * 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. + * Two states are stored — `in_service` and `out_of_service` — and everything + * else comes from Linka Go: whether the lock is open, and whether somebody has + * the bike out right now. **In use** means a ride under way on that very bike, + * not a reservation that happens to cover it: a booked bike still in the rack + * is not in use, and a bike taken without a booking is. + * + * The button only ever toggles the two real states, and the platform has the + * last word on it — a lock that refuses the change leaves the card as it was. */ import { computed } from 'vue' import { useI18n } from 'vue-i18n' +import { Lock, LockOpen, TriangleAlert } from '@lucide/vue' import { toast } from 'vue-sonner' +import { HttpStatus } from 'http-status-ts' 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' +import { useBikes, useFleetLive, useSetBikeStatus } from '@/services/api/bikes' +import { ApiError, type Bike, type BikeLive } from '@/utils/types' -const props = defineProps<{ reservations: Reservation[] }>() - -const { t } = useI18n() +const { t, locale } = useI18n() const { data: bikes, isPending, isError } = useBikes() +const { data: live } = useFleetLive() const setStatus = useSetBikeStatus() -/** Bikes held by a reservation that is under way right now */ -const inUse = computed(() => { - const ids = new Set() - const now = Date.now() - for (const reservation of props.reservations) { - if (reservation.status !== 'ongoing') continue - // A bike handed over early is no longer in use, even though the - // reservation it belonged to is still running - for (const bike of reservation.bikes) { - if (new Date(bike.end_time).getTime() > now) ids.add(bike.id) - } - } - return ids +/** What the platform says about each bike, by id */ +const platform = computed(() => { + const byBike = new Map() + for (const bike of live.value?.bikes ?? []) byBike.set(bike.bike, bike) + return byBike }) +const alerts = computed(() => live.value?.alerts ?? []) + 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' + return platform.value.get(bike.id)?.rider ? 'in_use' : 'in_service' } // One place decides the colour of a card, so the three states stay legible in @@ -58,9 +57,32 @@ const LABEL_CLASS: Record = { out_of_service: 'text-destructive', } +const formatter = computed( + () => + new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-CH' : 'en-GB', { + dateStyle: 'short', + timeStyle: 'short', + }), +) + +function since(moment: string | null | undefined) { + return moment ? formatter.value.format(new Date(moment)) : '—' +} + 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')) }) + setStatus.mutate( + { id: bike.id, status }, + { + onError: (error) => + toast.error( + // The platform would not take it, so nothing changed here either + error instanceof ApiError && error.status === HttpStatus.BAD_GATEWAY + ? t('admin.bikes.platform-error') + : t('admin.bikes.error'), + ), + }, + ) } @@ -71,7 +93,38 @@ function toggle(bike: Bike) { {{ $t('admin.bikes.intro') }} - + + +
+

+ + {{ $t('admin.bikes.alert.title') }} +

+

+ {{ + alert.reservation + ? $t('admin.bikes.alert.outside', { + bike: alert.bike_name, + rider: alert.rider.name, + email: alert.rider.email, + id: alert.reservation, + allowed: alert.allowed.join(', ') || '—', + since: since(alert.rider.since), + }) + : $t('admin.bikes.alert.without', { + bike: alert.bike_name, + rider: alert.rider.name, + email: alert.rider.email, + since: since(alert.rider.since), + }) + }} +

+
+
@@ -88,13 +141,35 @@ function toggle(bike: Bike) {
{{ bike.name }} {{ $t(`admin.bikes.status.${display(bike)}`) }} + + + + + {{ $t(`admin.bikes.lock.${platform.get(bike.id)!.lock_state}`) }} + +