feat: add linka
This commit is contained in:
parent
7c38a1e606
commit
2d6ef8de6c
34 changed files with 1873 additions and 51 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,4 +4,3 @@ config.yaml
|
|||
/LEGACY
|
||||
summary.ai
|
||||
.env
|
||||
.env.example
|
||||
32
README.md
32
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
db/migrations/20260825120000_linka_whitelist.sql
Normal file
16
db/migrations/20260825120000_linka_whitelist.sql
Normal 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;
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
125
frontend/src/lib/api.d.ts
vendored
125
frontend/src/lib/api.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
use thiserror::Error;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::core::{
|
||||
controller::{AdminAppController, AnonAppController, ControllerError},
|
||||
models::bike::{Bike, BikeId, BikeStatus, NewBike},
|
||||
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),
|
||||
}
|
||||
|
|
|
|||
385
src/core/controller/linka.rs
Normal file
385
src/core/controller/linka.rs
Normal 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(¤t).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:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ use crate::core::{
|
|||
|
||||
pub mod authn;
|
||||
pub mod bikes;
|
||||
pub mod linka;
|
||||
pub mod reservations;
|
||||
pub mod users;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(¤t, &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
110
src/core/models/linka.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod bike;
|
||||
pub mod linka;
|
||||
pub mod localized_string;
|
||||
pub mod reservation;
|
||||
pub mod unit;
|
||||
|
|
|
|||
27
src/core/repositories/linka_repository.rs
Normal file
27
src/core/repositories/linka_repository.rs
Normal 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>;
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
20
src/main.rs
20
src/main.rs
|
|
@ -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()
|
||||
|
|
|
|||
92
src/services/database/linka.rs
Normal file
92
src/services/database/linka.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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
115
src/services/linka/locks.rs
Normal 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
279
src/services/linka/mod.rs
Normal 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;
|
||||
}
|
||||
121
src/services/linka/rentals.rs
Normal file
121
src/services/linka/rentals.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
86
src/services/linka/whitelist.rs
Normal file
86
src/services/linka/whitelist.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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, ¬ification);
|
||||
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!(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue