← Index

ClubRide

2026twilio · multitenantLiveRepo

A multi-tenant platform for cycling clubs where members never install anything. They text IN to join a ride, ROLLING when they leave, DONE when they're back. Leaders get a web dashboard. First tenant is Toledo Area Bicyclists.

The constraint drives everything: the median club member is not going to download an app for six group rides a year, and a platform that requires one is a platform with no members.

Multi-tenancy you cannot forget to apply

The usual way to scope a multi-tenant app is WHERE org_id = ? on every query, enforced by discipline. Discipline fails once and leaks another club's roster.

Instead the guard hooks SQLAlchemy's do_orm_execute and injects with_loader_criteria(OrgScoped, cls.org_id == org_id) into every select, update, and delete that touches a scoped model. Two details make it real:

org = current_org.get()
if org is None:
    raise RuntimeError(
        "Attempted a query with no current_org context. "
        "Use `with unscoped():` for cross-tenant operations."
    )

A query with no tenant context raises rather than quietly returning every row — the failure mode is a 500 in development, not a data leak in production. And cross-tenant work has to say so out loud with with unscoped():, which turns "I forgot" into something that shows up in review. A before_flush listener stamps org_id onto new rows so inserts can't miss it either.

There's a scar in that file worth keeping: the org UUID is unwrapped from the ORM object before being closed over, because SQLAlchemy's lambda tracker recurses on ORM instances.

SMS is a stateless protocol

Tenant resolution happens two different ways. Normal requests resolve by subdomain. Twilio webhooks resolve by the To number in the form body — which forces reading await request.body() rather than request.form(), so the raw stream survives for the route handler downstream.

Text messages have no session, so disambiguation is bolted on with a ten-minute Redis key. Text IN with two rides today and you get a numbered menu; a bare 2 in reply is routed back to the pending choice and completes the check-in.

Two details I'd defend in review

Safety-alert dedup lives in Postgres, not application logic:

stmt = (pg_insert(RideSafetyAlert)
        .values(org_id=org.id, ride_id=ride.id, user_id=user.id,
                alert_type="overdue_rolling")
        .on_conflict_do_nothing(constraint="uq_safety_alert"))
result = await db.execute(stmt)
if result.rowcount == 0:
    return  # already alerted — conflict on uq_safety_alert

A cron that runs every five minutes will double-fire eventually. A unique constraint is the only version of "only alert once" that survives two workers racing.

And STOP sends the unsubscribe confirmation before recording the opt-out — commented # TCPA: — because writing the flag first would cause the opt-out to suppress the legally-required confirmation of the opt-out.

5,934 lines of Python, 6,192 of TypeScript, 51 endpoints, 105 tests. Most of it landed in 48 hours.