Club platform for Uzbekistan. Two categories — sport and learning —
share the same tables: a running club and an IELTS speaking club differ only
in labels. The data model is activity-agnostic, so football, padel or a coding
circle drop in as one activity_types row.
The admin is in Uzbek and Russian. Sign-in is by phone with no password; codes are delivered over Telegram.
pace/
backend/ FastAPI + SQLAlchemy 2 (async) + Postgres + Alembic
frontend/ Vue 3 + Vite + TypeScript — the organiser web admin ("adminka")
mobile/ Flutter — the member app (web today, iOS/Android once tooling exists)
docker-compose.yml
The runner-facing app is Flutter and talks to the same API — see A Flutter client below.
docker compose up -d dbcd backend && uv venv --python 3.12 .venv && uv pip install --python .venv/bin/python -e ".[dev]"cd backend && .venv/bin/alembic upgrade head && .venv/bin/python -m app.seedcd backend && .venv/bin/uvicorn app.main:app --reload --port 8000cd frontend && npm install && npm run dev- API — http://localhost:8000, interactive docs at
/docs - Admin — http://localhost:5173
- Postgres —
localhost:5434(deliberately clear of 5432 and 5433)
Sign in with 90 111 00 01 (Sardor, owns Toshkent Runners) or
90 111 00 04 (Nigora, owns the IELTS Speaking Club). The field takes the
9 national digits; +998 is fixed. Bare, 0-prefixed and full international
forms all normalise to the same number.
Without a Telegram bot token the API returns the code in the response and the login screen fills it in for you.
cd backend && .venv/bin/pytest tests -qPure-logic tests: geofence distance, degree gating, RRULE expansion across a DST change. The rules that need a real database — kudos scarcity, anti-reciprocity, geofence rejection, the audit trail — are covered end-to-end against a running API:
cd backend && .venv/bin/python scripts/smoke.pyThese are product decisions, not implementation details. Each is pinned in the place that is hardest to bypass.
| Rule | Enforced by |
|---|---|
| One kudos per person per session | unique (session_id, from_user_id) on kudos |
| Nobody awards themselves | check (from_user_id <> to_user_id) |
| No handing kudos straight back | Postgres trigger kudos_no_reciprocal (30 days) + service check |
| The same Strava run counts once | unique (source, external_id) on activity_records |
| One successful check-in per person | Partial unique index on check_ins |
| Rejected check-ins are kept | Committed before the error unwinds the request |
| A sponsor cannot buy a softer number | check (requires_checkin = true) on challenges |
Speed is never an input to anyone's degree. Distance carries the smallest weight (15%) and is capped; consistency and contribution carry the rest. Two gates stop a level being bought with distance alone.
The API is designed to be consumed by generated code. operationIds are the
plain function names (rsvp, checkIn, giveKudos), every list returns the
same {items, total, limit, offset} envelope, and every error carries a
stable machine-readable code — branch on that, never on the message.
curl http://localhost:8000/openapi.json -o openapi.jsonnpx @openapitools/openapi-generator-cli generate -i openapi.json -g dart-dio -o mobile/apiError codes the mobile client should handle by name:
outside_geofence · checkin_window_closed · already_checked_in ·
kudos_already_given · kudos_reciprocal_blocked · session_full ·
unauthorized · forbidden · not_found · invalid
outside_geofence also returns distance_m and allowed_m so the app can
say "you are 240 m away, get within 150 m" rather than a generic failure.
| Screen | Call |
|---|---|
| Sign in | POST /auth/otp/request, POST /auth/otp/verify |
| Home | GET /me/feed |
| Session | GET /sessions/{id} |
| RSVP / back out | PUT/DELETE /sessions/{id}/rsvp |
| Check in | POST /sessions/{id}/check-in |
| Post-run kudos | GET /sessions/{id}/kudos/prompt, POST /sessions/{id}/kudos |
| You | GET /me/degrees |
| Club | GET /clubs/{id}, GET /clubs/{id}/board |
| Invite preview | GET /invites/{code} — no token required |
GET /invites/{code} is deliberately unauthenticated: a shared link must show
the run, the time and who is coming before the sign-up screen.
POST /sessions/{id}/check-in accepts an occurred_at so the app can queue a
check-in offline and send it when signal returns. The geofence and the time
window are judged server-side regardless of what the device claims.
Uzbek (default) and Russian, switchable in the sidebar and on the login screen;
the choice persists in localStorage and sets <html lang>. Strings live in
frontend/src/locales/{uz,ru}.json — both files are kept key-for-key identical.
Server-generated text is not translated on the server. The admin API returns
a key (KPIs) or a kind plus params (attention items) and the client
renders them, so adding a language never means touching Python.
activity_types.category is sport or learning. Everything downstream —
sessions, groups, check-ins, kudos, degrees, challenges — is shared. Only
group_label differs: a running club shows pace group, an IELTS club shows
level.
curl "http://localhost:8000/api/v1/activity-types?category=learning"Adding a sport or a subject is one row, not a migration.
A bot cannot message a phone number. It can only post to a chat_id, and
only after that person has pressed Start. So the first sign-in for any number
is two steps, and every one after that is one:
POST /auth/otp/requestfinds no linked chat and returnschannel: "link_required"withtelegram_link- The user taps it — Telegram sends the bot
/start <token> POST /auth/telegram/webhookbinds thatchat_idto the phone and sends the code into the same chat- Next time,
channel: "telegram"and the code goes straight out
A Telegram bot can have exactly one webhook. Setting one silently replaces
whatever was there, and the previous owner stops receiving updates with no
error anywhere. Long polling is not a way around it either: getUpdates
refuses to run while a webhook is set.
There are two ways a bot can already be taken, and an empty webhook only rules out one of them:
| Sign | How to spot it | What happens if you ignore it |
|---|---|---|
| Webhook registered | getWebhookInfo returns a url |
Setting yours silently stops theirs |
| Something long-polling | getUpdates answers Conflict |
Both sides steal each other's updates |
Check both at once before wiring any token in:
cd backend && .venv/bin/python scripts/telegram_check.py <TOKEN>It prints FREE or TAKEN and says which of the two it hit.
Create a bot with @BotFather, then in backend/.env:
TELEGRAM_ENABLED_FLAG=true
TELEGRAM_BOT_TOKEN=123456:ABC-your-token
TELEGRAM_BOT_USERNAME=YourBotName
TELEGRAM_WEBHOOK_SECRET=a-long-random-string
TELEGRAM_ENABLED_FLAG is a deliberate second switch. A token on its own does
not turn delivery on, because a bot whose webhook belongs to somebody else
would hand out deep links that look correct and can never deliver a code.
Settings are read at import, so restart the API after editing .env.
Telegram only posts webhooks to a public HTTPS URL. Rather than run a tunnel, long-poll the bot in development:
cd backend && .venv/bin/python scripts/telegram_poll.pyIt runs the same handle_update the webhook runs — one code path, so there is
no dev-only behaviour to debug. It refuses to start if a webhook is registered,
because Telegram will not serve getUpdates while one exists.
Telegram needs a public HTTPS URL, so tunnel it in development
(cloudflared tunnel --url http://localhost:8000) or use your real host, then
register it once:
cd backend && .venv/bin/python -c "import asyncio;from app.modules.auth.telegram import set_webhook;print(asyncio.run(set_webhook('https://YOUR-TUNNEL/api/v1/auth/telegram/webhook','a-long-random-string')))"Check the token is valid and points at the bot you expect:
cd backend && .venv/bin/python -c "import asyncio;from app.modules.auth.telegram import get_me;print(asyncio.run(get_me()))"With no token set, channel comes back unavailable and the code appears in
the response — development only, gated on ENVIRONMENT and
EXPOSE_OTP_IN_RESPONSE.
Verified end to end against @pace_uzbot:
first sign-in channel=link_required, telegram_link=https://t.me/pace_uzbot?start=<token>
(press Start) linked +998…5101 -> chat 5995495508, code sent
every one after channel=telegram, sent=true <- no link, code just arrives
If the original code expires before they press Start, a fresh one is issued and sent on linking. Being welcomed with no code to type is a dead end, and pressing Start is itself proof they meant to sign in.
Note for deployment: Telegram's API is blocked on some Russian and Uzbek
networks. send_code fails soft and logs rather than throwing, so a blocked
host degrades to "code not delivered" instead of a 500.
Uzbek by default. 901234567, 0901234567, 998901234567 and
+998 90 123 45 67 all normalise to +998901234567. Estonian numbers stay
valid because they are useful for testing. Extend RULES in
backend/app/modules/auth/phone.py to add a country.
| Route | What it does |
|---|---|
/overview |
KPIs, 12-week attendance, "needs attention", recent sessions |
/sessions |
Upcoming and past; click through to a session |
/sessions/new |
Create a one-off session or a weekly series, with groups |
/sessions/:id |
Per-runner attendance, check-in times, kudos, the integrity panel |
/members |
Two tabs: the ranking board, and people + roles + approvals |
/invite |
Share link, QR, pending join requests |
/challenges |
Sponsored challenge builder, projected reach, past results |
/platform |
Platform admin only. Everything, across every club |
/join/:code |
Public. The page a shared invite link opens |
A signed-in user who runs no club sees a create-club form instead of an empty page, on every club-scoped route.
/join/<code> needs no account. It shows the club, the member count, the
description and the next session before asking anyone to sign in — a login
wall in front of a shared link is the biggest leak you can build. Signing in
from there returns to the invite via ?next=.
Invite codes never expire on rotation: pressing "new link" mints another one and every previously shared code keeps working until it is explicitly capped.
Russian dates come from Intl. Uzbek does not — Chrome resolves uz-UZ
to root-locale data and renders M09 29, Tue. Uzbek month and weekday names
are therefore supplied in frontend/src/format.ts. Use the helpers there
(fmtDate, fmtLong, fmtDateTime, fmtShort, fmtTime) rather than
calling toLocaleString anywhere in a component.
- No SMS provider; Telegram needs a bot token (see above).
- No Strava OAuth —
POST /sessions/{id}/recordsaccepts records directly. - Degrees recompute on kudos and on session completion; no nightly job.
- Session editing and club settings have endpoints but no screens.
- The session form takes coordinates as text; there is no map picker.
- Removing a member is API-only (
DELETE /clubs/{id}/members/meis self-leave).
A platform admin sits above club roles. Owning a club grants nothing here, and being a platform admin is granted only from the server shell:
cd backend && .venv/bin/python -m app.make_admin +998901234567Add --revoke to take it away. There is no UI path to the first one on
purpose.
/platform has four tabs — overview, people, clubs, sessions — each with
search, filters and actions. Enforced in three places: the nav item is hidden,
the route redirects, and every endpoint requires SuperAdmin.
Every control is reversible and keeps its reason and timestamp. A deleted row loses the audit trail that explains the decision, which is the one thing you need later.
| Action | Effect | Reversed by |
|---|---|---|
| Block a user | Cannot sign in; verify answers account_blocked with the reason, and existing tokens stop working |
Unblock |
| Suspend a club | No new sessions, joins, RSVPs or challenges. History stays readable to members | Restore |
| Cancel a session | Nobody can check in | Restore |
| Void a kudos | Row kept, excluded from degrees, recomputed immediately | — |
Two guards you cannot talk your way past: you cannot block yourself, and you cannot revoke your own platform admin. Undoing either would need shell access.
Suspension is enforced in ensure_club_active, called from every write path —
session create, series create, RSVP, challenge create and join. A flag that
nothing checks is theatre.
A new account answers four questions before it sees the app: which activities (multi-select, grouped sport / learning), which city, which times suit them (optional), and where they heard about Pace (optional).
Every answer feeds GET /onboarding/recommendations, which ranks — never
filters — clubs by interest (+100), same city (+30), has an upcoming session
(+10) and size. Clubs the person already belongs to sink to the bottom. So an
unusual interest still gets a list, not an empty screen.
| Endpoint | |
|---|---|
GET /onboarding/options |
The questions' choices. No token needed — the app renders them without hard-coding a list that can drift |
GET /onboarding/status |
Has this account finished? |
POST /onboarding |
Save answers; returns the recommendations. Answering again replaces, never stacks |
GET /onboarding/recommendations |
The ranked list, any time later |
cd mobile && flutter run -d chromeToday it builds for web only: iOS needs full Xcode (App Store, your Apple
ID) and Android needs the Android SDK. The same Dart compiles to both once they
exist; nothing is web-specific except web/.
lib/
api/ client.dart (Dio, token refresh, stable error codes), models.dart
core/ theme.dart (canvas tokens), strings.dart (uz/ru), format.dart, state.dart
features/ auth · onboarding · home · club · profile
widgets/ shared pieces
Built and verified in the browser, against the live API: sign-in over Telegram (including the first-time link step), all four onboarding questions, the recommendations with join, home with the next session and RSVP, discover, profile, and the Uzbek/Russian switch.
Things worth knowing:
- Uzbek dates are supplied in
format.dart. Dart's ICU data rendersuzas root-locale placeholders, the same trap the web admin hit. - Fonts are bundled (Archivo, Space Grotesk variable TTFs, OFL). A phone at a park start line often has no signal; fetched fonts would silently fall back.
state.dartuses Riverpod 3'sNotifier.StateNotifieris gone.- Debug web builds force the semantics tree on so automated checks can drive labelled elements. Release builds leave it to the platform.
- Browser automation must run the web build at its native pixel ratio. Under a mobile emulation, Flutter's own hit-testing is scaled and taps land in the wrong place — the app is fine, the harness is not.
Not built yet in Flutter: session detail with pace groups, check-in with geolocation, post-run kudos, the degree meters, notifications.