Multi-space backend: serve N spaces from one instance - #10
Merged
Conversation
Scaffolding for the upcoming multi-space refactor. Defaults are config/spaces.yaml and "pescara" so existing single-space deploys keep the same external behaviour once the rest of the refactor lands. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Parses the YAML file whose path is in SpacesConfigPath, resolves $ENV_VAR references in api_key fields, and validates required fields, unique slugs, and lat/lon range. Exposes a LegacySpaceFromConfig helper that synthesises a single-space definition from the legacy API_KEY + TELEGRAM_* env vars with the previously-hardcoded Metro Olografix SpaceAPI metadata, for the zero-config upgrade path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduces a Space entity (slug, name, SpaceAPI metadata, bcrypted API key, per-space Telegram target) and a SpaceID foreign-key column on sede_statuses with a composite (space_id, timestamp) index. Repository methods are now scoped: GetLatestStatus, CreateStatus, GetStatistics and GetWeeklyStats all take (or carry) a space_id; added GetSpaceBySlug, ListSpaces, UpsertSpace (OnConflict upsert keyed on slug), and BackfillDefaultSpaceID for the legacy-row migration. Handlers currently pass a placeholder tempDefaultSpaceID (1). App bootstrap will replace that with the real default-space ID in a follow-up commit; router/middleware will inject the resolved space from the URL one commit after that. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Send now takes chatID/threadID arguments so one bot client can notify multiple spaces. Empty token or chatID == 0 are treated as no-ops so callers don't branch on "telegram not configured". Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
NewApp now reads spaces.yaml, bcrypts each per-space API key, upserts every entry into the spaces table, and builds an in-memory slug->Space map for hot-path lookups. When the YAML file is missing, a single space is synthesised from the legacy API_KEY + TELEGRAM_* env vars so existing single-space deployments upgrade with no config changes. Legacy sede_statuses rows (space_id = 0) are backfilled onto the default space. Handlers now resolve the default space via a.defaultSpace.ID; the per-slug resolver arrives in the next commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Every handler now reads the resolved *Space from the request context. Two router middlewares feed it: resolveDefaultSpace for the legacy bare routes (/status, /stats, /spaceapi.json, /toggle) and resolveSpaceFromPath for the new /s/:slug/* group. authMiddleware bcrypt-checks X-API-KEY against the space's stored hash, so one space's key cannot unlock another's toggle. SpaceAPI fields are built from the space row; toggleStatus sends Telegram to the space's chat/thread and prefixes the message with the space name. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… path
- ui/index.html derives the stats URL from location.pathname so
/s/{slug}/ui fetches /s/{slug}/stats while legacy /ui still hits
/stats.
- deploy/docker-compose.yaml mounts ./config read-only so operators
can drop a spaces.yaml alongside the DB volume.
- deploy/spaces.example.yaml shows the two-space shape with $VAR
interpolation for secrets.
- README documents the new /s/{slug}/... surface and the legacy alias.
- .gitignore keeps real spaces.yaml out of the repo; only the example
is committed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GetWeeklyStats left result as a nil slice when no rows matched, which json.Marshal encodes as null. The UI calls data.find(...) and breaks on null. Initialise the slice so the response is always [] and add a regression test that asserts the raw JSON body. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- docker-compose.dev.yaml builds from the local Dockerfile, mounts the UI and deploy/spaces.example.yaml into /app/config, and sets dummy per-space API keys so the whole surface can be exercised with docker compose -f docker-compose.dev.yaml up --build. - Bump Dockerfile base to golang:1.24 — go.mod now requires 1.24.0. - Add L'Aquila as a third example space in spaces.example.yaml. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
michelangelomo
force-pushed
the
feat/multi-space
branch
from
April 21, 2026 18:54
6f85a96 to
5d1cb0c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Refactor the backend from single-tenant to multi-tenant so one instance can serve both Pescara and the new L'Aquila space without duplicating the deploy. Each space gets its own API key, Telegram chat/thread, and SpaceAPI metadata.
/s/{slug}/{status,toggle,stats,spaceapi.json,ui}. The bare legacy paths (/status,/toggle,/stats,/spaceapi.json,/ui) are kept as aliases forDEFAULT_SPACE_SLUG=pescarasothe deployed ESP32 button and the
sede.olografix.org-pinned MCP server keep working with zero changes.config/spaces.yaml(loaded at boot, upserted into the DB on slug) describes every space — API key, Telegram target, SpaceAPI fields. Secrets support$ENV_VARinterpolation. Seebackend/deploy/spaces.example.yaml.spaces.yamlis missing, the app synthesises a single space from the legacyAPI_KEY+TELEGRAM_*env vars and backfills anysede_statusesrows withspace_id = 0onto it —existing prod comes up untouched.
Spacemodel +SpaceIDonSedeStatuswith composite index(space_id, timestamp). All status/stats queries are scoped per-space.Dispatcher.Send(chatID, threadID, msg)— one bot client, per-call target.