Skip to content
shokhsmeePublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Pace

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.

Running it

docker compose up -d db
cd 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.seed
cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000
cd frontend && npm install && npm run dev

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.

Tests

cd backend && .venv/bin/pytest tests -q

Pure-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.py

The rules, and where they are enforced

These 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.

A Flutter client

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.json
npx @openapitools/openapi-generator-cli generate -i openapi.json -g dart-dio -o mobile/api

Error 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.

Endpoints the mobile app needs

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.

Languages

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 categories

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.

Telegram OTP delivery

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:

  1. POST /auth/otp/request finds no linked chat and returns channel: "link_required" with telegram_link
  2. The user taps it — Telegram sends the bot /start <token>
  3. POST /auth/telegram/webhook binds that chat_id to the phone and sends the code into the same chat
  4. Next time, channel: "telegram" and the code goes straight out

Use a bot that belongs to Pace and nothing else

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.

Wiring it up

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.

Local development: poll, do not tunnel

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.py

It 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.

Production: register the webhook

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.

What the two-step dance actually looks like

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.

Phone numbers

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.

Admin sections

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.

The public join page

/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.

Dates and locales

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.

Known gaps

  • No SMS provider; Telegram needs a bot token (see above).
  • No Strava OAuth — POST /sessions/{id}/records accepts 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/me is self-leave).

Platform administration

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 +998901234567

Add --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.

Nothing here deletes

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.

Onboarding

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

Mobile app (Flutter)

cd mobile && flutter run -d chrome

Today 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 renders uz as 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.dart uses Riverpod 3's Notifier. StateNotifier is 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.

pace

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages