feat: add linka

This commit is contained in:
Antoine Pelletier 2026-08-25 12:10:53 +02:00
parent 7c38a1e606
commit 2d6ef8de6c
34 changed files with 1873 additions and 51 deletions

1
.gitignore vendored
View file

@ -4,4 +4,3 @@ config.yaml
/LEGACY
summary.ai
.env
.env.example

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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');

View file

@ -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<number>()
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<number, BikeLive>()
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<Display, string> = {
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'),
),
},
)
}
</script>
@ -71,7 +93,38 @@ function toggle(bike: Bike) {
<CardDescription>{{ $t('admin.bikes.intro') }}</CardDescription>
</CardHeader>
<CardContent>
<CardContent class="grid gap-4">
<!-- A bike out with nothing entitling its rider to it. The group is told
at the same time; this is the same alert, where it can be acted on. -->
<div
v-if="alerts.length"
class="border-destructive/40 bg-destructive/5 grid gap-2 rounded-lg border p-4"
>
<p class="text-destructive flex items-center gap-2 text-sm font-medium">
<TriangleAlert class="size-4 shrink-0" />
{{ $t('admin.bikes.alert.title') }}
</p>
<p v-for="alert in alerts" :key="`${alert.bike}-${alert.rider.email}`" class="text-sm">
{{
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),
})
}}
</p>
</div>
<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>
@ -88,13 +141,35 @@ function toggle(bike: Bike) {
<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="flex flex-col items-center gap-2 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>
<!-- What the lock itself reports. Nothing is shown for a bike the
platform says nothing about, rather than a made-up "locked". -->
<span
v-if="platform.get(bike.id)"
class="text-muted-foreground flex items-center gap-1 text-xs"
:title="
platform.get(bike.id)?.rider
? $t('admin.bikes.ridden-by', {
rider: platform.get(bike.id)!.rider!.name,
since: since(platform.get(bike.id)!.rider!.since),
})
: undefined
"
>
<component
:is="platform.get(bike.id)!.lock_state === 'unlocked' ? LockOpen : Lock"
class="size-3.5 shrink-0"
/>
{{ $t(`admin.bikes.lock.${platform.get(bike.id)!.lock_state}`) }}
</span>
<Button
variant="outline"
size="sm"

View file

@ -335,6 +335,56 @@ export interface paths {
patch?: never
trace?: never
}
'/api/bikes/live': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Get what the platform says about the fleet */
get: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description The whole picture, refreshed by the ticker and read by the admin page */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['FleetLive']
}
}
/** @description Unauthenticated - a session is required */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden - the user must be an admin */
403: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/bikes/{id}/status': {
parameters: {
query?: never
@ -387,6 +437,13 @@ export interface paths {
}
content?: never
}
/** @description Linka Go refused the change: nothing was stored */
502: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
post?: never
@ -961,6 +1018,24 @@ export interface components {
size: components['schemas']['BikeSize']
status: components['schemas']['BikeStatus']
}
/** @description One bike, as the platform sees it right now */
BikeLive: {
/**
* Format: int32
* @description Battery of the lock itself, in percent
*/
battery?: number | null
/** Format: int32 */
bike: number
lock_state: components['schemas']['LockState']
/** @description Out of service on the platform. The app's own status follows it. */
out_of_service: boolean
/**
* @description Who is riding it, if anybody. This is what "in use" means: a ride under
* way on this very bike, not a reservation that happens to cover it.
*/
rider?: components['schemas']['Rider'] | null
}
/**
* @description 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.
@ -1041,6 +1116,17 @@ export interface components {
firstname: string
name: string
}
/** @description The whole picture, refreshed by the ticker and read by the admin page */
FleetLive: {
alerts: components['schemas']['UsageAlert'][]
bikes: components['schemas']['BikeLive'][]
/**
* Format: date-time
* @description When the platform was last reached. `None` means never — either it is
* not configured, or every attempt so far has failed.
*/
updated_at?: string | null
}
GetAuthorizeResponse: {
redirect_to: string
}
@ -1048,6 +1134,15 @@ export interface components {
/** Format: int32 */
id: number
}
/**
* @description Whether the bike is physically locked.
*
* `Unknown` is not a failure to answer: it is the platform reporting something
* this app does not recognise, which is worth showing as such rather than
* guessing "locked".
* @enum {string}
*/
LockState: 'locked' | 'unlocked' | 'unknown'
LoginDevForm: {
/** @description Email of one of the `dev_users` of the configuration */
user: string
@ -1248,10 +1343,21 @@ export interface components {
kind: 'free'
name: string
}
Rider: {
email: string
name: string
/** Format: date-time */
since?: string | null
}
SetStatusForm: {
status: components['schemas']['BikeStatus']
}
SetStatusForm2: {
/**
* @description Shown to the people on the reservation when it is cancelled, and ignored
* otherwise. Nothing stores it.
*/
reason?: string | null
status: components['schemas']['ReservationStatus']
}
Unit: {
@ -1260,6 +1366,25 @@ export interface components {
/** @description The Whiskey group name */
name: string
}
/**
* @description A bike being ridden by somebody no reservation entitles to it.
*
* Two cases, told apart by `reservation`: nobody's booking covers this rider
* at all, or their booking is for other bikes.
*/
UsageAlert: {
/** @description The bikes that reservation is for */
allowed: string[]
/** Format: int32 */
bike: number
bike_name: string
/**
* Format: int32
* @description The reservation the rider does have running, when there is one
*/
reservation?: number | null
rider: components['schemas']['Rider']
}
User: {
admin: boolean
email: string

View file

@ -162,6 +162,16 @@ admin:
in_service: In service
out_of_service: Out of service
in_use: In use
lock:
locked: 'Locked'
unlocked: 'Unlocked'
unknown: 'Unknown state'
alert:
title: 'Used without a reservation'
without: 'Cargobike {bike} has been out with {rider} ({email}) since {since}, with no reservation running.'
outside: 'Cargobike {bike} has been out with {rider} ({email}) since {since}, but their reservation #{id} is for: {allowed}.'
platform-error: 'Linka Go would not take the change: the bike was left as it was.'
ridden-by: 'Out with {rider} since {since}'
reservations:
title: Reservations
intro: Requests are waiting for a decision; approved reservations show up below.

View file

@ -163,6 +163,16 @@ admin:
in_service: En service
out_of_service: Hors service
in_use: En usage
lock:
locked: 'Verrouillé'
unlocked: 'Déverrouillé'
unknown: 'État inconnu'
alert:
title: 'Utilisation sans réservation'
without: 'Le cargobike {bike} est utilisé par {rider} ({email}) depuis le {since}, sans aucune réservation en cours.'
outside: 'Le cargobike {bike} est utilisé par {rider} ({email}) depuis le {since}, mais sa réservation #{id} porte sur : {allowed}.'
platform-error: "Linka Go n'a pas accepté le changement : le vélo n'a pas été modifié."
ridden-by: 'Utilisé par {rider} depuis le {since}'
reservations:
title: Réservations
intro: Les demandes attendent une décision ; les réservations validées apparaissent ensuite.

View file

@ -5,10 +5,14 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { HttpStatus } from 'http-status-ts'
import type { Bike, BikeStatus } from '@/utils/types'
import type { Bike, BikeStatus, FleetLive } from '@/utils/types'
import { getClient } from './client'
export const BIKES_KEY = ['bikes']
export const FLEET_LIVE_KEY = ['bikes', 'live']
/** How often the platform's picture of the fleet is asked for again */
const LIVE_REFRESH_MS = 30 * 1000
export function useBikes() {
return useQuery({
@ -24,12 +28,36 @@ export function useBikes() {
})
}
/**
* What Linka Go last said about the fleet: which bike is locked, which is being
* ridden, and by whom.
*
* The backend answers from a snapshot its own ticker refreshes, so this is a
* cheap call: polling it costs the platform nothing. Admin only.
*/
export function useFleetLive() {
return useQuery({
queryKey: FLEET_LIVE_KEY,
refetchInterval: LIVE_REFRESH_MS,
queryFn: async (): Promise<FleetLive> => {
const { data, response } = await getClient().GET('/api/bikes/live')
if (response.status === HttpStatus.OK && data) {
return data
}
throw new Error(`Unexpected status code received: ${response.status}`)
},
})
}
/** 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 }) => {
// A refusal from the platform comes back as a 502, which the client
// turns into an `ApiError`: the card must not pretend the lock heard
// about a change it refused
await getClient().PUT('/api/bikes/{id}/status', {
params: { path: { id } },
body: { status },

View file

@ -27,7 +27,9 @@ const client = createClient<paths>({
client.use({
async onResponse({ response }) {
if (!response.ok) {
if (response.status >= 500) {
// A 502 is not a bug here but a third party refusing: its detail is
// meant to be shown, so it falls through to the branch below
if (response.status >= 500 && response.status !== HttpStatus.BAD_GATEWAY) {
console.error(
`Server error from ${response.url}: ${response.status} ${response.statusText}`,
)

View file

@ -51,6 +51,9 @@ export type ReservationBike = components['schemas']['ReservationBike']
export type NewReservationBike = components['schemas']['NewReservationBike']
export type Conflict = components['schemas']['Conflict']
export type Person = components['schemas']['Person']
export type FleetLive = components['schemas']['FleetLive']
export type BikeLive = components['schemas']['BikeLive']
export type UsageAlert = components['schemas']['UsageAlert']
/**
* What the details block can render: the fields the public calendar carries,

View file

@ -79,7 +79,7 @@ const updatedAt = computed(() =>
</CardContent>
</Card>
<BikeFleet class="min-w-0" :reservations="activeList" />
<BikeFleet class="min-w-0" />
<ReservationAdmin
class="min-w-0"

View file

@ -13,16 +13,22 @@ use schemars::JsonSchema;
use serde::Deserialize;
use crate::{
api::helpers::{IdPath, admin, admin_desc, unexpected_error},
api::helpers::{IdPath, admin, admin_desc, desc, unexpected_error},
core::{
controller::{AnonAppController, AppController},
models::bike::{Bike, BikeStatus},
controller::{
AnonAppController, AppController, ControllerError, bikes::BikesControllerError,
},
models::{
bike::{Bike, BikeStatus},
linka::FleetLive,
},
},
};
pub fn routes() -> ApiRouter {
ApiRouter::new()
.api_route("/", get_with(get_bikes, get_bikes_docs))
.api_route("/live", get_with(get_live, get_live_docs))
.api_route("/{id}/status", put_with(set_status, set_status_docs))
}
@ -42,6 +48,11 @@ async fn set_status(
match admin(ac)?.set_bike_status(id, status).await {
Ok(()) => Ok(()),
Err(err) if err.is_not_found() => Err((StatusCode::NOT_FOUND, "No such bike".to_owned())),
// The platform would not take the change, so it did not happen here
// either: saying so is the only honest answer
Err(ControllerError::Bike(err @ BikesControllerError::PlatformRefused(_))) => {
Err((StatusCode::BAD_GATEWAY, err.to_string()))
}
Err(err) => unexpected_error("set_bike_status", err),
}
}
@ -51,6 +62,26 @@ fn set_status_docs(op: TransformOperation) -> TransformOperation {
.summary("Put a bike in or out of service")
.response_with::<403, (), _>(admin_desc)
.response::<404, ()>()
.response_with::<502, (), _>(desc("Linka Go refused the change: nothing was stored"))
}
/// What Linka Go last said about the fleet: lock state, battery, who is riding
/// what, and the bikes taken out with no reservation covering them.
///
/// Read from the snapshot the ticker keeps, so the page never waits on the
/// platform. Admin only: it names riders.
#[axum::debug_handler]
async fn get_live(ac: AppController) -> Result<Json<FleetLive>, (StatusCode, String)> {
match admin(ac)?.fleet_live() {
Ok(live) => Ok(Json(live)),
Err(err) => unexpected_error("get_live", err),
}
}
fn get_live_docs(op: TransformOperation) -> TransformOperation {
op.tag("Bikes")
.summary("Get what the platform says about the fleet")
.response_with::<403, (), _>(admin_desc)
}
#[axum::debug_handler]

View file

@ -1,8 +1,12 @@
use thiserror::Error;
use tracing::warn;
use crate::core::{
use crate::{
core::{
controller::{AdminAppController, AnonAppController, ControllerError},
models::bike::{Bike, BikeId, BikeStatus, NewBike},
},
services::linka::{self, LinkaError, locks},
};
/// Reading the fleet needs no session: the reservation form shows it before
@ -30,9 +34,13 @@ impl AdminAppController {
if bike.key_quantity < 0 || bike.name.trim().is_empty() {
return Err(BikesControllerError::BikeInvalid.into());
}
if bike == self.db.get_bike(bike.id).await? {
let stored = self.db.get_bike(bike.id).await?;
if bike == stored {
return Ok(());
}
if bike.status != stored.status {
self.push_service_state(&stored, bike.status).await?;
}
self.db.update_bike(bike).await.map_err(Into::into)
}
@ -41,11 +49,44 @@ impl AdminAppController {
id: BikeId,
status: BikeStatus,
) -> Result<(), ControllerError> {
let bike = self.db.get_bike(id).await?;
if bike.status == status {
return Ok(());
}
self.push_service_state(&bike, status).await?;
self.db
.set_bike_status(id, status)
.await
.map_err(Into::into)
}
/// Tells the platform about a bike taken in or out of service here.
///
/// Awaited, and a failure stops the change: the platform is where a lock
/// actually refuses to open, so a status stored here that never reached it
/// would be a promise this app cannot keep — and the next synchronisation
/// would silently undo it anyway.
async fn push_service_state(
&self,
bike: &Bike,
status: BikeStatus,
) -> Result<(), ControllerError> {
if !linka::configured() {
return Ok(());
}
let Some(serial) = linka::serial_of(&bike.name) else {
// A bike with no lock configured is this app's own business
warn!(
"[LINKA] no lock serial for {}: its service state stays here",
bike.name
);
return Ok(());
};
match locks::set_service_state(&serial, status == BikeStatus::OutOfService).await {
Ok(()) | Err(LinkaError::NotConfigured | LinkaError::DryRun) => Ok(()),
Err(err) => Err(BikesControllerError::PlatformRefused(err.to_string()).into()),
}
}
}
#[derive(Error, Debug)]
@ -54,4 +95,6 @@ pub enum BikesControllerError {
BikeInvalid,
#[error("The bike appears in a reservation: take it out of service instead of deleting it")]
BikeReserved,
#[error("Linka Go refused the change: {0}")]
PlatformRefused(String),
}

View file

@ -0,0 +1,385 @@
//! Keeping this app and the Linka Go platform in step.
//!
//! Three things happen on every tick, in this order:
//!
//! 1. the fleet is read — lock state, battery, and who is riding what — and
//! kept as one snapshot the admin page reads without ever waiting on the
//! platform;
//! 2. a bike the platform reports out of service is taken out of service here
//! too (the platform is the truth: the mechanic works there, and the same
//! lock refuses to open either way);
//! 3. the access list is reconciled: everybody a live reservation entitles is
//! on it, and nobody else.
//!
//! The reconciliation replaces the flags the old system kept on each booking.
//! Flags could not survive a booking being edited, an address being added after
//! approval, or a call that failed once — a set difference survives all three,
//! and a failed call is simply retried at the next tick.
use std::{
collections::{HashSet, VecDeque},
sync::{Mutex, OnceLock},
};
use chrono::Utc;
use tracing::{debug, error, info, warn};
use crate::{
core::{
controller::{AnonAppController, ControllerError},
models::{
bike::{Bike, BikeStatus},
linka::{BikeLive, FleetLive, LiveBooking, Rider, UsageAlert},
},
},
services::{
linka::{self, LinkaError, locks, rentals, whitelist},
telegram::{self, Notification},
},
};
/// How much of the access list a pass goes over.
///
/// The platform cannot be read back, so this app works from its own record of
/// what it has posted. That record is only ever an assumption: somebody may
/// change the list on the platform, and an entry can go missing without this
/// app hearing about it. A [`Sweep::Full`] is what repairs that.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sweep {
/// Only the difference with what this app believes it has already posted.
/// Cheap enough to run every minute.
Diff,
/// Every entitled address is asserted again, whatever the record says, and
/// everybody else is still taken off. Costs one call per live address.
Full,
}
/// Riders are let in half an hour before their reservation starts and taken off
/// the list half an hour after it ends — the time to walk to the bike, and to
/// bring it back.
const GRACE_MINUTES: i32 = 30;
impl AnonAppController {
/// One pass of the synchronisation. Never fails the caller: a platform that
/// is down leaves the app exactly as it was, and the next tick tries again.
pub async fn sync_linka(&self, sweep: Sweep) {
if !linka::configured() {
debug!("[LINKA] not configured, nothing to synchronise");
return;
}
self.sync_fleet().await;
self.sync_access_list(sweep).await;
}
/// Reads the fleet, follows the platform's service state, and raises the
/// alerts.
async fn sync_fleet(&self) {
let bikes = match self.db.get_bikes().await {
Ok(bikes) => bikes,
Err(err) => return error!("[LINKA] cannot read the fleet: {err}"),
};
let locks = match locks::fetch().await {
Ok(locks) => locks,
Err(err) => return warn!("[LINKA] cannot read the locks: {err}"),
};
let rides = match rentals::ongoing().await {
Ok(rides) => rides,
Err(err) => return warn!("[LINKA] cannot read the ongoing rides: {err}"),
};
let mut live = Vec::new();
for lock in &locks {
let Some(bike) = bikes.iter().find(|bike| bike.name == lock.number) else {
debug!("[LINKA] lock {} matches no bike here", lock.number);
continue;
};
// Whoever is on this very bike, if anybody
let rider = rides
.iter()
.find(|ride| ride.bikes.contains(&lock.number))
.map(|ride| Rider {
name: ride.rider.clone(),
email: ride.email.clone(),
since: ride.since,
});
live.push(BikeLive {
bike: bike.id,
lock_state: lock.state,
battery: lock.battery,
out_of_service: lock.out_of_service,
rider,
});
self.follow_service_state(bike, lock.out_of_service).await;
}
let alerts = match self.db.live_bookings().await {
Ok(bookings) => alerts(&rides, &bikes, &bookings),
Err(err) => {
error!("[LINKA] cannot read the live reservations: {err}");
Vec::new()
}
};
announce(&alerts);
linka::store(FleetLive {
updated_at: Some(Utc::now()),
bikes: live,
alerts,
});
}
/// The platform is where a bike is taken out of service for real; this app
/// follows. The other direction is pushed as it happens, in
/// `set_bike_status`.
async fn follow_service_state(&self, bike: &Bike, out_of_service: bool) {
let wanted = if out_of_service {
BikeStatus::OutOfService
} else {
BikeStatus::InService
};
if bike.status == wanted {
return;
}
info!(
"[LINKA] {} is {} on the platform: following",
bike.name,
if out_of_service {
"out of service"
} else {
"back in service"
}
);
if let Err(err) = self.db.set_bike_status(bike.id, wanted).await {
error!(
"[LINKA] cannot follow the service state of {}: {err}",
bike.name
);
}
}
/// Puts everybody a live reservation entitles on the platform's access
/// list, and takes off everybody else.
pub async fn sync_access_list(&self, sweep: Sweep) {
if !linka::configured() {
return;
}
let (wanted, current) = match (
self.db.emails_to_allow(GRACE_MINUTES).await,
self.db.allowed_emails().await,
) {
(Ok(wanted), Ok(current)) => (wanted, current),
(Err(err), _) | (_, Err(err)) => {
return error!("[LINKA] cannot work out the access list: {err}");
}
};
let wanted: HashSet<String> = wanted.into_iter().collect();
let current: HashSet<String> = current.into_iter().collect();
// A full sweep asks for everybody again, including those the record
// already counts as posted: an address the platform lost — taken off
// there, or an answer this app misread — is put back rather than
// missing until the reservation ends.
let to_allow: Vec<&String> = match sweep {
Sweep::Diff => wanted.difference(&current).collect(),
Sweep::Full => wanted.iter().collect(),
};
for email in to_allow {
match whitelist::allow(email).await {
// Recorded only once the platform has taken it: a failure is
// retried at the next tick rather than forgotten
Ok(()) => match self.db.record_allowed(email).await {
Ok(()) => info!("[LINKA] {email} may now unlock the bikes"),
Err(err) => error!("[LINKA] cannot record {email}: {err}"),
},
Err(LinkaError::DryRun) => {}
Err(err) => warn!("[LINKA] cannot allow {email}: {err}"),
}
}
for email in current.difference(&wanted) {
match whitelist::revoke(email).await {
Ok(()) => match self.db.forget_allowed(email).await {
Ok(()) => info!("[LINKA] {email} may no longer unlock the bikes"),
Err(err) => error!("[LINKA] cannot forget {email}: {err}"),
},
Err(LinkaError::DryRun) => {}
Err(err) => warn!("[LINKA] cannot revoke {email}: {err}"),
}
}
}
/// The last picture of the fleet, as read by the admin page
pub fn fleet_live(&self) -> Result<FleetLive, ControllerError> {
Ok(linka::snapshot())
}
}
/// Every bike being ridden by somebody no live reservation entitles to it.
///
/// Two shapes of trouble, told apart by whether the rider has a reservation
/// running at all — the group is told which, because the answer is not the
/// same: a stranger on a bike, or somebody on the wrong one.
fn alerts(rides: &[rentals::Rental], bikes: &[Bike], bookings: &[LiveBooking]) -> Vec<UsageAlert> {
let mut alerts = Vec::new();
for ride in rides {
let theirs: Vec<&LiveBooking> = bookings
.iter()
.filter(|booking| booking.covers(&ride.email))
.collect();
for number in &ride.bikes {
if theirs.iter().any(|booking| booking.allows(number)) {
continue;
}
let Some(bike) = bikes.iter().find(|bike| bike.name == *number) else {
continue;
};
alerts.push(UsageAlert {
bike: bike.id,
bike_name: bike.name.clone(),
rider: Rider {
name: ride.rider.clone(),
email: ride.email.clone(),
since: ride.since,
},
// Named when there is exactly one: "your reservation is for the
// 1000" only makes sense when there is one to point at
reservation: theirs.first().map(|booking| booking.reservation),
allowed: theirs
.iter()
.flat_map(|booking| booking.bikes.clone())
.collect(),
});
}
}
alerts
}
/// Tells the group about the alerts it has not been told about yet.
///
/// An episode is one rider on one bike: the message goes out when it starts,
/// and again only if it stops and starts anew. Without this the group would be
/// told once a minute for as long as the ride lasts.
fn announce(alerts: &[UsageAlert]) {
static ANNOUNCED: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
/// Enough to remember the rides of a busy day; the oldest keys fall out
const REMEMBERED: usize = 64;
let mut announced = ANNOUNCED
.get_or_init(|| Mutex::new(VecDeque::new()))
.lock()
.expect("the announced alerts lock is poisoned");
let live: HashSet<String> = alerts.iter().map(UsageAlert::key).collect();
// An episode that is over is forgotten, so the next one is announced
announced.retain(|key| live.contains(key));
for alert in alerts {
let key = alert.key();
if announced.contains(&key) {
continue;
}
announced.push_back(key);
while announced.len() > REMEMBERED {
announced.pop_front();
}
telegram::notify(match alert.reservation {
Some(_) => Notification::BikeRiddenOutsideReservation(alert.clone()),
None => Notification::BikeRiddenWithoutReservation(alert.clone()),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::models::bike::{BikeSize, BikeStatus};
fn bike(id: i32, name: &str) -> Bike {
Bike {
id,
name: name.to_owned(),
key_number: None,
key_quantity: 0,
drivetrain: None,
battery: None,
size: BikeSize::Large,
status: BikeStatus::InService,
}
}
fn ride(email: &str, bikes: &[&str]) -> rentals::Rental {
rentals::Rental {
rider: "Edgar Wolff".to_owned(),
email: email.to_owned(),
bikes: bikes.iter().map(|name| (*name).to_owned()).collect(),
since: None,
}
}
fn booking(id: i32, emails: &[&str], bikes: &[&str]) -> LiveBooking {
LiveBooking {
reservation: id,
emails: emails.iter().map(|email| (*email).to_owned()).collect(),
bikes: bikes.iter().map(|name| (*name).to_owned()).collect(),
}
}
#[test]
fn a_ride_covered_by_a_reservation_raises_nothing() {
let alerts = alerts(
&[ride("edgar@epfl.ch", &["3000"])],
&[bike(3, "3000")],
&[booking(7, &["Edgar@epfl.ch"], &["3000"])],
);
assert!(alerts.is_empty(), "{alerts:?}");
}
#[test]
fn a_ride_by_a_stranger_names_no_reservation() {
let alerts = alerts(
&[ride("nobody@epfl.ch", &["3000"])],
&[bike(3, "3000")],
&[booking(7, &["edgar@epfl.ch"], &["3000"])],
);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].bike, 3);
assert_eq!(alerts[0].reservation, None);
assert!(alerts[0].allowed.is_empty());
}
#[test]
fn a_ride_on_a_bike_the_reservation_does_not_hold_names_it() {
let alerts = alerts(
&[ride("edgar@epfl.ch", &["3000"])],
&[bike(1, "1000"), bike(3, "3000")],
&[booking(7, &["edgar@epfl.ch"], &["1000"])],
);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].bike_name, "3000");
assert_eq!(alerts[0].reservation, Some(7));
assert_eq!(alerts[0].allowed, vec!["1000"]);
}
#[test]
fn a_bike_this_app_does_not_know_is_ignored() {
let alerts = alerts(&[ride("a@epfl.ch", &["9000"])], &[bike(1, "1000")], &[]);
assert!(alerts.is_empty());
}
#[test]
fn two_reservations_of_the_same_rider_are_both_honoured() {
let alerts = alerts(
&[ride("edgar@epfl.ch", &["1000", "3000"])],
&[bike(1, "1000"), bike(3, "3000")],
&[
booking(7, &["edgar@epfl.ch"], &["1000"]),
booking(8, &["edgar@epfl.ch"], &["3000"]),
],
);
assert!(alerts.is_empty(), "{alerts:?}");
}
}

View file

@ -31,6 +31,7 @@ use crate::core::{
pub mod authn;
pub mod bikes;
pub mod linka;
pub mod reservations;
pub mod users;

View file

@ -96,6 +96,9 @@ impl AnonAppController {
self.db.set_reservation_status(id, status).await?;
let updated = self.db.get_reservation(id).await?;
self.announce_approval(&updated).await;
// A booking that starts within the half hour is one somebody may be
// standing next to: the access list is settled now, not at the next tick
self.sync_access_list().await;
Ok(updated)
}
@ -502,6 +505,8 @@ impl AppController {
let after = self.db.get_reservation(current.id).await?;
self.announce_edit(&current, &after).await;
// An address added to a live booking can unlock a bike straight away
self.sync_access_list().await;
Ok(())
}
@ -627,6 +632,8 @@ impl ManagerAppController {
}
_ => {}
}
// Approving lets its riders in; anything else may take them back out
self.sync_access_list().await;
Ok(())
}

110
src/core/models/linka.rs Normal file
View file

@ -0,0 +1,110 @@
//! What the Linka Go platform says about the fleet, in the app's own terms.
//!
//! The platform knows locks and rides; this app knows bikes and reservations.
//! Everything below is already translated: a lock has been matched to a bike, a
//! rental to the address that started it. Nothing here carries a serial number
//! or a lock id — those stay in `services/linka`.
use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::core::models::{bike::BikeId, reservation::ReservationId};
/// Whether the bike is physically locked.
///
/// `Unknown` is not a failure to answer: it is the platform reporting something
/// this app does not recognise, which is worth showing as such rather than
/// guessing "locked".
#[derive(Debug, Serialize, Deserialize, Clone, Copy, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LockState {
Locked,
Unlocked,
Unknown,
}
impl LockState {
pub fn parse(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"locked" => LockState::Locked,
"unlocked" => LockState::Unlocked,
_ => LockState::Unknown,
}
}
}
/// One bike, as the platform sees it right now
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
pub struct BikeLive {
pub bike: BikeId,
pub lock_state: LockState,
/// Battery of the lock itself, in percent
pub battery: Option<i32>,
/// Out of service on the platform. The app's own status follows it.
pub out_of_service: bool,
/// Who is riding it, if anybody. This is what "in use" means: a ride under
/// way on this very bike, not a reservation that happens to cover it.
pub rider: Option<Rider>,
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct Rider {
pub name: String,
pub email: String,
pub since: Option<DateTime<Utc>>,
}
/// A bike being ridden by somebody no reservation entitles to it.
///
/// Two cases, told apart by `reservation`: nobody's booking covers this rider
/// at all, or their booking is for other bikes.
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct UsageAlert {
pub bike: BikeId,
pub bike_name: String,
pub rider: Rider,
/// The reservation the rider does have running, when there is one
pub reservation: Option<ReservationId>,
/// The bikes that reservation is for
pub allowed: Vec<String>,
}
impl UsageAlert {
/// Identifies the episode, so the group is told about it once rather than
/// once a minute for as long as the ride lasts
pub fn key(&self) -> String {
format!("{}@{}", self.rider.email.to_lowercase(), self.bike_name)
}
}
/// The whole picture, refreshed by the ticker and read by the admin page
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Default)]
pub struct FleetLive {
/// When the platform was last reached. `None` means never — either it is
/// not configured, or every attempt so far has failed.
pub updated_at: Option<DateTime<Utc>>,
pub bikes: Vec<BikeLive>,
pub alerts: Vec<UsageAlert>,
}
/// Who may ride what, right now: one live reservation, its Linka Go accounts,
/// and the bikes it holds at this instant.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LiveBooking {
pub reservation: ReservationId,
pub emails: Vec<String>,
pub bikes: Vec<String>,
}
impl LiveBooking {
pub fn covers(&self, email: &str) -> bool {
self.emails
.iter()
.any(|known| known.eq_ignore_ascii_case(email))
}
pub fn allows(&self, bike_name: &str) -> bool {
self.bikes.iter().any(|name| name == bike_name)
}
}

View file

@ -1,4 +1,5 @@
pub mod bike;
pub mod linka;
pub mod localized_string;
pub mod reservation;
pub mod unit;

View file

@ -0,0 +1,27 @@
//! What the Linka Go synchronisation needs from the database: who should be
//! allowed to unlock a bike right now, and what has already been asked of the
//! platform.
use async_trait::async_trait;
use crate::core::{models::linka::LiveBooking, repositories::RepositoryError};
#[async_trait]
pub trait LinkaRepository {
/// The Linka Go accounts of every reservation whose period — widened by
/// `grace_minutes` on both sides — contains this instant.
///
/// Refused and cancelled reservations are left out, so cancelling one takes
/// its riders off the list at the next tick.
async fn emails_to_allow(&self, grace_minutes: i32) -> Result<Vec<String>, RepositoryError>;
/// The addresses this app has put on the platform's list and not taken off
async fn allowed_emails(&self) -> Result<Vec<String>, RepositoryError>;
async fn record_allowed(&self, email: &str) -> Result<(), RepositoryError>;
async fn forget_allowed(&self, email: &str) -> Result<(), RepositoryError>;
/// Every reservation under way right now, with the bikes it holds at this
/// instant — a bike handed back early is already out of it.
async fn live_bookings(&self) -> Result<Vec<LiveBooking>, RepositoryError>;
}

View file

@ -8,12 +8,13 @@ use async_trait::async_trait;
use thiserror::Error;
use crate::core::repositories::{
bikes_repository::BikesRepository, oidc_states_repository::OidcStatesRepository,
reservations_repository::ReservationsRepository, units_repository::UnitsRepository,
users_repository::UsersRepository,
bikes_repository::BikesRepository, linka_repository::LinkaRepository,
oidc_states_repository::OidcStatesRepository, reservations_repository::ReservationsRepository,
units_repository::UnitsRepository, users_repository::UsersRepository,
};
pub mod bikes_repository;
pub mod linka_repository;
pub mod oidc_states_repository;
pub mod reservations_repository;
pub mod units_repository;
@ -27,6 +28,7 @@ pub trait DatabaseRepository:
+ ReservationsRepository
+ UnitsRepository
+ OidcStatesRepository
+ LinkaRepository
+ Send
+ Sync
{

View file

@ -42,6 +42,10 @@ async fn main() {
// that, so the clock does it.
spawn_status_ticker(aac.clone());
// The bikes are unlocked through Linka Go: the fleet is read from it, and
// the access list is kept in step with the reservations.
spawn_linka_ticker(aac.clone());
// Anything that is not an api route is served from the built frontend,
// falling back on index.html so the vue router can handle the path.
// (`fallback` and not `not_found_service`, which would force a 404 status)
@ -72,6 +76,22 @@ fn spawn_status_ticker(controller: AnonAppController) {
});
}
/// Keeps the app and the Linka Go platform in step, once a minute.
///
/// The same pass reads the fleet and reconciles the access list, so a rider
/// whose booking starts in half an hour is let in within the minute — and a
/// decision taken in the meantime does not wait for the tick, since approving
/// reconciles straight away.
fn spawn_linka_ticker(controller: AnonAppController) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tick.tick().await;
controller.sync_linka().await;
}
});
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()

View file

@ -0,0 +1,92 @@
//! Postgres side of the Linka Go synchronisation.
use async_trait::async_trait;
use sqlx::{query, query_scalar};
use crate::{
core::{
models::linka::LiveBooking,
repositories::{RepositoryError, linka_repository::LinkaRepository},
},
services::database::SqlxDatabase,
};
#[async_trait]
impl LinkaRepository for SqlxDatabase {
async fn emails_to_allow(&self, grace_minutes: i32) -> Result<Vec<String>, RepositoryError> {
// `archived` is included on purpose: a reservation is archived the
// moment it ends, and its riders keep their access for the grace period
// that follows — the time to bring the bike back and lock it.
let emails = query_scalar!(
r#"SELECT DISTINCT lower(email) AS "email!"
FROM reservations r, UNNEST(r.linka_emails) AS email
WHERE r.status IN ('approved', 'ongoing', 'archived')
AND now() >= r.start_time - make_interval(mins => $1)
AND now() <= r.end_time + make_interval(mins => $1)"#,
grace_minutes
)
.fetch_all(&self.pool)
.await?;
Ok(emails)
}
async fn allowed_emails(&self) -> Result<Vec<String>, RepositoryError> {
Ok(query_scalar!(r#"SELECT email FROM linka_whitelist"#)
.fetch_all(&self.pool)
.await?)
}
async fn record_allowed(&self, email: &str) -> Result<(), RepositoryError> {
query!(
r#"INSERT INTO linka_whitelist (email) VALUES ($1)
ON CONFLICT (email) DO NOTHING"#,
email
)
.execute(&self.pool)
.await?;
Ok(())
}
async fn forget_allowed(&self, email: &str) -> Result<(), RepositoryError> {
query!(r#"DELETE FROM linka_whitelist WHERE email = $1"#, email)
.execute(&self.pool)
.await?;
Ok(())
}
async fn live_bookings(&self) -> Result<Vec<LiveBooking>, RepositoryError> {
// Left join, and the instant is checked in the join itself: a
// reservation whose bikes have all been handed back early still shows
// up, with no bike — which is what tells "riding somebody else's bike"
// apart from "riding with no booking at all".
let rows = query!(
r#"SELECT r.id AS "reservation!",
r.linka_emails AS "emails!",
COALESCE(
ARRAY_AGG(b.name) FILTER (WHERE b.name IS NOT NULL),
'{}'
) AS "bikes!"
FROM reservations r
LEFT JOIN reservations_bikes rb
ON rb.reservation_id = r.id
AND now() >= COALESCE(rb.start_time, r.start_time)
AND now() <= COALESCE(rb.end_time, r.end_time)
LEFT JOIN bikes b ON b.id = rb.bike_id
WHERE r.status IN ('approved', 'ongoing')
AND now() >= r.start_time
AND now() <= r.end_time
GROUP BY r.id"#
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|row| LiveBooking {
reservation: row.reservation,
emails: row.emails,
bikes: row.bikes,
})
.collect())
}
}

View file

@ -5,6 +5,7 @@
//! data must be present (`cargo sqlx prepare`).
mod bikes;
mod linka;
mod oidc_states;
mod reservations;
mod units;

115
src/services/linka/locks.rs Normal file
View file

@ -0,0 +1,115 @@
//! The locks: what they report, and putting one in or out of service.
use openidconnect::reqwest::Method;
use serde_json::{Value, json};
use tracing::info;
use crate::{
core::models::linka::LockState,
services::linka::{LinkaError, Result, dry_run, fleet, linka},
};
/// One lock, reduced to what this app shows or acts on
#[derive(Debug, Clone, PartialEq)]
pub struct Lock {
/// The number written on the bike — the name it goes by here too
pub number: String,
pub state: LockState,
pub battery: Option<i32>,
pub out_of_service: bool,
}
/// Every lock of the account.
///
/// The bounding box is the whole planet: the platform filters by map area, and
/// this app wants the fleet, wherever it happens to be parked.
pub async fn fetch() -> Result<Vec<Lock>> {
let answer = fleet(
"merchantlocks",
Method::PUT,
json!({ "longitudeX": -180, "latitudeX": -90, "longitudeY": 180, "latitudeY": 90 }),
)
.await?;
let locks = answer
.get("data")
.and_then(Value::as_array)
.ok_or_else(|| LinkaError::failed("merchantlocks answered without a list"))?;
Ok(locks.iter().filter_map(read).collect())
}
/// A lock with no number is of no use here: it could not be matched to a bike.
fn read(lock: &Value) -> Option<Lock> {
let number = match lock.get("lock_number") {
Some(Value::String(number)) => number.trim().to_owned(),
Some(Value::Number(number)) => number.to_string(),
_ => return None,
};
Some(Lock {
number,
state: lock
.get("lock_state")
.and_then(Value::as_str)
.map_or(LockState::Unknown, LockState::parse),
battery: lock
.get("lock_battery_percent")
.and_then(Value::as_i64)
.map(|percent| percent as i32),
out_of_service: lock
.get("out_of_service")
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
/// Takes the lock of `serial` in or out of service on the platform.
pub async fn set_service_state(serial: &str, out_of_service: bool) -> Result<()> {
let linka = linka().ok_or(LinkaError::NotConfigured)?;
if dry_run() {
info!("[LINKA] (dry run) would set {serial} out_of_service={out_of_service}");
return Err(LinkaError::DryRun);
}
super::authenticated(
&linka.service_state_url.clone(),
Method::POST,
json!({ "lock_serial_no": serial, "out_of_service": out_of_service }),
)
.await
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_lock_is_read_whether_its_number_is_written_as_text_or_as_a_number() {
let text = read(&json!({
"lock_number": "3000", "lock_state": "Unlocked",
"lock_battery_percent": 95, "out_of_service": false
}))
.unwrap();
assert_eq!(text.number, "3000");
assert_eq!(text.state, LockState::Unlocked);
assert_eq!(text.battery, Some(95));
let number = read(&json!({ "lock_number": 1000, "lock_state": "Locked" })).unwrap();
assert_eq!(number.number, "1000");
assert_eq!(number.state, LockState::Locked);
assert_eq!(number.battery, None);
assert!(!number.out_of_service);
}
#[test]
fn a_lock_without_a_number_is_dropped_rather_than_guessed_at() {
assert!(read(&json!({ "lock_state": "Locked" })).is_none());
}
#[test]
fn an_unexpected_lock_state_is_reported_as_unknown() {
let lock = read(&json!({ "lock_number": 1, "lock_state": "Ajar" })).unwrap();
assert_eq!(lock.state, LockState::Unknown);
}
}

279
src/services/linka/mod.rs Normal file
View file

@ -0,0 +1,279 @@
//! Linka Go: the platform the locks, the rides and the access list live on.
//!
//! One file per family of calls — [`locks`], [`rentals`], [`whitelist`] — over
//! the transport kept here: the access token and its refresh, the headers the
//! fleetview api insists on, and the answer shape (`{"status": "success", …}`)
//! every endpoint shares. A caller says *what it wants of the platform*, never
//! how the platform is spoken to.
//!
//! Without the `LINKA_*` variables every call answers [`LinkaError::NotConfigured`]
//! and nothing is attempted, which is what a development machine and the test
//! suite want.
//!
//! What the platform reports about the fleet is kept in one place — [`snapshot`]
//! — refreshed by the ticker and read by the admin page, so a page load never
//! waits on a third party.
pub mod locks;
pub mod rentals;
pub mod whitelist;
use std::sync::{OnceLock, RwLock};
use chrono::{DateTime, Utc};
use openidconnect::reqwest::{self, Method};
use serde_json::{Value, json};
use thiserror::Error;
use tokio::sync::Mutex;
use tracing::debug;
use crate::core::models::linka::FleetLive;
/// The platform itself. Only ever overridden — with `LINKA_BASE_URL` — to point
/// the calls at a stub, the way the Telegram sender can be.
const BASE_URL: &str = "https://app.linkalock.com";
fn base_url() -> &'static str {
static BASE: OnceLock<String> = OnceLock::new();
BASE.get_or_init(|| {
std::env::var("LINKA_BASE_URL")
.ok()
.map(|url| url.trim().trim_end_matches('/').to_owned())
.filter(|url| !url.is_empty())
.unwrap_or_else(|| BASE_URL.to_owned())
})
}
/// The fleetview api answers 403 without these
const ORIGIN: &str = "https://fleetview.linkalock.com";
const REFERER: &str = "https://fleetview.linkalock.com/";
#[derive(Debug, Error)]
pub enum LinkaError {
#[error("Linka Go is not configured")]
NotConfigured,
#[error("dry run: nothing was sent to Linka Go")]
DryRun,
#[error("{0}")]
Failed(String),
}
impl LinkaError {
fn failed(message: impl Into<String>) -> Self {
LinkaError::Failed(message.into())
}
}
pub type Result<T> = std::result::Result<T, LinkaError>;
/// The account this app acts as.
///
/// Read from plain `LINKA_*` variables rather than the `APP__*` configuration:
/// these are the names the platform documents, and `.env` already carries them.
struct Linka {
/// The access list endpoint (`addRemoveRestriction`)
restriction_url: String,
service_state_url: String,
user_id: String,
auth_token: String,
api_key: String,
secret_key: String,
}
fn linka() -> Option<&'static Linka> {
static LINKA: OnceLock<Option<Linka>> = OnceLock::new();
LINKA
.get_or_init(|| {
let read = |name: &str| std::env::var(name).ok().filter(|v| !v.trim().is_empty());
Some(Linka {
restriction_url: read("LINKA_API_URL")?,
service_state_url: read("LINKA_API_SERVICE_STATE_URL")?,
user_id: read("LINKA_USER_ID")?,
auth_token: read("LINKA_AUTH_TOKEN")?,
api_key: read("LINKA_API_KEY")?,
secret_key: read("LINKA_SECRET_KEY")?,
})
})
.as_ref()
}
/// Whether writing to the platform is held back.
///
/// `LINKA_DRY_RUN=1` lets a development machine read the real fleet — locks,
/// rides, alerts — without ever touching the access list or the service state
/// of the real bikes. Nothing is recorded as done either, so switching it off
/// puts everything in step at the next tick.
pub fn dry_run() -> bool {
static DRY_RUN: OnceLock<bool> = OnceLock::new();
*DRY_RUN.get_or_init(|| {
std::env::var("LINKA_DRY_RUN")
.map(|value| matches!(value.trim(), "1" | "true" | "yes"))
.unwrap_or(false)
})
}
pub fn configured() -> bool {
linka().is_some()
}
/// The serial of the lock bolted to the bike named `bike_name`.
///
/// The mapping is configuration — `LINKA_LOCK_SERIAL_1000=D8:4F:…` — keyed by
/// the name the bike carries here and on the platform (its lock number). Adding
/// a bike is therefore one line in `.env`, and a bike with no line is simply
/// left alone rather than guessed at.
pub fn serial_of(bike_name: &str) -> Option<String> {
std::env::var(format!("LINKA_LOCK_SERIAL_{bike_name}"))
.ok()
.map(|serial| serial.trim().to_uppercase())
.filter(|serial| !serial.is_empty())
}
// ---------------------------------------------------------------------------
// The access token
// ---------------------------------------------------------------------------
/// Tokens last about three months; asked for once and kept until shortly before
/// they lapse. The mutex is held across the refresh so a burst of calls on a
/// cold cache asks for one token, not one each.
async fn access_token(linka: &Linka) -> Result<String> {
/// A token and the moment it lapses
type Cached = Mutex<Option<(String, DateTime<Utc>)>>;
static TOKEN: OnceLock<Cached> = OnceLock::new();
let mut cached = TOKEN.get_or_init(|| Mutex::new(None)).lock().await;
if let Some((token, expires_at)) = cached.as_ref()
&& Utc::now() + chrono::Duration::minutes(5) < *expires_at
{
return Ok(token.clone());
}
let answer = post(
&format!("{}/api/merchant_api/fetch_access_token", base_url()),
Method::POST,
json!({ "api_key": linka.api_key, "secret_key": linka.secret_key }),
None,
)
.await?;
let data = answer.get("data").unwrap_or(&answer);
let token = data
.get("access_token")
.and_then(Value::as_str)
.ok_or_else(|| LinkaError::failed("fetch_access_token answered without a token"))?
.to_owned();
// A token with no readable expiry is trusted for a day, so a change of
// format costs one extra call a day rather than every call failing
let expires_at = data
.get("access_token_expireAt")
.and_then(Value::as_str)
.and_then(|at| DateTime::parse_from_rfc3339(at).ok())
.map(|at| at.with_timezone(&Utc))
.unwrap_or_else(|| Utc::now() + chrono::Duration::days(1));
*cached = Some((token.clone(), expires_at));
debug!("[LINKA] new access token, good until {expires_at}");
Ok(token)
}
// ---------------------------------------------------------------------------
// Transport
// ---------------------------------------------------------------------------
/// Calls `url` as the configured account, with the access token in the body.
///
/// Every endpoint of the platform answers `{"status": "success", "data": …}` or
/// `{"status": "error", "message": …}` — with a 200 either way — so the status
/// field, not the http code, is what says whether it worked.
async fn authenticated(url: &str, method: Method, body: Value) -> Result<Value> {
let linka = linka().ok_or(LinkaError::NotConfigured)?;
let token = access_token(linka).await?;
let mut payload = json!({ "access_token": token });
if let (Some(payload), Some(body)) = (payload.as_object_mut(), body.as_object()) {
payload.extend(body.clone());
}
post(url, method, payload, Some(linka)).await
}
/// The same, on a path of the fleetview api
async fn fleet(path: &str, method: Method, body: Value) -> Result<Value> {
authenticated(
&format!("{}/api/fleetview/{path}", base_url()),
method,
body,
)
.await
}
async fn post(url: &str, method: Method, body: Value, linka: Option<&Linka>) -> Result<Value> {
// Serialised by hand: the http client is the one `openidconnect` brings,
// and it is built without its `json` feature.
let payload =
serde_json::to_string(&body).map_err(|err| LinkaError::failed(err.to_string()))?;
let mut request = http_client()
.request(method, url)
.header("content-type", "application/json; charset=UTF-8")
.header("origin", ORIGIN)
.header("referer", REFERER)
.body(payload);
if let Some(linka) = linka {
request = request
.header("x-user-id", &linka.user_id)
.header("x-auth-token", &linka.auth_token);
}
let response = request
.send()
.await
.map_err(|err| LinkaError::failed(err.to_string()))?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
let answer: Value =
serde_json::from_str(&text).map_err(|_| LinkaError::failed(format!("{status}: {text}")))?;
if answer.get("status").and_then(Value::as_str) != Some("success") {
let message = answer
.get("message")
.and_then(Value::as_str)
.unwrap_or(&text)
.to_owned();
return Err(LinkaError::failed(message));
}
Ok(answer)
}
fn http_client() -> &'static reqwest::Client {
static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
HTTP_CLIENT.get_or_init(|| {
reqwest::ClientBuilder::new()
.timeout(std::time::Duration::from_secs(20))
.build()
.expect("Unable to build the Linka http client")
})
}
// ---------------------------------------------------------------------------
// What the platform last said
// ---------------------------------------------------------------------------
fn cache() -> &'static RwLock<FleetLive> {
static SNAPSHOT: OnceLock<RwLock<FleetLive>> = OnceLock::new();
SNAPSHOT.get_or_init(|| RwLock::new(FleetLive::default()))
}
/// The last picture of the fleet. Empty until the first successful tick.
pub fn snapshot() -> FleetLive {
cache()
.read()
.expect("the snapshot lock is poisoned")
.clone()
}
pub fn store(snapshot: FleetLive) {
*cache().write().expect("the snapshot lock is poisoned") = snapshot;
}

View file

@ -0,0 +1,121 @@
//! The rides under way right now.
//!
//! This is what "in use" means in this app: somebody has a bike out, whatever
//! the reservations say. A bike nobody is riding is not in use, even during the
//! reservation that holds it.
use chrono::{DateTime, Utc};
use openidconnect::reqwest::Method;
use serde_json::{Value, json};
use crate::services::linka::{LinkaError, Result, fleet};
/// One ride under way
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rental {
pub rider: String,
pub email: String,
/// The numbers of the bikes taken out — the names they go by here
pub bikes: Vec<String>,
pub since: Option<DateTime<Utc>>,
}
pub async fn ongoing() -> Result<Vec<Rental>> {
let answer = fleet("ongoing_rental", Method::POST, json!({})).await?;
let rentals = answer
.get("data")
.and_then(Value::as_array)
.ok_or_else(|| LinkaError::failed("ongoing_rental answered without a list"))?;
Ok(rentals.iter().filter_map(read).collect())
}
/// A ride with no address behind it cannot be matched to a reservation, and is
/// left out rather than reported as somebody riding without one.
fn read(rental: &Value) -> Option<Rental> {
let email = rental
.get("user_email")
.and_then(Value::as_str)
.map(|email| email.trim().to_lowercase())
.filter(|email| !email.is_empty())?;
let locks = rental.get("locks").and_then(Value::as_array);
let bikes = locks
.map(|locks| {
locks
.iter()
.filter_map(|lock| match lock.get("merchantlock_lock_number") {
Some(Value::String(number)) => Some(number.trim().to_owned()),
Some(Value::Number(number)) => Some(number.to_string()),
_ => None,
})
.collect()
})
.unwrap_or_default();
// The platform dates each lock of a ride; the earliest is when the ride
// started
let since = locks.and_then(|locks| {
locks
.iter()
.filter_map(|lock| lock.get("start_date").and_then(Value::as_str))
.filter_map(|at| DateTime::parse_from_rfc3339(at).ok())
.map(|at| at.with_timezone(&Utc))
.min()
});
Some(Rental {
rider: rental
.get("name")
.and_then(Value::as_str)
.map(str::to_owned)
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| email.clone()),
email,
bikes,
since,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn rental() -> Value {
json!({
"name": "Edgar Wolff",
"user_email": "Edgar.Wolff@epfl.ch",
"locks": [
{ "merchantlock_lock_number": "3000", "start_date": "2026-08-22T12:44:12.082Z" },
{ "merchantlock_lock_number": 1000, "start_date": "2026-08-22T10:00:00.000Z" }
]
})
}
#[test]
fn a_ride_carries_its_rider_its_bikes_and_when_it_started() {
let rental = read(&rental()).unwrap();
assert_eq!(rental.rider, "Edgar Wolff");
// Lower-cased: it is matched against the addresses of a reservation
assert_eq!(rental.email, "edgar.wolff@epfl.ch");
assert_eq!(rental.bikes, vec!["3000", "1000"]);
assert_eq!(
rental.since.map(|at| at.to_rfc3339()),
Some("2026-08-22T10:00:00+00:00".to_owned())
);
}
#[test]
fn a_ride_without_an_address_is_dropped() {
assert!(read(&json!({ "name": "Nobody", "locks": [] })).is_none());
}
#[test]
fn a_rider_with_no_name_is_known_by_their_address() {
let rental = read(&json!({ "user_email": "a@epfl.ch", "locks": [] })).unwrap();
assert_eq!(rental.rider, "a@epfl.ch");
assert!(rental.bikes.is_empty());
assert_eq!(rental.since, None);
}
}

View file

@ -0,0 +1,86 @@
//! The access list: who may unlock a bike.
//!
//! The platform calls it the restriction list — the merchant restricts riding
//! to the addresses on it — so putting somebody on it is what grants access.
//! There is no endpoint to read it back: what this app has asked for is
//! remembered in `linka_whitelist`, and reconciled from there.
use openidconnect::reqwest::Method;
use serde_json::json;
use tracing::{debug, info};
use crate::services::linka::{LinkaError, Result, authenticated, dry_run, linka};
/// Lets `email` unlock the bikes.
///
/// An address already on the list is the outcome asked for, so it counts as
/// done — the platform reports it as an error, but there is nothing left to do
/// and nothing to retry.
pub async fn allow(email: &str) -> Result<()> {
match call(email, Method::PUT).await {
Err(LinkaError::Failed(message)) if already_allowed(&message) => {
debug!("[LINKA] {email} was already allowed");
Ok(())
}
outcome => outcome,
}
}
/// Takes `email` back off the list.
///
/// Removing an address that is not on it answers success, so no such case has
/// to be recognised here.
pub async fn revoke(email: &str) -> Result<()> {
call(email, Method::POST).await
}
/// Whether the platform is saying "it is already so" rather than "it failed".
///
/// Matched on the wording because there is no code to match on: the answer is
/// `{"status": "error", "message": "email is already in user list"}`. Anything
/// else stays an error, and is tried again at the next tick.
fn already_allowed(message: &str) -> bool {
message.to_lowercase().contains("already in user list")
}
/// The same endpoint either way: the verb is what says which
async fn call(email: &str, method: Method) -> Result<()> {
let linka = linka().ok_or(LinkaError::NotConfigured)?;
if dry_run() {
info!(
"[LINKA] (dry run) would {} {email}",
if method == Method::PUT {
"allow"
} else {
"revoke"
}
);
return Err(LinkaError::DryRun);
}
authenticated(
&linka.restriction_url.clone(),
method,
json!({ "email": email }),
)
.await
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_platforms_already_there_answer_is_recognised() {
assert!(already_allowed("email is already in user list"));
// The platform's wording is not to be counted on to the letter
assert!(already_allowed("Email is Already in user list."));
}
#[test]
fn a_real_failure_is_still_a_failure() {
assert!(!already_allowed("invalid access token"));
assert!(!already_allowed("email is not in user list"));
}
}

View file

@ -1,4 +1,5 @@
//! External services used by the core: database, and any third party api you add.
pub mod database;
pub mod linka;
pub mod mail;
pub mod telegram;

View file

@ -0,0 +1,39 @@
//! A cargobike is out, taken by somebody who does have a reservation running —
//! but for other bikes.
use crate::{
core::models::linka::UsageAlert,
services::telegram::messages::{alert_details, escape},
};
pub fn message(alert: &UsageAlert) -> String {
let allowed = if alert.allowed.is_empty() {
"—".to_owned()
} else {
alert.allowed.join(", ")
};
format!(
"⚠️ <b>Cargobike utilisé hors réservation</b>\n{details}\n\
<b>Réservation en cours :</b> #{id}\n\
<b>Cargobike(s) réservé(s) :</b> {allowed}",
details = alert_details(alert),
id = alert.reservation.unwrap_or_default(),
allowed = escape(&allowed),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::telegram::messages::tests::alert;
#[test]
fn the_message_names_the_reservation_and_what_it_is_for() {
let rendered = message(&alert(Some(7)));
assert!(rendered.starts_with("⚠️ <b>Cargobike utilisé hors réservation</b>"));
assert!(rendered.contains("<b>Réservation en cours :</b> #7"));
assert!(rendered.contains("<b>Cargobike(s) réservé(s) :</b> 1000, 2000"));
assert!(rendered.contains("3000"));
}
}

View file

@ -0,0 +1,29 @@
//! A cargobike is out, and nobody's reservation covers the person riding it.
use crate::{core::models::linka::UsageAlert, services::telegram::messages::alert_details};
pub fn message(alert: &UsageAlert) -> String {
format!(
"⚠️ <b>Cargobike utilisé sans réservation</b>\n{}",
alert_details(alert)
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::telegram::messages::tests::alert;
#[test]
fn the_message_names_the_bike_and_the_rider() {
let rendered = message(&alert(None));
assert!(rendered.starts_with("⚠️ <b>Cargobike utilisé sans réservation</b>"));
assert!(rendered.contains("3000"));
assert!(rendered.contains("Edgar Wolff"));
assert!(rendered.contains("edgar.wolff@epfl.ch"));
assert!(rendered.contains("22.08.2026 14:44"));
// Nothing to say about a reservation there is none of
assert!(!rendered.contains("Réservation"));
}
}

View file

@ -4,12 +4,17 @@
//! [`super::Notification`] — the wording never leaks into the rest of the app,
//! and changing a message is a change to one file with no logic in it.
pub mod bike_ridden_outside_reservation;
pub mod bike_ridden_without_reservation;
pub mod reservation_decided;
pub mod reservation_requested;
use chrono::{DateTime, Utc};
use crate::core::models::reservation::{Reservation, ReservationId, ReservationStatus};
use crate::core::models::{
linka::UsageAlert,
reservation::{Reservation, ReservationId, ReservationStatus},
};
/// The facts every message about a reservation shows.
///
@ -87,6 +92,25 @@ impl ReservationCard {
}
}
/// The facts both alerts about a bike being ridden share: which bike, who has
/// it, and since when.
pub fn alert_details(alert: &UsageAlert) -> String {
let since = alert
.rider
.since
.map_or_else(|| "—".to_owned(), format_moment);
format!(
"<b>Cargobike :</b> {bike}\n\
<b>Utilisateur :</b> {rider}\n\
<b>E-mail :</b> {email}\n\
<b>Depuis :</b> {since}",
bike = escape(&alert.bike_name),
rider = escape(&alert.rider.name),
email = escape(&alert.rider.email),
since = escape(&since),
)
}
/// The three characters Telegram's HTML mode reads as markup
pub fn escape(value: &str) -> String {
value
@ -105,6 +129,8 @@ pub fn format_moment(moment: DateTime<Utc>) -> String {
#[cfg(test)]
pub mod tests {
use chrono::Timelike;
use super::*;
fn moment(day: u32, hour: u32) -> DateTime<Utc> {
@ -169,6 +195,25 @@ pub mod tests {
assert!(details.contains("Milan Hyenne"));
}
/// The fixture both alert tests render
pub fn alert(reservation: Option<ReservationId>) -> UsageAlert {
use crate::core::models::linka::Rider;
UsageAlert {
bike: 3,
bike_name: "3000".to_owned(),
rider: Rider {
name: "Edgar Wolff".to_owned(),
email: "edgar.wolff@epfl.ch".to_owned(),
since: Some(moment(22, 12).with_minute(44).unwrap()),
},
reservation,
allowed: reservation
.map(|_| vec!["1000".to_owned(), "2000".to_owned()])
.unwrap_or_default(),
}
}
#[test]
fn markup_in_a_user_value_is_escaped() {
let mut card = card(ReservationStatus::Requested);

View file

@ -24,9 +24,15 @@ use tracing::{debug, error, info, warn};
use crate::{
core::{
controller::AnonAppController,
models::reservation::{ReservationId, ReservationStatus},
models::{
linka::UsageAlert,
reservation::{ReservationId, ReservationStatus},
},
},
services::telegram::messages::{
ReservationCard, bike_ridden_outside_reservation, bike_ridden_without_reservation,
reservation_decided, reservation_requested,
},
services::telegram::messages::{ReservationCard, reservation_decided, reservation_requested},
utils::config::{self, TelegramConfig},
};
@ -37,12 +43,22 @@ pub mod messages;
pub enum Notification {
/// A reservation request has just been filed, with its decision buttons
ReservationRequested(ReservationCard),
/// A bike is being ridden by somebody with no reservation running
BikeRiddenWithoutReservation(UsageAlert),
/// A bike is being ridden by somebody whose reservation is for other bikes
BikeRiddenOutsideReservation(UsageAlert),
}
impl Notification {
fn render(&self) -> String {
match self {
Notification::ReservationRequested(card) => reservation_requested::message(card),
Notification::BikeRiddenWithoutReservation(alert) => {
bike_ridden_without_reservation::message(alert)
}
Notification::BikeRiddenOutsideReservation(alert) => {
bike_ridden_outside_reservation::message(alert)
}
}
}
@ -52,6 +68,10 @@ impl Notification {
Notification::ReservationRequested(card) => {
Some(reservation_requested::keyboard(card.id))
}
// An alert is read, not answered: whoever acts on it does so in the
// admin page, where the same alert is shown
Notification::BikeRiddenWithoutReservation(_)
| Notification::BikeRiddenOutsideReservation(_) => None,
}
}
}
@ -68,19 +88,31 @@ pub fn notify(notification: Notification) {
};
let telegram = telegram.clone();
tokio::spawn(async move {
let body = json!({
"chat_id": telegram.chat_id,
"text": notification.render(),
"parse_mode": "HTML",
"link_preview_options": { "is_disabled": true },
"reply_markup": notification.keyboard(),
});
let body = message_body(&telegram.chat_id, &notification);
if let Err(err) = call(&telegram, "sendMessage", body).await {
error!("[TELEGRAM] could not send the notification: {err}");
}
});
}
/// The body of a `sendMessage` call.
///
/// `reply_markup` is left out entirely when the message carries no buttons:
/// Telegram wants an object there or nothing at all, and answers
/// "object expected as reply markup" to a `null`.
fn message_body(chat_id: &str, notification: &Notification) -> Value {
let mut body = json!({
"chat_id": chat_id,
"text": notification.render(),
"parse_mode": "HTML",
"link_preview_options": { "is_disabled": true },
});
if let (Some(keyboard), Some(body)) = (notification.keyboard(), body.as_object_mut()) {
body.insert("reply_markup".to_owned(), keyboard);
}
body
}
// ---------------------------------------------------------------------------
// The buttons coming back
// ---------------------------------------------------------------------------
@ -296,6 +328,38 @@ fn http_client() -> &'static reqwest::Client {
mod tests {
use super::*;
#[test]
fn a_message_with_buttons_carries_them() {
use crate::services::telegram::messages::tests::card;
let body = message_body(
"-1",
&Notification::ReservationRequested(card(ReservationStatus::Requested)),
);
assert!(body["reply_markup"]["inline_keyboard"].is_array());
assert_eq!(body["chat_id"], "-1");
assert_eq!(body["parse_mode"], "HTML");
}
#[test]
fn a_message_without_buttons_leaves_reply_markup_out() {
use crate::services::telegram::messages::tests::alert;
let body = message_body(
"-1",
&Notification::BikeRiddenWithoutReservation(alert(None)),
);
// Absent, not null: Telegram answers "object expected as reply markup"
// to a null, and every alert is a message with no buttons
assert!(
body.get("reply_markup").is_none(),
"reply_markup should be absent, got {body}"
);
assert!(body["text"].as_str().unwrap().contains("3000"));
}
#[test]
fn buttons_carry_the_reservation_they_decide() {
assert_eq!(