From b9c3532b0efde60d41d542ff9ac1b25a6d03b7b8 Mon Sep 17 00:00:00 2001 From: Michelangelo <3934987+michelangelomo@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:11:10 +0200 Subject: [PATCH 1/9] feat(config): add SpacesConfigPath and DefaultSpaceSlug 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 --- backend/cmd/server/cmd/root.go | 7 +++++++ backend/internal/config/config.go | 18 +++++++++++++++++ backend/internal/config/config_test.go | 28 +++++++++++++++++--------- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/backend/cmd/server/cmd/root.go b/backend/cmd/server/cmd/root.go index 980a20a..710bbf9 100644 --- a/backend/cmd/server/cmd/root.go +++ b/backend/cmd/server/cmd/root.go @@ -37,6 +37,9 @@ func init() { rootCmd.PersistentFlags().Int64Var(&cfg.TelegramChatId, "telegram-chat-id", 0, "Telegram chat ID") rootCmd.PersistentFlags().IntVar(&cfg.TelegramChatThreadId, "telegram-chat-thread-id", 0, "Telegram chat thread ID") + rootCmd.PersistentFlags().StringVar(&cfg.SpacesConfigPath, "spaces-config-path", "", "Path to the spaces.yaml config file") + rootCmd.PersistentFlags().StringVar(&cfg.DefaultSpaceSlug, "default-space-slug", "", "Slug of the space that legacy bare routes resolve to") + // Bind flags to viper viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port")) viper.BindPFlag("api_key", rootCmd.PersistentFlags().Lookup("api-key")) @@ -46,6 +49,8 @@ func init() { viper.BindPFlag("telegram_token", rootCmd.PersistentFlags().Lookup("telegram-token")) viper.BindPFlag("telegram_chat_id", rootCmd.PersistentFlags().Lookup("telegram-chat-id")) viper.BindPFlag("telegram_chat_thread_id", rootCmd.PersistentFlags().Lookup("telegram-chat-thread-id")) + viper.BindPFlag("spaces_config_path", rootCmd.PersistentFlags().Lookup("spaces-config-path")) + viper.BindPFlag("default_space_slug", rootCmd.PersistentFlags().Lookup("default-space-slug")) } func initConfig() { @@ -61,6 +66,8 @@ func initConfig() { cfg.TelegramToken = viper.GetString("telegram_token") cfg.TelegramChatId = viper.GetInt64("telegram_chat_id") cfg.TelegramChatThreadId = viper.GetInt("telegram_chat_thread_id") + cfg.SpacesConfigPath = viper.GetString("spaces_config_path") + cfg.DefaultSpaceSlug = viper.GetString("default_space_slug") } func Execute() { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f65cd50..3114189 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -16,6 +16,16 @@ type Config struct { HashAPIKey bool DatabasePath string + // SpacesConfigPath points to the YAML file that defines all spaces served + // by this instance. Empty / missing file triggers the legacy-upgrade path + // (single space synthesised from APIKey + TelegramToken + TelegramChatId). + SpacesConfigPath string + // DefaultSpaceSlug names the space that legacy bare routes (/status, + // /toggle, /stats, /spaceapi.json, /ui) resolve to. + DefaultSpaceSlug string + + // Legacy single-space Telegram target. Used only for the one-time upgrade + // path: when SpacesConfigPath is missing, these seed the default space. TelegramToken string TelegramChatId int64 TelegramChatThreadId int @@ -36,6 +46,14 @@ func ValidateAndSetDefaults(cfg Config) Config { cfg.DatabasePath = "database/sede.db" } + if cfg.SpacesConfigPath == "" { + cfg.SpacesConfigPath = "config/spaces.yaml" + } + + if cfg.DefaultSpaceSlug == "" { + cfg.DefaultSpaceSlug = "pescara" + } + return cfg } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index f713eec..6d504b0 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -19,6 +19,8 @@ func TestValidateAndSetDefaults(t *testing.T) { Debug: false, AllowedOriginsStr: "https://example.com,http://localhost:3000", DatabasePath: "custom/path.db", + SpacesConfigPath: "custom/spaces.yaml", + DefaultSpaceSlug: "bologna", }, shouldPanic: false, expected: Config{ @@ -28,6 +30,8 @@ func TestValidateAndSetDefaults(t *testing.T) { AllowedOriginsStr: "https://example.com,http://localhost:3000", AllowedOrigins: []string{"https://example.com", "http://localhost:3000"}, DatabasePath: "custom/path.db", + SpacesConfigPath: "custom/spaces.yaml", + DefaultSpaceSlug: "bologna", }, }, { @@ -39,11 +43,13 @@ func TestValidateAndSetDefaults(t *testing.T) { }, shouldPanic: false, expected: Config{ - Port: "3000", - APIKey: "validapikey123456", - Debug: false, - AllowedOrigins: []string{}, - DatabasePath: "database/sede.db", + Port: "3000", + APIKey: "validapikey123456", + Debug: false, + AllowedOrigins: []string{}, + DatabasePath: "database/sede.db", + SpacesConfigPath: "config/spaces.yaml", + DefaultSpaceSlug: "pescara", }, }, { @@ -55,11 +61,13 @@ func TestValidateAndSetDefaults(t *testing.T) { }, shouldPanic: false, expected: Config{ - Port: "8080", - APIKey: "short", - Debug: true, - AllowedOrigins: []string{}, - DatabasePath: "database/sede.db", + Port: "8080", + APIKey: "short", + Debug: true, + AllowedOrigins: []string{}, + DatabasePath: "database/sede.db", + SpacesConfigPath: "config/spaces.yaml", + DefaultSpaceSlug: "pescara", }, }, { From 245f071d52376e230cf787ec164568cb0302daca Mon Sep 17 00:00:00 2001 From: Michelangelo <3934987+michelangelomo@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:13:30 +0200 Subject: [PATCH 2/9] feat(config): add spaces.yaml loader with env-var interpolation 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 --- backend/go.mod | 2 +- backend/internal/config/spaces.go | 189 +++++++++++++++++++ backend/internal/config/spaces_test.go | 251 +++++++++++++++++++++++++ 3 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 backend/internal/config/spaces.go create mode 100644 backend/internal/config/spaces_test.go diff --git a/backend/go.mod b/backend/go.mod index 0b7ebb9..43cdafe 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -14,6 +14,7 @@ require ( golang.org/x/crypto v0.45.0 golang.org/x/net v0.47.0 golang.org/x/time v0.9.0 + gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 ) @@ -61,5 +62,4 @@ require ( golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.36.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/internal/config/spaces.go b/backend/internal/config/spaces.go new file mode 100644 index 0000000..373493c --- /dev/null +++ b/backend/internal/config/spaces.go @@ -0,0 +1,189 @@ +package config + +import ( + "errors" + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// SpaceDef is the resolved, validated description of a single space as loaded +// from spaces.yaml. Secrets referenced via $ENV_VAR are already substituted. +type SpaceDef struct { + Slug string + Name string + Address string + Lat float64 + Lon float64 + Timezone string + LogoURL string + URL string + ContactEmail string + Message string + APIKey string + TelegramChatID int64 + TelegramThread int + Projects []string + Links []SpaceLink +} + +type SpaceLink struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description" json:"description"` + URL string `yaml:"url" json:"url"` +} + +type spacesFile struct { + Spaces []spaceEntry `yaml:"spaces"` +} + +type spaceEntry struct { + Slug string `yaml:"slug"` + Name string `yaml:"name"` + Address string `yaml:"address"` + Lat float64 `yaml:"lat"` + Lon float64 `yaml:"lon"` + Timezone string `yaml:"timezone"` + LogoURL string `yaml:"logo_url"` + URL string `yaml:"url"` + Contact contactEntry `yaml:"contact"` + Message string `yaml:"message"` + APIKey string `yaml:"api_key"` + Telegram telegramEntry `yaml:"telegram"` + Projects []string `yaml:"projects"` + Links []SpaceLink `yaml:"links"` +} + +type contactEntry struct { + Email string `yaml:"email"` +} + +type telegramEntry struct { + ChatID int64 `yaml:"chat_id"` + ThreadID int `yaml:"thread_id"` +} + +// LoadSpaces reads spaces.yaml from path, resolves $ENV_VAR references in +// secret fields, and validates the result. A missing file returns an error +// that wraps os.ErrNotExist, so callers can fall back to the legacy-env path. +func LoadSpaces(path string) ([]SpaceDef, error) { + raw, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("spaces config not found at %s: %w", path, err) + } + return nil, fmt.Errorf("read spaces config %s: %w", path, err) + } + + var file spacesFile + if err := yaml.Unmarshal(raw, &file); err != nil { + return nil, fmt.Errorf("parse spaces config %s: %w", path, err) + } + + defs := make([]SpaceDef, 0, len(file.Spaces)) + for i, e := range file.Spaces { + apiKey, err := resolveEnvRef(e.APIKey) + if err != nil { + return nil, fmt.Errorf("space[%d] (%q) api_key: %w", i, e.Slug, err) + } + defs = append(defs, SpaceDef{ + Slug: e.Slug, + Name: e.Name, + Address: e.Address, + Lat: e.Lat, + Lon: e.Lon, + Timezone: e.Timezone, + LogoURL: e.LogoURL, + URL: e.URL, + ContactEmail: e.Contact.Email, + Message: e.Message, + APIKey: apiKey, + TelegramChatID: e.Telegram.ChatID, + TelegramThread: e.Telegram.ThreadID, + Projects: e.Projects, + Links: e.Links, + }) + } + + if err := ValidateSpaces(defs); err != nil { + return nil, err + } + return defs, nil +} + +// ValidateSpaces enforces required fields, unique slugs, and sane lat/lon. +func ValidateSpaces(defs []SpaceDef) error { + if len(defs) == 0 { + return errors.New("no spaces defined") + } + seen := make(map[string]struct{}, len(defs)) + for i, d := range defs { + if d.Slug == "" { + return fmt.Errorf("space[%d]: slug is required", i) + } + if d.Name == "" { + return fmt.Errorf("space[%d] (%q): name is required", i, d.Slug) + } + if d.APIKey == "" { + return fmt.Errorf("space[%d] (%q): api_key is required", i, d.Slug) + } + if d.Lat < -90 || d.Lat > 90 { + return fmt.Errorf("space[%d] (%q): lat %f out of range [-90, 90]", i, d.Slug, d.Lat) + } + if d.Lon < -180 || d.Lon > 180 { + return fmt.Errorf("space[%d] (%q): lon %f out of range [-180, 180]", i, d.Slug, d.Lon) + } + if _, dup := seen[d.Slug]; dup { + return fmt.Errorf("duplicate slug %q", d.Slug) + } + seen[d.Slug] = struct{}{} + } + return nil +} + +// LegacySpaceFromConfig synthesises the single-space definition used when no +// spaces.yaml is present. It reuses the legacy API_KEY + TELEGRAM_* env vars +// and the previously-hardcoded Metro Olografix Pescara SpaceAPI metadata, so +// an existing single-space deployment upgrades with zero config changes. +func LegacySpaceFromConfig(cfg Config) SpaceDef { + return SpaceDef{ + Slug: cfg.DefaultSpaceSlug, + Name: "Metro Olografix", + Address: "Viale Marconi 278/1, 65126 Pescara, Italy", + Lat: 42.454657, + Lon: 14.224055, + Timezone: "Europe/Rome", + LogoURL: "https://olografix.org/images/metro-dark.png", + URL: "https://olografix.org", + ContactEmail: "info@olografix.org", + Message: "We meet every Monday evening from 9:00 PM", + APIKey: cfg.APIKey, + TelegramChatID: cfg.TelegramChatId, + TelegramThread: cfg.TelegramChatThreadId, + Projects: []string{"https://github.com/Metro-Olografix"}, + Links: []SpaceLink{ + {Name: "MOCA - Metro Olografix Camp", Description: "Il più antico campeggio hacker in Italia", URL: "https://moca.camp"}, + {Name: "Wikipedia", Description: "Metro Olografix Wikipedia page", URL: "https://it.wikipedia.org/wiki/Metro_Olografix"}, + }, + } +} + +// resolveEnvRef expands a single $ENV_VAR reference. Literal values pass +// through unchanged. A reference to an unset variable is an error, so missing +// secrets fail loud at boot instead of silently sending empty keys. +func resolveEnvRef(v string) (string, error) { + if !strings.HasPrefix(v, "$") { + return v, nil + } + name := strings.TrimPrefix(v, "$") + if name == "" { + return "", errors.New(`"$" with no variable name`) + } + val, ok := os.LookupEnv(name) + if !ok { + return "", fmt.Errorf("environment variable %s is not set", name) + } + return val, nil +} diff --git a/backend/internal/config/spaces_test.go b/backend/internal/config/spaces_test.go new file mode 100644 index 0000000..a9bf24f --- /dev/null +++ b/backend/internal/config/spaces_test.go @@ -0,0 +1,251 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeYAML(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "spaces.yaml") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + return p +} + +const validTwoSpaces = ` +spaces: + - slug: pescara + name: Metro Olografix Pescara + address: Viale Marconi 278/1, Pescara + lat: 42.454657 + lon: 14.224055 + timezone: Europe/Rome + logo_url: https://olografix.org/logo.png + url: https://olografix.org + contact: + email: info@olografix.org + message: Open on Mondays + api_key: $PESCARA_API_KEY + telegram: + chat_id: -100123 + thread_id: 42 + projects: + - https://github.com/Metro-Olografix + links: + - name: MOCA + description: hacker camp + url: https://moca.camp + - slug: bologna + name: Metro Olografix Bologna + lat: 44.494887 + lon: 11.342616 + api_key: bologna-plain-key-0000 + telegram: + chat_id: -100456 +` + +func TestLoadSpaces_ValidYAML(t *testing.T) { + t.Setenv("PESCARA_API_KEY", "pescara-secret-from-env") + path := writeYAML(t, validTwoSpaces) + + defs, err := LoadSpaces(path) + if err != nil { + t.Fatalf("LoadSpaces: %v", err) + } + if len(defs) != 2 { + t.Fatalf("expected 2 spaces, got %d", len(defs)) + } + + p := defs[0] + if p.Slug != "pescara" || + p.Name != "Metro Olografix Pescara" || + p.Address != "Viale Marconi 278/1, Pescara" || + p.Lat != 42.454657 || + p.Lon != 14.224055 || + p.Timezone != "Europe/Rome" || + p.LogoURL != "https://olografix.org/logo.png" || + p.URL != "https://olografix.org" || + p.ContactEmail != "info@olografix.org" || + p.Message != "Open on Mondays" || + p.APIKey != "pescara-secret-from-env" || + p.TelegramChatID != -100123 || + p.TelegramThread != 42 { + t.Errorf("pescara fields not fully populated: %+v", p) + } + if len(p.Projects) != 1 || p.Projects[0] != "https://github.com/Metro-Olografix" { + t.Errorf("projects mismatch: %+v", p.Projects) + } + if len(p.Links) != 1 || p.Links[0].Name != "MOCA" || p.Links[0].URL != "https://moca.camp" { + t.Errorf("links mismatch: %+v", p.Links) + } + + b := defs[1] + if b.Slug != "bologna" || b.APIKey != "bologna-plain-key-0000" || b.TelegramChatID != -100456 || b.TelegramThread != 0 { + t.Errorf("bologna fields wrong: %+v", b) + } +} + +func TestLoadSpaces_EnvInterpolation_MissingEnv(t *testing.T) { + os.Unsetenv("PESCARA_API_KEY") + path := writeYAML(t, ` +spaces: + - slug: pescara + name: P + lat: 0 + lon: 0 + api_key: $PESCARA_API_KEY +`) + + _, err := LoadSpaces(path) + if err == nil { + t.Fatal("expected error when referenced env var is missing") + } + if !strings.Contains(err.Error(), "PESCARA_API_KEY") { + t.Errorf("error should name the missing variable, got: %v", err) + } +} + +func TestLoadSpaces_MissingFile(t *testing.T) { + _, err := LoadSpaces(filepath.Join(t.TempDir(), "does-not-exist.yaml")) + if err == nil { + t.Fatal("expected error for missing file") + } + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("error should wrap os.ErrNotExist, got: %v", err) + } +} + +func TestLoadSpaces_MalformedYAML(t *testing.T) { + path := writeYAML(t, "spaces: [this is not: valid yaml\n indent broken") + + _, err := LoadSpaces(path) + if err == nil { + t.Fatal("expected parse error") + } + if !strings.Contains(err.Error(), "parse spaces config") { + t.Errorf("error should mention parse failure, got: %v", err) + } +} + +func TestLoadSpaces_DuplicateSlug(t *testing.T) { + path := writeYAML(t, ` +spaces: + - slug: pescara + name: A + lat: 0 + lon: 0 + api_key: keyAkeyAkeyAkeyA + - slug: pescara + name: B + lat: 1 + lon: 1 + api_key: keyBkeyBkeyBkeyB +`) + _, err := LoadSpaces(path) + if err == nil || !strings.Contains(err.Error(), "duplicate slug") { + t.Fatalf("expected duplicate-slug error, got: %v", err) + } +} + +func TestLoadSpaces_EmptyRequiredFields(t *testing.T) { + cases := []struct { + name string + yaml string + want string + }{ + { + name: "missing slug", + yaml: `spaces: [{name: A, lat: 0, lon: 0, api_key: kkkkkkkkkkkkkkkk}]`, + want: "slug is required", + }, + { + name: "missing name", + yaml: `spaces: [{slug: x, lat: 0, lon: 0, api_key: kkkkkkkkkkkkkkkk}]`, + want: "name is required", + }, + { + name: "missing api_key", + yaml: `spaces: [{slug: x, name: X, lat: 0, lon: 0}]`, + want: "api_key is required", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := LoadSpaces(writeYAML(t, c.yaml)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("expected error containing %q, got: %v", c.want, err) + } + }) + } +} + +func TestLoadSpaces_InvalidLatLon(t *testing.T) { + cases := []struct { + name string + lat float64 + lon float64 + want string + }{ + {"lat too high", 91, 0, "lat 91"}, + {"lat too low", -91, 0, "lat -91"}, + {"lon too high", 0, 181, "lon 181"}, + {"lon too low", 0, -181, "lon -181"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + yaml := fmt.Sprintf( + "spaces:\n - slug: x\n name: X\n api_key: kkkkkkkkkkkkkkkk\n lat: %g\n lon: %g\n", + c.lat, c.lon, + ) + _, err := LoadSpaces(writeYAML(t, yaml)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("expected %q, got: %v", c.want, err) + } + }) + } +} + +func TestLoadSpaces_EmptyFileRejected(t *testing.T) { + _, err := LoadSpaces(writeYAML(t, "spaces: []\n")) + if err == nil || !strings.Contains(err.Error(), "no spaces") { + t.Fatalf("expected 'no spaces' error, got: %v", err) + } +} + +func TestLegacySpaceFromConfig(t *testing.T) { + cfg := Config{ + APIKey: "legacy-api-key-0000", + DefaultSpaceSlug: "pescara", + TelegramChatId: -100999, + TelegramChatThreadId: 7, + } + def := LegacySpaceFromConfig(cfg) + + if def.Slug != "pescara" { + t.Errorf("slug: got %q", def.Slug) + } + if def.APIKey != "legacy-api-key-0000" { + t.Errorf("api_key not propagated from legacy cfg: %q", def.APIKey) + } + if def.TelegramChatID != -100999 || def.TelegramThread != 7 { + t.Errorf("telegram: %+v", def) + } + // SpaceAPI metadata hardcoded to Pescara values to match pre-refactor + // /spaceapi.json response byte-for-byte. + if def.Name != "Metro Olografix" || def.Lat != 42.454657 || def.Lon != 14.224055 { + t.Errorf("pescara spaceapi defaults wrong: %+v", def) + } + if len(def.Links) != 2 || def.Links[0].URL != "https://moca.camp" { + t.Errorf("links defaults wrong: %+v", def.Links) + } + if err := ValidateSpaces([]SpaceDef{def}); err != nil { + t.Errorf("legacy-synthesised def should validate: %v", err) + } +} From cdaf0343a83c07f1f4181e535532a5ba4a42cd0e Mon Sep 17 00:00:00 2001 From: Michelangelo <3934987+michelangelomo@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:18:20 +0200 Subject: [PATCH 3/9] feat(db): add Space model and scope status/stats queries per space 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 --- backend/internal/app/handlers.go | 14 +- backend/internal/app/handlers_test.go | 1 + backend/internal/database/database.go | 134 +++++-- backend/internal/database/database_test.go | 386 +++++++++++++-------- 4 files changed, 356 insertions(+), 179 deletions(-) diff --git a/backend/internal/app/handlers.go b/backend/internal/app/handlers.go index 8a0c5e3..38b8ccc 100644 --- a/backend/internal/app/handlers.go +++ b/backend/internal/app/handlers.go @@ -24,6 +24,11 @@ const ( apiKeyMinLength = 16 cooldownPeriod = time.Minute contextTimeout = 30 * time.Second + + // TODO(commit 5): replace with a.defaultSpace.ID once app bootstrap loads + // the space config. Every handler is scoped per-space; until the router + // resolves the slug (commit 6), we operate against a single seeded row. + tempDefaultSpaceID = uint(1) ) type StatsResponse struct { @@ -74,7 +79,7 @@ func (a *App) getStatus(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), contextTimeout) defer cancel() - status, err := a.repo.GetLatestStatus(ctx) + status, err := a.repo.GetLatestStatus(ctx, tempDefaultSpaceID) if handleDatabaseError(c, err) { return } @@ -97,7 +102,7 @@ func (a *App) toggleStatus(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), contextTimeout) defer cancel() - currentStatus, err := a.repo.GetLatestStatus(ctx) + currentStatus, err := a.repo.GetLatestStatus(ctx, tempDefaultSpaceID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { handleDatabaseError(c, err) return @@ -121,6 +126,7 @@ func (a *App) toggleStatus(c *gin.Context) { // Toggle status newStatus := database.SedeStatus{ + SpaceID: tempDefaultSpaceID, IsOpen: !currentStatus.IsOpen, Timestamp: time.Now().UTC(), } @@ -209,7 +215,7 @@ func (a *App) getStats(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), contextTimeout) defer cancel() - weeklyStats, err := a.repo.GetWeeklyStats(ctx) + weeklyStats, err := a.repo.GetWeeklyStats(ctx, tempDefaultSpaceID) if handleDatabaseError(c, err) { return } @@ -269,7 +275,7 @@ func (a *App) getSpaceAPI(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), contextTimeout) defer cancel() - status, err := a.repo.GetLatestStatus(ctx) + status, err := a.repo.GetLatestStatus(ctx, tempDefaultSpaceID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { handleDatabaseError(c, err) return diff --git a/backend/internal/app/handlers_test.go b/backend/internal/app/handlers_test.go index c879512..568bb6b 100644 --- a/backend/internal/app/handlers_test.go +++ b/backend/internal/app/handlers_test.go @@ -49,6 +49,7 @@ func setupTestApp(t *testing.T) (*App, func()) { func createTestStatus(t *testing.T, app *App, isOpen bool, timestamp time.Time) { status := database.SedeStatus{ + SpaceID: tempDefaultSpaceID, IsOpen: isOpen, Timestamp: timestamp, } diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index 5e77d54..62c406c 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -10,6 +10,7 @@ import ( "github.com/metro-olografix/sede/internal/config" "gorm.io/driver/sqlite" "gorm.io/gorm" + "gorm.io/gorm/clause" "gorm.io/gorm/logger" ) @@ -22,10 +23,40 @@ type Repository struct { Db *gorm.DB } +// Space is one physical association location served by this instance. +// The API key is stored as a bcrypt hash; per-space Telegram chat and thread +// IDs route notifications without a global bot configuration. Projects and +// Links hold JSON-encoded arrays used by the per-space SpaceAPI response. +type Space struct { + ID uint `gorm:"primarykey"` + Slug string `gorm:"uniqueIndex;not null"` + Name string `gorm:"not null"` + Address string + Lat float64 + Lon float64 + Timezone string + LogoURL string + URL string + ContactEmail string + Message string + APIKeyHash []byte `gorm:"not null"` + TelegramChatID int64 + TelegramThread int + Projects string + Links string + CreatedAt time.Time + UpdatedAt time.Time +} + +// SedeStatus is an open/closed event for a specific space. `default:0` on +// SpaceID exists solely so that adding the column via SQLite ALTER TABLE on +// an existing single-space DB succeeds; new rows always set SpaceID +// explicitly, and boot-time backfill rewrites any legacy zeros. type SedeStatus struct { ID uint `gorm:"primarykey"` - IsOpen bool `gorm:"not null;index"` - Timestamp time.Time `gorm:"not null;index"` + SpaceID uint `gorm:"not null;default:0;index:idx_space_timestamp,priority:1"` + IsOpen bool `gorm:"not null"` + Timestamp time.Time `gorm:"not null;index:idx_space_timestamp,priority:2"` } type DailyStats struct { @@ -33,7 +64,6 @@ type DailyStats struct { Probability float64 `json:"probability" validate:"required,min=0,max=1"` } -// New types for weekly statistics type HourlyStat struct { Hour string `json:"hour"` Probability float64 `json:"probability"` @@ -68,9 +98,12 @@ func New(cfg config.Config) (*Repository, error) { return &Repository{Db: db}, nil } -func (r *Repository) GetLatestStatus(ctx context.Context) (SedeStatus, error) { +func (r *Repository) GetLatestStatus(ctx context.Context, spaceID uint) (SedeStatus, error) { var status SedeStatus - err := r.Db.WithContext(ctx).Order("timestamp desc").First(&status).Error + err := r.Db.WithContext(ctx). + Where("space_id = ?", spaceID). + Order("timestamp desc"). + First(&status).Error return status, err } @@ -78,24 +111,26 @@ func (r *Repository) CreateStatus(ctx context.Context, status SedeStatus) error return r.Db.WithContext(ctx).Create(&status).Error } -func (r *Repository) GetStatistics(ctx context.Context) ([]DailyStats, int64, error) { +func (r *Repository) GetStatistics(ctx context.Context, spaceID uint) ([]DailyStats, int64, error) { var totalChanges int64 var dailyStats []DailyStats err := r.Db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&SedeStatus{}).Count(&totalChanges).Error; err != nil { + if err := tx.Model(&SedeStatus{}).Where("space_id = ?", spaceID).Count(&totalChanges).Error; err != nil { return err } return tx.Raw( - `SELECT strftime(?, timestamp) as date, - COUNT(*) * 1.0 / ? as probability - FROM sede_statuses - WHERE timestamp >= date('now', ?) - GROUP BY date + `SELECT strftime(?, timestamp) as date, + COUNT(*) * 1.0 / ? as probability + FROM sede_statuses + WHERE space_id = ? + AND timestamp >= date('now', ?) + GROUP BY date ORDER BY date`, statsDateLayout, analysisDays, + spaceID, fmt.Sprintf("-%d days", analysisDays), ).Scan(&dailyStats).Error }) @@ -103,15 +138,15 @@ func (r *Repository) GetStatistics(ctx context.Context) ([]DailyStats, int64, er return dailyStats, totalChanges, err } -// GetWeeklyStats fetches daily and hourly statistics merged by day. -func (r *Repository) GetWeeklyStats(ctx context.Context) ([]WeeklyStatsDetailed, error) { - // Query overall daily probability +// GetWeeklyStats fetches daily and hourly probabilities for the given space +// over the last 90 days, grouped by weekday and (9-21 UTC) hour. +func (r *Repository) GetWeeklyStats(ctx context.Context, spaceID uint) ([]WeeklyStatsDetailed, error) { var dailyStats []struct { Day string `json:"day"` DailyProbability float64 `json:"dailyProbability"` } err := r.Db.WithContext(ctx).Raw(` - SELECT + SELECT CASE strftime('%w', timestamp) WHEN '0' THEN 'Sunday' WHEN '1' THEN 'Monday' @@ -122,22 +157,22 @@ func (r *Repository) GetWeeklyStats(ctx context.Context) ([]WeeklyStatsDetailed, ELSE 'Saturday' END as day, AVG(CASE WHEN is_open THEN 1.0 ELSE 0.0 END) as dailyProbability FROM sede_statuses - WHERE timestamp >= date('now', '-90 days') + WHERE space_id = ? + AND timestamp >= date('now', '-90 days') GROUP BY day ORDER BY strftime('%w', timestamp) - `).Scan(&dailyStats).Error + `, spaceID).Scan(&dailyStats).Error if err != nil { return nil, err } - // Query hourly breakdown for hours 9am to 9pm var hourlyStats []struct { Day string `json:"day"` Hour string `json:"hour"` Probability float64 `json:"probability"` } err = r.Db.WithContext(ctx).Raw(` - SELECT + SELECT CASE strftime('%w', timestamp) WHEN '0' THEN 'Sunday' WHEN '1' THEN 'Monday' @@ -149,11 +184,12 @@ func (r *Repository) GetWeeklyStats(ctx context.Context) ([]WeeklyStatsDetailed, strftime('%H', timestamp) as hour, AVG(CASE WHEN is_open THEN 1.0 ELSE 0.0 END) as probability FROM sede_statuses - WHERE timestamp >= date('now', '-90 days') + WHERE space_id = ? + AND timestamp >= date('now', '-90 days') AND CAST(strftime('%H', timestamp) as integer) BETWEEN 9 AND 21 GROUP BY day, hour ORDER BY strftime('%w', timestamp), hour - `).Scan(&hourlyStats).Error + `, spaceID).Scan(&hourlyStats).Error if err != nil { return nil, err } @@ -190,6 +226,58 @@ func (r *Repository) GetWeeklyStats(ctx context.Context) ([]WeeklyStatsDetailed, return result, nil } +// GetSpaceBySlug returns the space with the given slug, or +// gorm.ErrRecordNotFound if none exists. +func (r *Repository) GetSpaceBySlug(ctx context.Context, slug string) (*Space, error) { + var sp Space + if err := r.Db.WithContext(ctx).Where("slug = ?", slug).First(&sp).Error; err != nil { + return nil, err + } + return &sp, nil +} + +// ListSpaces returns every space in the database, ordered by ID. +func (r *Repository) ListSpaces(ctx context.Context) ([]Space, error) { + var spaces []Space + err := r.Db.WithContext(ctx).Order("id asc").Find(&spaces).Error + return spaces, err +} + +// UpsertSpace inserts s if its slug is new, otherwise updates every +// mutable column on the existing row. Returns the persisted row including +// its assigned ID so callers can cache it. +func (r *Repository) UpsertSpace(ctx context.Context, s Space) (*Space, error) { + err := r.Db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "slug"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "name", "address", "lat", "lon", "timezone", + "logo_url", "url", "contact_email", "message", + "api_key_hash", "telegram_chat_id", "telegram_thread", + "projects", "links", "updated_at", + }), + }).Create(&s).Error + if err != nil { + return nil, err + } + // OnConflict's DoUpdates path does not populate s.ID reliably across + // drivers, so re-read by slug to guarantee we return the persisted row. + return r.GetSpaceBySlug(ctx, s.Slug) +} + +// BackfillDefaultSpaceID rewrites any sede_statuses rows still carrying the +// migration default (space_id = 0) to point at spaceID. Returns the number +// of rows updated so the caller can log it. +func (r *Repository) BackfillDefaultSpaceID(ctx context.Context, spaceID uint) (int64, error) { + if spaceID == 0 { + return 0, fmt.Errorf("refusing to backfill to space_id = 0") + } + res := r.Db.WithContext(ctx). + Model(&SedeStatus{}). + Where("space_id = ?", 0). + Update("space_id", spaceID) + return res.RowsAffected, res.Error +} + func createLogger(debug bool) logger.Interface { logLevel := logger.Silent if debug { @@ -223,5 +311,5 @@ func configureConnectionPool(db *gorm.DB) error { } func migrateSchema(db *gorm.DB) error { - return db.AutoMigrate(&SedeStatus{}) + return db.AutoMigrate(&Space{}, &SedeStatus{}) } diff --git a/backend/internal/database/database_test.go b/backend/internal/database/database_test.go index 68410bc..5bfbcd9 100644 --- a/backend/internal/database/database_test.go +++ b/backend/internal/database/database_test.go @@ -2,12 +2,14 @@ package database import ( "context" + "errors" "os" "path/filepath" "testing" "time" "github.com/metro-olografix/sede/internal/config" + "gorm.io/gorm" ) func setupTestDB(t *testing.T) (*Repository, func()) { @@ -34,6 +36,22 @@ func setupTestDB(t *testing.T) (*Repository, func()) { return repo, cleanup } +// seedSpace persists a space with the given slug and returns its ID. Tests +// that insert SedeStatus rows need a space ID so the scoped queries have +// something to find. +func seedSpace(t *testing.T, repo *Repository, slug string) uint { + t.Helper() + sp, err := repo.UpsertSpace(context.Background(), Space{ + Slug: slug, + Name: slug, + APIKeyHash: []byte("fake-hash-for-tests"), + }) + if err != nil { + t.Fatalf("seed space %q: %v", slug, err) + } + return sp.ID +} + func TestNew(t *testing.T) { t.Run("successful database creation", func(t *testing.T) { tmpDir := t.TempDir() @@ -57,7 +75,6 @@ func TestNew(t *testing.T) { t.Fatal("Expected database connection to be established") } - // Clean up if sqlDB, err := repo.Db.DB(); err == nil { sqlDB.Close() } @@ -76,208 +93,273 @@ func TestNew(t *testing.T) { }) } -func TestCreateAndGetLatestStatus(t *testing.T) { +func TestMigrateSchema_CreatesSpacesTable(t *testing.T) { repo, cleanup := setupTestDB(t) defer cleanup() - ctx := context.Background() + if !repo.Db.Migrator().HasTable(&Space{}) { + t.Fatal("expected spaces table to exist after migrate") + } + if !repo.Db.Migrator().HasIndex(&Space{}, "Slug") { + t.Error("expected unique index on Space.Slug") + } +} - t.Run("create and retrieve status", func(t *testing.T) { - testTime := time.Now().UTC() - status := SedeStatus{ - IsOpen: true, - Timestamp: testTime, - } +func TestMigrateSchema_AddsSpaceIDColumn(t *testing.T) { + repo, cleanup := setupTestDB(t) + defer cleanup() - err := repo.CreateStatus(ctx, status) - if err != nil { - t.Fatalf("Failed to create status: %v", err) - } + if !repo.Db.Migrator().HasColumn(&SedeStatus{}, "SpaceID") { + t.Fatal("expected sede_statuses.space_id column") + } + if !repo.Db.Migrator().HasIndex(&SedeStatus{}, "idx_space_timestamp") { + t.Error("expected composite index idx_space_timestamp on sede_statuses") + } +} - latest, err := repo.GetLatestStatus(ctx) - if err != nil { - t.Fatalf("Failed to get latest status: %v", err) - } +func TestUpsertSpace_InsertAndUpdate(t *testing.T) { + repo, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() - if latest.IsOpen != true { - t.Errorf("Expected IsOpen to be true, got %v", latest.IsOpen) - } + first, err := repo.UpsertSpace(ctx, Space{ + Slug: "pescara", + Name: "Pescara Orig", + APIKeyHash: []byte("hash-v1"), + TelegramChatID: -1, + }) + if err != nil { + t.Fatalf("insert: %v", err) + } + if first.ID == 0 { + t.Fatal("expected assigned ID after insert") + } + if first.CreatedAt.IsZero() { + t.Error("expected CreatedAt populated") + } - if latest.Timestamp.Unix() != testTime.Unix() { - t.Errorf("Expected timestamp %v, got %v", testTime, latest.Timestamp) - } + // Upsert again with the same slug — same row, updated fields. + second, err := repo.UpsertSpace(ctx, Space{ + Slug: "pescara", + Name: "Pescara Renamed", + APIKeyHash: []byte("hash-v2"), + TelegramChatID: -2, }) + if err != nil { + t.Fatalf("update: %v", err) + } + if second.ID != first.ID { + t.Errorf("expected same row ID after upsert, got %d vs %d", first.ID, second.ID) + } + if second.Name != "Pescara Renamed" || string(second.APIKeyHash) != "hash-v2" || second.TelegramChatID != -2 { + t.Errorf("mutable fields not updated: %+v", second) + } - t.Run("get latest from multiple statuses", func(t *testing.T) { - // Create older status - oldTime := time.Now().UTC().Add(-1 * time.Hour) - oldStatus := SedeStatus{ - IsOpen: false, - Timestamp: oldTime, - } - repo.CreateStatus(ctx, oldStatus) + var count int64 + repo.Db.Model(&Space{}).Count(&count) + if count != 1 { + t.Errorf("expected 1 row after upsert-twice, got %d", count) + } +} - // Create newer status - newTime := time.Now().UTC() - newStatus := SedeStatus{ - IsOpen: true, - Timestamp: newTime, - } - repo.CreateStatus(ctx, newStatus) +func TestGetSpaceBySlug(t *testing.T) { + repo, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() - latest, err := repo.GetLatestStatus(ctx) - if err != nil { - t.Fatalf("Failed to get latest status: %v", err) - } + seedSpace(t, repo, "pescara") - if latest.IsOpen != true { - t.Errorf("Expected latest status to be open, got %v", latest.IsOpen) - } + sp, err := repo.GetSpaceBySlug(ctx, "pescara") + if err != nil { + t.Fatalf("hit: %v", err) + } + if sp == nil || sp.Slug != "pescara" { + t.Errorf("unexpected: %+v", sp) + } + + _, err = repo.GetSpaceBySlug(ctx, "does-not-exist") + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Errorf("miss should be ErrRecordNotFound, got %v", err) + } +} - if latest.Timestamp.Unix() < newTime.Unix()-1 { // Allow 1 second tolerance - t.Errorf("Expected latest timestamp to be recent, got %v", latest.Timestamp) +func TestGetLatestStatus_ScopedPerSpace(t *testing.T) { + repo, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + a := seedSpace(t, repo, "spaceA") + b := seedSpace(t, repo, "spaceB") + + base := time.Now().UTC() + mustCreate := func(spaceID uint, open bool, offset time.Duration) { + if err := repo.CreateStatus(ctx, SedeStatus{SpaceID: spaceID, IsOpen: open, Timestamp: base.Add(offset)}); err != nil { + t.Fatal(err) } - }) + } + + // spaceA: closed@-2h, open@-1h (latest open) + mustCreate(a, false, -2*time.Hour) + mustCreate(a, true, -1*time.Hour) + // spaceB: open@-3h, closed@-30m (latest closed — and more recent than A) + mustCreate(b, true, -3*time.Hour) + mustCreate(b, false, -30*time.Minute) + + latestA, err := repo.GetLatestStatus(ctx, a) + if err != nil { + t.Fatal(err) + } + if latestA.IsOpen != true || latestA.SpaceID != a { + t.Errorf("A latest wrong: %+v", latestA) + } + + latestB, err := repo.GetLatestStatus(ctx, b) + if err != nil { + t.Fatal(err) + } + if latestB.IsOpen != false || latestB.SpaceID != b { + t.Errorf("B latest wrong: %+v", latestB) + } } -func TestGetStatistics(t *testing.T) { +func TestGetLatestStatus_EmptySpace(t *testing.T) { repo, cleanup := setupTestDB(t) defer cleanup() + ctx := context.Background() + id := seedSpace(t, repo, "empty") + _, err := repo.GetLatestStatus(ctx, id) + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Errorf("expected ErrRecordNotFound, got %v", err) + } +} + +func TestGetWeeklyStats_ScopedPerSpace(t *testing.T) { + repo, cleanup := setupTestDB(t) + defer cleanup() ctx := context.Background() - t.Run("statistics with no data", func(t *testing.T) { - stats, total, err := repo.GetStatistics(ctx) - if err != nil { - t.Fatalf("Failed to get statistics: %v", err) - } + a := seedSpace(t, repo, "spaceA") + b := seedSpace(t, repo, "spaceB") - if total != 0 { - t.Errorf("Expected total changes to be 0, got %d", total) + // Seed only spaceA. spaceB stays empty. + now := time.Now().UTC() + monday10 := time.Date(now.Year(), now.Month(), now.Day()-int(now.Weekday())+1, 10, 0, 0, 0, time.UTC) + for _, s := range []SedeStatus{ + {SpaceID: a, IsOpen: true, Timestamp: monday10}, + {SpaceID: a, IsOpen: false, Timestamp: monday10.Add(4 * time.Hour)}, + } { + if err := repo.CreateStatus(ctx, s); err != nil { + t.Fatal(err) } + } - if len(stats) != 0 { - t.Errorf("Expected no daily stats, got %d", len(stats)) - } - }) + statsA, err := repo.GetWeeklyStats(ctx, a) + if err != nil { + t.Fatal(err) + } + if len(statsA) == 0 { + t.Error("expected non-empty stats for spaceA") + } - t.Run("statistics with data", func(t *testing.T) { - // Create some test data - now := time.Now().UTC() - statuses := []SedeStatus{ - {IsOpen: true, Timestamp: now.Add(-24 * time.Hour)}, - {IsOpen: false, Timestamp: now.Add(-23 * time.Hour)}, - {IsOpen: true, Timestamp: now.Add(-1 * time.Hour)}, - } + statsB, err := repo.GetWeeklyStats(ctx, b) + if err != nil { + t.Fatal(err) + } + if len(statsB) != 0 { + t.Errorf("expected empty stats for spaceB (no rows), got %d", len(statsB)) + } +} - for _, status := range statuses { - err := repo.CreateStatus(ctx, status) - if err != nil { - t.Fatalf("Failed to create status: %v", err) - } - } +func TestGetStatistics_ScopedPerSpace(t *testing.T) { + repo, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() - stats, total, err := repo.GetStatistics(ctx) - if err != nil { - t.Fatalf("Failed to get statistics: %v", err) - } + a := seedSpace(t, repo, "spaceA") + b := seedSpace(t, repo, "spaceB") - if total < 3 { - t.Errorf("Expected at least 3 total changes, got %d", total) + now := time.Now().UTC() + for _, s := range []SedeStatus{ + {SpaceID: a, IsOpen: true, Timestamp: now.Add(-24 * time.Hour)}, + {SpaceID: a, IsOpen: false, Timestamp: now.Add(-23 * time.Hour)}, + {SpaceID: a, IsOpen: true, Timestamp: now.Add(-1 * time.Hour)}, + {SpaceID: b, IsOpen: true, Timestamp: now.Add(-1 * time.Hour)}, + } { + if err := repo.CreateStatus(ctx, s); err != nil { + t.Fatal(err) } + } - if len(stats) == 0 { - t.Error("Expected some daily statistics") - } + _, totalA, err := repo.GetStatistics(ctx, a) + if err != nil { + t.Fatal(err) + } + if totalA != 3 { + t.Errorf("spaceA totalChanges: want 3, got %d", totalA) + } - // Verify daily stats structure - for _, stat := range stats { - if stat.Date == "" { - t.Error("Expected date to be set") - } - if stat.Probability < 0 || stat.Probability > 1 { - t.Errorf("Expected probability between 0 and 1, got %f", stat.Probability) - } - } - }) + _, totalB, err := repo.GetStatistics(ctx, b) + if err != nil { + t.Fatal(err) + } + if totalB != 1 { + t.Errorf("spaceB totalChanges: want 1, got %d", totalB) + } } -func TestGetWeeklyStats(t *testing.T) { +func TestBackfillDefaultSpaceID(t *testing.T) { repo, cleanup := setupTestDB(t) defer cleanup() - ctx := context.Background() - t.Run("weekly stats with no data", func(t *testing.T) { - stats, err := repo.GetWeeklyStats(ctx) - if err != nil { - t.Fatalf("Failed to get weekly stats: %v", err) - } + defaultID := seedSpace(t, repo, "default") - if len(stats) != 0 { - t.Errorf("Expected no weekly stats, got %d", len(stats)) + // Simulate a legacy DB: rows inserted with SpaceID=0 (the migration + // default), predating the multi-space refactor. + now := time.Now().UTC() + for _, ts := range []time.Duration{-3 * time.Hour, -2 * time.Hour, -1 * time.Hour} { + if err := repo.Db.Create(&SedeStatus{SpaceID: 0, IsOpen: true, Timestamp: now.Add(ts)}).Error; err != nil { + t.Fatal(err) } - }) + } - t.Run("weekly stats with data", func(t *testing.T) { - // Create test data for different days and hours - now := time.Now().UTC() - - // Monday 10 AM - open - monday10 := time.Date(now.Year(), now.Month(), now.Day()-int(now.Weekday())+1, 10, 0, 0, 0, time.UTC) - // Monday 2 PM - closed - monday14 := time.Date(now.Year(), now.Month(), now.Day()-int(now.Weekday())+1, 14, 0, 0, 0, time.UTC) - // Tuesday 11 AM - open - tuesday11 := time.Date(now.Year(), now.Month(), now.Day()-int(now.Weekday())+2, 11, 0, 0, 0, time.UTC) - - statuses := []SedeStatus{ - {IsOpen: true, Timestamp: monday10}, - {IsOpen: false, Timestamp: monday14}, - {IsOpen: true, Timestamp: tuesday11}, - } + updated, err := repo.BackfillDefaultSpaceID(ctx, defaultID) + if err != nil { + t.Fatal(err) + } + if updated != 3 { + t.Errorf("expected 3 rows backfilled, got %d", updated) + } - for _, status := range statuses { - err := repo.CreateStatus(ctx, status) - if err != nil { - t.Fatalf("Failed to create status: %v", err) - } - } + var remaining int64 + repo.Db.Model(&SedeStatus{}).Where("space_id = 0").Count(&remaining) + if remaining != 0 { + t.Errorf("expected 0 orphan rows, got %d", remaining) + } - stats, err := repo.GetWeeklyStats(ctx) - if err != nil { - t.Fatalf("Failed to get weekly stats: %v", err) - } + var migrated int64 + repo.Db.Model(&SedeStatus{}).Where("space_id = ?", defaultID).Count(&migrated) + if migrated != 3 { + t.Errorf("expected 3 rows on default space, got %d", migrated) + } +} - // Should have some weekly stats - if len(stats) == 0 { - t.Error("Expected some weekly statistics") - } +func TestBackfillDefaultSpaceID_RefusesZero(t *testing.T) { + repo, cleanup := setupTestDB(t) + defer cleanup() - // Verify structure - for _, stat := range stats { - if stat.Day == "" { - t.Error("Expected day to be set") - } - if stat.DailyProbability < 0 || stat.DailyProbability > 1 { - t.Errorf("Expected daily probability between 0 and 1, got %f", stat.DailyProbability) - } - - for _, hourly := range stat.Hourly { - if hourly.Hour == "" { - t.Error("Expected hour to be set") - } - if hourly.Probability < 0 || hourly.Probability > 1 { - t.Errorf("Expected hourly probability between 0 and 1, got %f", hourly.Probability) - } - } - } - }) + _, err := repo.BackfillDefaultSpaceID(context.Background(), 0) + if err == nil { + t.Fatal("expected refusal to backfill into space_id=0") + } } func TestSedeStatus(t *testing.T) { t.Run("sede status creation", func(t *testing.T) { testTime := time.Now().UTC() status := SedeStatus{ + SpaceID: 1, IsOpen: true, Timestamp: testTime, } From ef03e1e321b2dcf6e40e7bb260fefb8969c3f7d2 Mon Sep 17 00:00:00 2001 From: Michelangelo <3934987+michelangelomo@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:22:26 +0200 Subject: [PATCH 4/9] refactor(notification): turn Telegram into a per-call Dispatcher 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 --- backend/go.mod | 2 +- backend/internal/app/app.go | 4 +- backend/internal/app/handlers.go | 2 +- backend/internal/notification/telegram.go | 58 +++-- .../internal/notification/telegram_test.go | 216 +++--------------- 5 files changed, 60 insertions(+), 222 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 43cdafe..0851d26 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,7 +12,6 @@ require ( github.com/spf13/viper v1.19.0 github.com/ulule/limiter/v3 v3.11.2 golang.org/x/crypto v0.45.0 - golang.org/x/net v0.47.0 golang.org/x/time v0.9.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/sqlite v1.5.7 @@ -58,6 +57,7 @@ require ( go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.12.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.36.1 // indirect diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index d395d84..6eee1af 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -24,7 +24,7 @@ type App struct { limiter *rate.Limiter apiKeyHash []byte rateLimiter *limiter.Limiter - telegram *notification.Telegram + telegram *notification.Dispatcher } const ( @@ -56,7 +56,7 @@ func NewApp(cfg config.Config) (*App, error) { Limit: rateLimitRequests, }) - telegram, err := notification.NewTelegram(cfg) + telegram, err := notification.NewDispatcher(cfg.TelegramToken) if err != nil { log.Printf("telegram notification not initialized: %s", err.Error()) } diff --git a/backend/internal/app/handlers.go b/backend/internal/app/handlers.go index 38b8ccc..3e7266e 100644 --- a/backend/internal/app/handlers.go +++ b/backend/internal/app/handlers.go @@ -153,7 +153,7 @@ func (a *App) toggleStatus(c *gin.Context) { msg = fmt.Sprintf("%s sede %s", emoji, action) } - if err := a.telegram.Send(msg); err != nil { + if err := a.telegram.Send(a.config.TelegramChatId, a.config.TelegramChatThreadId, msg); err != nil { log.Printf("Failed to send Telegram notification: %v", err) } }() diff --git a/backend/internal/notification/telegram.go b/backend/internal/notification/telegram.go index 2de1c4c..ea26bc6 100644 --- a/backend/internal/notification/telegram.go +++ b/backend/internal/notification/telegram.go @@ -1,52 +1,50 @@ package notification import ( + "context" "fmt" "github.com/go-telegram/bot" - "github.com/metro-olografix/sede/internal/config" - "golang.org/x/net/context" ) -type Telegram struct { - client *bot.Bot - chatId int64 - chatThreadId int +// Dispatcher holds a single Telegram bot client. Each Send call specifies +// its own chatID / threadID so the same bot can notify multiple spaces. +type Dispatcher struct { + client *bot.Bot } -func NewTelegram(cfg config.Config) (*Telegram, error) { - if (cfg.TelegramChatId == 0) || (cfg.TelegramToken == "") { - return &Telegram{}, fmt.Errorf("telegram token or chat id not set") +// NewDispatcher builds a Dispatcher for the given bot token. An empty token +// returns an uninitialised dispatcher whose Send is a no-op, so callers can +// treat "no Telegram configured" as non-fatal without branching. +func NewDispatcher(token string) (*Dispatcher, error) { + if token == "" { + return &Dispatcher{}, fmt.Errorf("telegram token not set") } - b, err := bot.New(cfg.TelegramToken) - + b, err := bot.New(token) if err != nil { - return &Telegram{}, err + return &Dispatcher{}, err } - return &Telegram{ - client: b, - chatId: cfg.TelegramChatId, - chatThreadId: cfg.TelegramChatThreadId, - }, nil + return &Dispatcher{client: b}, nil } -func (telegram *Telegram) IsInitialized() bool { - return telegram.client != nil +func (d *Dispatcher) IsInitialized() bool { + return d != nil && d.client != nil } -func (t *Telegram) Send(msg string) error { - params := &bot.SendMessageParams{ - ChatID: t.chatId, - Text: msg, - MessageThreadID: t.chatThreadId, - } - - _, err := t.client.SendMessage(context.TODO(), params) - if err != nil { - return err +// Send posts msg to chatID / threadID. chatID == 0 is treated as "no +// Telegram target" and returns nil — lets spaces without Telegram config +// go through the toggle flow cleanly. +func (d *Dispatcher) Send(chatID int64, threadID int, msg string) error { + if !d.IsInitialized() || chatID == 0 { + return nil } - return nil + _, err := d.client.SendMessage(context.TODO(), &bot.SendMessageParams{ + ChatID: chatID, + Text: msg, + MessageThreadID: threadID, + }) + return err } diff --git a/backend/internal/notification/telegram_test.go b/backend/internal/notification/telegram_test.go index a5649b2..2bafac9 100644 --- a/backend/internal/notification/telegram_test.go +++ b/backend/internal/notification/telegram_test.go @@ -2,203 +2,43 @@ package notification import ( "testing" - - "github.com/metro-olografix/sede/internal/config" ) -func TestNewTelegram(t *testing.T) { - tests := []struct { - name string - config config.Config - expectError bool - expectNil bool - shouldInit bool - }{ - { - name: "valid telegram config", - config: config.Config{ - TelegramToken: "valid-token", - TelegramChatId: 123456789, - TelegramChatThreadId: 1, - }, - expectError: true, // Will error due to invalid token, but that's expected - expectNil: false, - shouldInit: false, - }, - { - name: "missing token", - config: config.Config{ - TelegramToken: "", - TelegramChatId: 123456789, - TelegramChatThreadId: 1, - }, - expectError: true, - expectNil: false, - shouldInit: false, - }, - { - name: "missing chat id", - config: config.Config{ - TelegramToken: "valid-token", - TelegramChatId: 0, - TelegramChatThreadId: 1, - }, - expectError: true, - expectNil: false, - shouldInit: false, - }, - { - name: "both missing", - config: config.Config{ - TelegramToken: "", - TelegramChatId: 0, - TelegramChatThreadId: 1, - }, - expectError: true, - expectNil: false, - shouldInit: false, - }, - { - name: "valid config without thread id", - config: config.Config{ - TelegramToken: "valid-token", - TelegramChatId: 123456789, - TelegramChatThreadId: 0, - }, - expectError: true, // Will error due to invalid token, but that's expected - expectNil: false, - shouldInit: false, - }, +func TestNewDispatcher_MissingToken(t *testing.T) { + d, err := NewDispatcher("") + if err == nil { + t.Fatal("expected error for empty token") } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - telegram, err := NewTelegram(tt.config) - - if tt.expectError && err == nil { - t.Errorf("Expected error but got none") - } - - if !tt.expectError && err != nil { - t.Errorf("Expected no error but got: %v", err) - } - - if tt.expectNil && telegram != nil { - t.Errorf("Expected telegram to be nil") - } - - if !tt.expectNil && telegram == nil { - t.Errorf("Expected telegram not to be nil") - } - - if telegram != nil { - if telegram.IsInitialized() != tt.shouldInit { - t.Errorf("Expected IsInitialized to be %v, got %v", tt.shouldInit, telegram.IsInitialized()) - } - - if tt.shouldInit { - if telegram.chatId != tt.config.TelegramChatId { - t.Errorf("Expected chatId %d, got %d", tt.config.TelegramChatId, telegram.chatId) - } - - if telegram.chatThreadId != tt.config.TelegramChatThreadId { - t.Errorf("Expected chatThreadId %d, got %d", tt.config.TelegramChatThreadId, telegram.chatThreadId) - } - } - } - }) + if d == nil { + t.Fatal("expected non-nil dispatcher even on error") + } + if d.IsInitialized() { + t.Error("expected IsInitialized false for empty token") } } -func TestIsInitialized(t *testing.T) { - t.Run("uninitialized telegram", func(t *testing.T) { - telegram := &Telegram{} - - if telegram.IsInitialized() { - t.Error("Expected IsInitialized to return false for uninitialized telegram") - } - }) - - t.Run("initialized telegram", func(t *testing.T) { - cfg := config.Config{ - TelegramToken: "test-token", - TelegramChatId: 123456789, - TelegramChatThreadId: 1, - } - - _, err := NewTelegram(cfg) - if err == nil { - t.Error("Expected error with invalid token") - } - - // Test with manually created struct to avoid API call - telegram := &Telegram{ - client: nil, // Simulate what happens when token is invalid - chatId: 123456789, - chatThreadId: 1, - } +func TestDispatcher_IsInitialized(t *testing.T) { + var nilD *Dispatcher + if nilD.IsInitialized() { + t.Error("nil dispatcher should not be initialized") + } - if telegram.IsInitialized() { - t.Error("Expected IsInitialized to return false when client is nil") - } - }) + empty := &Dispatcher{} + if empty.IsInitialized() { + t.Error("dispatcher with nil client should not be initialized") + } } -func TestSend(t *testing.T) { - t.Run("send with uninitialized telegram", func(t *testing.T) { - telegram := &Telegram{} - - // This will panic due to nil client, which is expected behavior - defer func() { - if r := recover(); r == nil { - t.Error("Expected panic when sending with uninitialized telegram") - } - }() - - telegram.Send("test message") - }) - - t.Run("send parameters validation", func(t *testing.T) { - // Test the structure without making actual API calls - telegram := &Telegram{ - chatId: 123456789, - chatThreadId: 1, - } - - if telegram.chatId != 123456789 { - t.Errorf("Expected chatId 123456789, got %d", telegram.chatId) - } - - if telegram.chatThreadId != 1 { - t.Errorf("Expected chatThreadId 1, got %d", telegram.chatThreadId) - } - - // Verify that IsInitialized correctly identifies uninitialized client - if telegram.IsInitialized() { - t.Error("Expected IsInitialized to return false for nil client") - } - }) +func TestDispatcher_Send_NoOpWhenUninitialized(t *testing.T) { + d := &Dispatcher{} + if err := d.Send(12345, 1, "hello"); err != nil { + t.Errorf("expected nil error from uninitialized Send, got %v", err) + } } -// TestTelegramStruct tests the basic structure -func TestTelegramStruct(t *testing.T) { - t.Run("telegram struct creation", func(t *testing.T) { - telegram := &Telegram{ - client: nil, - chatId: 123456789, - chatThreadId: 1, - } - - if telegram.chatId != 123456789 { - t.Errorf("Expected chatId 123456789, got %d", telegram.chatId) - } - - if telegram.chatThreadId != 1 { - t.Errorf("Expected chatThreadId 1, got %d", telegram.chatThreadId) - } - - if telegram.IsInitialized() { - t.Error("Expected IsInitialized to be false when client is nil") - } - }) +func TestDispatcher_Send_NoOpWhenChatIDZero(t *testing.T) { + d := &Dispatcher{} + if err := d.Send(0, 0, "hello"); err != nil { + t.Errorf("expected nil error when chatID is 0, got %v", err) + } } From f870ae163f5ba4815d9922ee8e8d9a16dbee779a Mon Sep 17 00:00:00 2001 From: Michelangelo <3934987+michelangelomo@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:25:37 +0200 Subject: [PATCH 5/9] refactor(app): load spaces from YAML and seed the DB on boot 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 --- backend/internal/app/app.go | 107 ++++++++++++- backend/internal/app/app_test.go | 222 ++++++++++++++++++++++++++ backend/internal/app/handlers.go | 15 +- backend/internal/app/handlers_test.go | 2 +- 4 files changed, 328 insertions(+), 18 deletions(-) create mode 100644 backend/internal/app/app_test.go diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 6eee1af..4ebd297 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -2,9 +2,12 @@ package app import ( "context" + "encoding/json" + "errors" "fmt" "log" "net/http" + "os" "time" "github.com/go-playground/validator/v10" @@ -18,13 +21,15 @@ import ( ) type App struct { - repo *database.Repository - config config.Config - validate *validator.Validate - limiter *rate.Limiter - apiKeyHash []byte - rateLimiter *limiter.Limiter - telegram *notification.Dispatcher + repo *database.Repository + config config.Config + validate *validator.Validate + limiter *rate.Limiter + apiKeyHash []byte + rateLimiter *limiter.Limiter + telegram *notification.Dispatcher + spaces map[string]*database.Space + defaultSpace *database.Space } const ( @@ -39,6 +44,7 @@ func NewApp(cfg config.Config) (*App, error) { config: cfg, validate: validator.New(), limiter: rate.NewLimiter(rate.Every(rateLimitDuration/rateLimitRequests), rateLimitRequests), + spaces: make(map[string]*database.Space), } if err := app.initSecurity(); err != nil { @@ -62,9 +68,96 @@ func NewApp(cfg config.Config) (*App, error) { } app.telegram = telegram + if err := app.loadAndSeedSpaces(); err != nil { + return nil, fmt.Errorf("space bootstrap failed: %w", err) + } + return app, nil } +// loadAndSeedSpaces reads spaces.yaml (or synthesises a single space from the +// legacy env vars when the file is missing), upserts every entry into the DB +// with a bcrypt-hashed API key, builds the hot lookup map, and backfills any +// legacy status rows carrying space_id = 0 onto the default space. +func (a *App) loadAndSeedSpaces() error { + slug := a.config.DefaultSpaceSlug + if slug == "" { + slug = "pescara" + } + + defs, err := config.LoadSpaces(a.config.SpacesConfigPath) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return err + } + if a.config.APIKey == "" { + return fmt.Errorf("no spaces config at %s and no legacy API_KEY to synthesise a default space", a.config.SpacesConfigPath) + } + legacy := config.LegacySpaceFromConfig(a.config) + if legacy.Slug == "" { + legacy.Slug = slug + } + defs = []config.SpaceDef{legacy} + log.Printf("spaces config not found at %s; synthesising single space %q from legacy env vars", a.config.SpacesConfigPath, legacy.Slug) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + for _, d := range defs { + hash, err := bcrypt.GenerateFromPassword([]byte(d.APIKey), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash api key for space %q: %w", d.Slug, err) + } + projectsJSON, err := json.Marshal(d.Projects) + if err != nil { + return fmt.Errorf("encode projects for space %q: %w", d.Slug, err) + } + linksJSON, err := json.Marshal(d.Links) + if err != nil { + return fmt.Errorf("encode links for space %q: %w", d.Slug, err) + } + + sp, err := a.repo.UpsertSpace(ctx, database.Space{ + Slug: d.Slug, + Name: d.Name, + Address: d.Address, + Lat: d.Lat, + Lon: d.Lon, + Timezone: d.Timezone, + LogoURL: d.LogoURL, + URL: d.URL, + ContactEmail: d.ContactEmail, + Message: d.Message, + APIKeyHash: hash, + TelegramChatID: d.TelegramChatID, + TelegramThread: d.TelegramThread, + Projects: string(projectsJSON), + Links: string(linksJSON), + }) + if err != nil { + return fmt.Errorf("upsert space %q: %w", d.Slug, err) + } + a.spaces[sp.Slug] = sp + } + + ds, ok := a.spaces[slug] + if !ok { + return fmt.Errorf("default space slug %q not found in loaded spaces", slug) + } + a.defaultSpace = ds + + n, err := a.repo.BackfillDefaultSpaceID(ctx, ds.ID) + if err != nil { + return fmt.Errorf("backfill legacy sede_statuses: %w", err) + } + if n > 0 { + log.Printf("backfilled %d legacy sede_statuses rows onto space %q (id=%d)", n, ds.Slug, ds.ID) + } + + return nil +} + func (a *App) initSecurity() error { if a.config.HashAPIKey { hash, err := bcrypt.GenerateFromPassword([]byte(a.config.APIKey), bcrypt.DefaultCost) diff --git a/backend/internal/app/app_test.go b/backend/internal/app/app_test.go new file mode 100644 index 0000000..4f38424 --- /dev/null +++ b/backend/internal/app/app_test.go @@ -0,0 +1,222 @@ +package app + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/metro-olografix/sede/internal/config" + "golang.org/x/crypto/bcrypt" +) + +func writeYAML(t *testing.T, dir, body string) string { + t.Helper() + p := filepath.Join(dir, "spaces.yaml") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + return p +} + +func baseCfg(t *testing.T, dir string) config.Config { + t.Helper() + return config.Config{ + Port: "8080", + APIKey: "test-api-key-123456", + Debug: true, + DatabasePath: filepath.Join(dir, "test.db"), + DefaultSpaceSlug: "pescara", + } +} + +func TestNewApp_LegacyUpgradePath(t *testing.T) { + dir := t.TempDir() + cfg := baseCfg(t, dir) + cfg.SpacesConfigPath = filepath.Join(dir, "does-not-exist.yaml") + + app, err := NewApp(cfg) + if err != nil { + t.Fatalf("NewApp: %v", err) + } + defer closeApp(app) + + if app.defaultSpace == nil { + t.Fatal("defaultSpace nil") + } + if app.defaultSpace.Slug != "pescara" { + t.Errorf("want slug pescara, got %q", app.defaultSpace.Slug) + } + if len(app.spaces) != 1 { + t.Errorf("want 1 space, got %d", len(app.spaces)) + } + if err := bcrypt.CompareHashAndPassword(app.defaultSpace.APIKeyHash, []byte(cfg.APIKey)); err != nil { + t.Errorf("legacy api key not hashed into default space: %v", err) + } +} + +func TestNewApp_LoadsYAMLAndSeedsDB(t *testing.T) { + dir := t.TempDir() + yaml := `spaces: + - slug: pescara + name: Metro Olografix Pescara + address: Viale Marconi 278/1 + lat: 42.454657 + lon: 14.224055 + timezone: Europe/Rome + api_key: pescara-key-1234567890 + telegram: + chat_id: 111 + thread_id: 1 + - slug: bologna + name: Metro Olografix Bologna + address: Via Test 1 + lat: 44.494887 + lon: 11.342616 + timezone: Europe/Rome + api_key: bologna-key-1234567890 + telegram: + chat_id: 222 + thread_id: 2 +` + cfg := baseCfg(t, dir) + cfg.SpacesConfigPath = writeYAML(t, dir, yaml) + + app, err := NewApp(cfg) + if err != nil { + t.Fatalf("NewApp: %v", err) + } + defer closeApp(app) + + if len(app.spaces) != 2 { + t.Fatalf("want 2 spaces, got %d", len(app.spaces)) + } + if app.defaultSpace == nil || app.defaultSpace.Slug != "pescara" { + t.Errorf("default space not pescara: %+v", app.defaultSpace) + } + bologna := app.spaces["bologna"] + if bologna == nil { + t.Fatal("bologna missing") + } + if bologna.TelegramChatID != 222 || bologna.TelegramThread != 2 { + t.Errorf("bologna telegram wrong: chat=%d thread=%d", bologna.TelegramChatID, bologna.TelegramThread) + } + if err := bcrypt.CompareHashAndPassword(bologna.APIKeyHash, []byte("bologna-key-1234567890")); err != nil { + t.Errorf("bologna hash mismatch: %v", err) + } + + spaces, err := app.repo.ListSpaces(context.Background()) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(spaces) != 2 { + t.Errorf("want 2 rows in db, got %d", len(spaces)) + } +} + +func TestNewApp_UpsertIsIdempotent(t *testing.T) { + dir := t.TempDir() + yaml1 := `spaces: + - slug: pescara + name: Pescara v1 + lat: 42.454657 + lon: 14.224055 + api_key: pescara-key-1234567890 +` + cfg := baseCfg(t, dir) + cfg.SpacesConfigPath = writeYAML(t, dir, yaml1) + + app1, err := NewApp(cfg) + if err != nil { + t.Fatalf("NewApp#1: %v", err) + } + closeApp(app1) + + yaml2 := `spaces: + - slug: pescara + name: Pescara v2 + lat: 42.454657 + lon: 14.224055 + api_key: pescara-key-rotated-01 +` + _ = writeYAML(t, dir, yaml2) + + app2, err := NewApp(cfg) + if err != nil { + t.Fatalf("NewApp#2: %v", err) + } + defer closeApp(app2) + + spaces, err := app2.repo.ListSpaces(context.Background()) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(spaces) != 1 { + t.Fatalf("want 1 row after rerun, got %d", len(spaces)) + } + if spaces[0].Name != "Pescara v2" { + t.Errorf("upsert did not update name: %q", spaces[0].Name) + } + if err := bcrypt.CompareHashAndPassword(spaces[0].APIKeyHash, []byte("pescara-key-rotated-01")); err != nil { + t.Errorf("rotated key not persisted: %v", err) + } +} + +func TestNewApp_RejectsDuplicateSlugs(t *testing.T) { + dir := t.TempDir() + yaml := `spaces: + - slug: pescara + name: A + lat: 42.0 + lon: 14.0 + api_key: key-1234567890abcdef + - slug: pescara + name: B + lat: 42.0 + lon: 14.0 + api_key: key-abcdef1234567890 +` + cfg := baseCfg(t, dir) + cfg.SpacesConfigPath = writeYAML(t, dir, yaml) + + if _, err := NewApp(cfg); err == nil { + t.Fatal("expected error on duplicate slugs") + } +} + +func TestNewApp_RejectsEmptyYAMLWithoutLegacyEnv(t *testing.T) { + dir := t.TempDir() + cfg := baseCfg(t, dir) + cfg.APIKey = "" + cfg.SpacesConfigPath = filepath.Join(dir, "missing.yaml") + + if _, err := NewApp(cfg); err == nil { + t.Fatal("expected error: no YAML and no legacy API_KEY") + } +} + +func TestNewApp_DefaultSlugMissingFromYAML(t *testing.T) { + dir := t.TempDir() + yaml := `spaces: + - slug: bologna + name: Bologna + lat: 44.49 + lon: 11.34 + api_key: bologna-key-1234567890 +` + cfg := baseCfg(t, dir) + cfg.SpacesConfigPath = writeYAML(t, dir, yaml) + + if _, err := NewApp(cfg); err == nil { + t.Fatal("expected error: default slug pescara not in YAML") + } +} + +func closeApp(app *App) { + if app == nil || app.repo == nil { + return + } + if sqlDB, err := app.repo.Db.DB(); err == nil { + sqlDB.Close() + } +} diff --git a/backend/internal/app/handlers.go b/backend/internal/app/handlers.go index 3e7266e..c350f14 100644 --- a/backend/internal/app/handlers.go +++ b/backend/internal/app/handlers.go @@ -24,11 +24,6 @@ const ( apiKeyMinLength = 16 cooldownPeriod = time.Minute contextTimeout = 30 * time.Second - - // TODO(commit 5): replace with a.defaultSpace.ID once app bootstrap loads - // the space config. Every handler is scoped per-space; until the router - // resolves the slug (commit 6), we operate against a single seeded row. - tempDefaultSpaceID = uint(1) ) type StatsResponse struct { @@ -79,7 +74,7 @@ func (a *App) getStatus(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), contextTimeout) defer cancel() - status, err := a.repo.GetLatestStatus(ctx, tempDefaultSpaceID) + status, err := a.repo.GetLatestStatus(ctx, a.defaultSpace.ID) if handleDatabaseError(c, err) { return } @@ -102,7 +97,7 @@ func (a *App) toggleStatus(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), contextTimeout) defer cancel() - currentStatus, err := a.repo.GetLatestStatus(ctx, tempDefaultSpaceID) + currentStatus, err := a.repo.GetLatestStatus(ctx, a.defaultSpace.ID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { handleDatabaseError(c, err) return @@ -126,7 +121,7 @@ func (a *App) toggleStatus(c *gin.Context) { // Toggle status newStatus := database.SedeStatus{ - SpaceID: tempDefaultSpaceID, + SpaceID: a.defaultSpace.ID, IsOpen: !currentStatus.IsOpen, Timestamp: time.Now().UTC(), } @@ -215,7 +210,7 @@ func (a *App) getStats(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), contextTimeout) defer cancel() - weeklyStats, err := a.repo.GetWeeklyStats(ctx, tempDefaultSpaceID) + weeklyStats, err := a.repo.GetWeeklyStats(ctx, a.defaultSpace.ID) if handleDatabaseError(c, err) { return } @@ -275,7 +270,7 @@ func (a *App) getSpaceAPI(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), contextTimeout) defer cancel() - status, err := a.repo.GetLatestStatus(ctx, tempDefaultSpaceID) + status, err := a.repo.GetLatestStatus(ctx, a.defaultSpace.ID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { handleDatabaseError(c, err) return diff --git a/backend/internal/app/handlers_test.go b/backend/internal/app/handlers_test.go index 568bb6b..dcff7eb 100644 --- a/backend/internal/app/handlers_test.go +++ b/backend/internal/app/handlers_test.go @@ -49,7 +49,7 @@ func setupTestApp(t *testing.T) (*App, func()) { func createTestStatus(t *testing.T, app *App, isOpen bool, timestamp time.Time) { status := database.SedeStatus{ - SpaceID: tempDefaultSpaceID, + SpaceID: app.defaultSpace.ID, IsOpen: isOpen, Timestamp: timestamp, } From c454f11a947fa800ad61a7af300f478a32246bed Mon Sep 17 00:00:00 2001 From: Michelangelo <3934987+michelangelomo@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:25:17 +0200 Subject: [PATCH 6/9] refactor(app): expose /s/:slug routes with per-space auth and SpaceAPI 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 --- backend/internal/app/app.go | 16 - backend/internal/app/handlers.go | 138 +++--- backend/internal/app/handlers_test.go | 605 +++++++++++--------------- backend/internal/app/router.go | 78 +++- 4 files changed, 384 insertions(+), 453 deletions(-) diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 4ebd297..d9350c6 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -25,7 +25,6 @@ type App struct { config config.Config validate *validator.Validate limiter *rate.Limiter - apiKeyHash []byte rateLimiter *limiter.Limiter telegram *notification.Dispatcher spaces map[string]*database.Space @@ -47,10 +46,6 @@ func NewApp(cfg config.Config) (*App, error) { spaces: make(map[string]*database.Space), } - if err := app.initSecurity(); err != nil { - return nil, err - } - repo, err := database.New(cfg) if err != nil { return nil, fmt.Errorf("database initialization failed: %w", err) @@ -158,17 +153,6 @@ func (a *App) loadAndSeedSpaces() error { return nil } -func (a *App) initSecurity() error { - if a.config.HashAPIKey { - hash, err := bcrypt.GenerateFromPassword([]byte(a.config.APIKey), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("failed to hash API key: %w", err) - } - a.apiKeyHash = hash - } - return nil -} - func (a *App) CreateServer() *http.Server { return &http.Server{ Addr: ":" + a.config.Port, diff --git a/backend/internal/app/handlers.go b/backend/internal/app/handlers.go index c350f14..86ee443 100644 --- a/backend/internal/app/handlers.go +++ b/backend/internal/app/handlers.go @@ -3,7 +3,6 @@ package app import ( "bytes" "context" - "crypto/subtle" "encoding/json" "errors" "fmt" @@ -21,9 +20,8 @@ import ( ) const ( - apiKeyMinLength = 16 - cooldownPeriod = time.Minute - contextTimeout = 30 * time.Second + cooldownPeriod = time.Minute + contextTimeout = 30 * time.Second ) type StatsResponse struct { @@ -33,7 +31,6 @@ type StatsResponse struct { DailyChanges []database.DailyStats `json:"daily_changes"` } -// Add new types for hourly breakdowns type HourlyStat struct { Hour string `json:"hour"` Probability float64 `json:"probability"` @@ -45,36 +42,36 @@ type WeeklyStatsDetailed struct { Hourly []HourlyStat `json:"hourly"` } +// authMiddleware compares X-API-KEY against the bcrypt hash stored on the +// resolved space. Every space owns its own key so one space's secret cannot +// unlock another's toggle endpoint. func (a *App) authMiddleware() gin.HandlerFunc { return func(c *gin.Context) { + sp := spaceFrom(c) + if sp == nil { + abortUnauthorized(c) + return + } apiKey := c.GetHeader("X-API-KEY") if apiKey == "" { abortUnauthorized(c) return } - - if a.config.HashAPIKey { - if err := bcrypt.CompareHashAndPassword(a.apiKeyHash, []byte(apiKey)); err != nil { - logSecurityEvent("Invalid API key attempt") - abortUnauthorized(c) - return - } - } else { - if subtle.ConstantTimeCompare([]byte(apiKey), []byte(a.config.APIKey)) != 1 { - logSecurityEvent("API key mismatch") - abortUnauthorized(c) - return - } + if err := bcrypt.CompareHashAndPassword(sp.APIKeyHash, []byte(apiKey)); err != nil { + logSecurityEvent(fmt.Sprintf("invalid API key attempt for space %q", sp.Slug)) + abortUnauthorized(c) + return } c.Next() } } func (a *App) getStatus(c *gin.Context) { + sp := spaceFrom(c) ctx, cancel := context.WithTimeout(c.Request.Context(), contextTimeout) defer cancel() - status, err := a.repo.GetLatestStatus(ctx, a.defaultSpace.ID) + status, err := a.repo.GetLatestStatus(ctx, sp.ID) if handleDatabaseError(c, err) { return } @@ -88,6 +85,8 @@ type ToggleStatusRequest struct { } func (a *App) toggleStatus(c *gin.Context) { + sp := spaceFrom(c) + var req ToggleStatusRequest if err := c.ShouldBindJSON(&req); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"}) @@ -97,7 +96,7 @@ func (a *App) toggleStatus(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), contextTimeout) defer cancel() - currentStatus, err := a.repo.GetLatestStatus(ctx, a.defaultSpace.ID) + currentStatus, err := a.repo.GetLatestStatus(ctx, sp.ID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { handleDatabaseError(c, err) return @@ -110,7 +109,6 @@ func (a *App) toggleStatus(c *gin.Context) { return } - // Get card name via POST request var cardName string if req.CardID != "" && req.Hash != "" { cardName = a.getCardName(ctx, req.CardID, req.Hash, c) @@ -119,9 +117,8 @@ func (a *App) toggleStatus(c *gin.Context) { } } - // Toggle status newStatus := database.SedeStatus{ - SpaceID: a.defaultSpace.ID, + SpaceID: sp.ID, IsOpen: !currentStatus.IsOpen, Timestamp: time.Now().UTC(), } @@ -131,10 +128,8 @@ func (a *App) toggleStatus(c *gin.Context) { return } - // Send notification - if a.telegram.IsInitialized() { + if a.telegram.IsInitialized() && sp.TelegramChatID != 0 { go func() { - var msg string emoji := "🟢" action := "aperta" if !newStatus.IsOpen { @@ -142,13 +137,14 @@ func (a *App) toggleStatus(c *gin.Context) { action = "chiusa" } + var msg string if cardName != "" { - msg = fmt.Sprintf("%s sede %s da %s", emoji, action, cardName) + msg = fmt.Sprintf("%s sede %s %s da %s", emoji, sp.Name, action, cardName) } else { - msg = fmt.Sprintf("%s sede %s", emoji, action) + msg = fmt.Sprintf("%s sede %s %s", emoji, sp.Name, action) } - if err := a.telegram.Send(a.config.TelegramChatId, a.config.TelegramChatThreadId, msg); err != nil { + if err := a.telegram.Send(sp.TelegramChatID, sp.TelegramThread, msg); err != nil { log.Printf("Failed to send Telegram notification: %v", err) } }() @@ -207,10 +203,11 @@ func (a *App) getCardName(ctx context.Context, cardID, hash string, c *gin.Conte } func (a *App) getStats(c *gin.Context) { + sp := spaceFrom(c) ctx, cancel := context.WithTimeout(c.Request.Context(), contextTimeout) defer cancel() - weeklyStats, err := a.repo.GetWeeklyStats(ctx, a.defaultSpace.ID) + weeklyStats, err := a.repo.GetWeeklyStats(ctx, sp.ID) if handleDatabaseError(c, err) { return } @@ -247,17 +244,16 @@ func handleDatabaseError(c *gin.Context, err error) bool { return true } -// Structure and handler for SpaceAPI type SpaceAPIResponse struct { - APICompatibility []string `json:"api_compatibility"` - Space string `json:"space"` - Logo string `json:"logo"` - URL string `json:"url"` - Location map[string]interface{} `json:"location"` - State SpaceAPIState `json:"state"` - Contact map[string]string `json:"contact"` - Projects []string `json:"projects"` - Links []map[string]string `json:"links"` + APICompatibility []string `json:"api_compatibility"` + Space string `json:"space"` + Logo string `json:"logo"` + URL string `json:"url"` + Location map[string]any `json:"location"` + State SpaceAPIState `json:"state"` + Contact map[string]string `json:"contact"` + Projects []string `json:"projects"` + Links []SpaceAPILink `json:"links"` } type SpaceAPIState struct { @@ -266,11 +262,18 @@ type SpaceAPIState struct { LastChange int64 `json:"lastchange"` } +type SpaceAPILink struct { + Name string `json:"name"` + Description string `json:"description"` + URL string `json:"url"` +} + func (a *App) getSpaceAPI(c *gin.Context) { + sp := spaceFrom(c) ctx, cancel := context.WithTimeout(c.Request.Context(), contextTimeout) defer cancel() - status, err := a.repo.GetLatestStatus(ctx, a.defaultSpace.ID) + status, err := a.repo.GetLatestStatus(ctx, sp.ID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { handleDatabaseError(c, err) return @@ -278,47 +281,48 @@ func (a *App) getSpaceAPI(c *gin.Context) { var isOpen bool var lastChange int64 - if !errors.Is(err, gorm.ErrRecordNotFound) { isOpen = status.IsOpen lastChange = status.Timestamp.Unix() } - spaceAPI := SpaceAPIResponse{ + var projects []string + if sp.Projects != "" { + if err := json.Unmarshal([]byte(sp.Projects), &projects); err != nil { + log.Printf("space %q: decode projects: %v", sp.Slug, err) + } + } + var links []SpaceAPILink + if sp.Links != "" { + if err := json.Unmarshal([]byte(sp.Links), &links); err != nil { + log.Printf("space %q: decode links: %v", sp.Slug, err) + } + } + + resp := SpaceAPIResponse{ APICompatibility: []string{"15"}, - Space: "Metro Olografix", - Logo: "https://olografix.org/images/metro-dark.png", - URL: "https://olografix.org", - Location: map[string]interface{}{ - "address": "Viale Marconi 278/1, 65126 Pescara, Italy", - "lat": 42.454657, - "lon": 14.224055, - "timezone": "Europe/Rome", + Space: sp.Name, + Logo: sp.LogoURL, + URL: sp.URL, + Location: map[string]any{ + "address": sp.Address, + "lat": sp.Lat, + "lon": sp.Lon, + "timezone": sp.Timezone, }, State: SpaceAPIState{ Open: isOpen, LastChange: lastChange, - Message: "We meet every Monday evening from 9:00 PM", + Message: sp.Message, }, Contact: map[string]string{ - "email": "info@olografix.org", - }, - Projects: []string{"https://github.com/Metro-Olografix"}, - Links: []map[string]string{ - { - "name": "MOCA - Metro Olografix Camp", - "description": "Il più antico campeggio hacker in Italia", - "url": "https://moca.camp", - }, - { - "name": "Wikipedia", - "description": "Metro Olografix Wikipedia page", - "url": "https://it.wikipedia.org/wiki/Metro_Olografix", - }, + "email": sp.ContactEmail, }, + Projects: projects, + Links: links, } c.Header("Access-Control-Allow-Origin", "*") c.Header("Cache-Control", "no-cache, must-revalidate") - c.JSON(http.StatusOK, spaceAPI) + c.JSON(http.StatusOK, resp) } diff --git a/backend/internal/app/handlers_test.go b/backend/internal/app/handlers_test.go index dcff7eb..667c819 100644 --- a/backend/internal/app/handlers_test.go +++ b/backend/internal/app/handlers_test.go @@ -17,465 +17,350 @@ import ( "github.com/metro-olografix/sede/internal/database" ) +const ( + pescaraKey = "pescara-key-123456" + bolognaKey = "bologna-key-123456" +) + +func twoSpaceYAML(t *testing.T) string { + t.Helper() + dir := t.TempDir() + body := `spaces: + - slug: pescara + name: Metro Olografix Pescara + address: Viale Marconi 278/1 + lat: 42.454657 + lon: 14.224055 + timezone: Europe/Rome + logo_url: https://example.com/pescara.png + url: https://pescara.example + contact: + email: pescara@example.org + message: Pescara welcomes you + api_key: ` + pescaraKey + ` + telegram: + chat_id: 1001 + thread_id: 11 + projects: + - https://github.com/Metro-Olografix + links: + - name: MOCA + description: campeggio hacker + url: https://moca.camp + - slug: bologna + name: Metro Olografix Bologna + address: Via Test 1 + lat: 44.494887 + lon: 11.342616 + timezone: Europe/Rome + logo_url: https://example.com/bologna.png + url: https://bologna.example + contact: + email: bologna@example.org + message: Bologna welcomes you + api_key: ` + bolognaKey + ` + telegram: + chat_id: 0 + thread_id: 0 +` + p := filepath.Join(dir, "spaces.yaml") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + return p +} + func setupTestApp(t *testing.T) (*App, func()) { - // Set gin to test mode + t.Helper() gin.SetMode(gin.TestMode) tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") cfg := config.Config{ - Port: "8080", - APIKey: "test-api-key-123456", - Debug: true, - DatabasePath: dbPath, - HashAPIKey: false, + Port: "8080", + APIKey: "ignored-legacy-key-1234", + Debug: true, + DatabasePath: dbPath, + SpacesConfigPath: twoSpaceYAML(t), + DefaultSpaceSlug: "pescara", } app, err := NewApp(cfg) if err != nil { - t.Fatalf("Failed to create test app: %v", err) + t.Fatalf("NewApp: %v", err) } cleanup := func() { if sqlDB, err := app.repo.Db.DB(); err == nil { sqlDB.Close() } - os.Remove(dbPath) } - return app, cleanup } -func createTestStatus(t *testing.T, app *App, isOpen bool, timestamp time.Time) { - status := database.SedeStatus{ - SpaceID: app.defaultSpace.ID, +func createTestStatusFor(t *testing.T, app *App, spaceID uint, isOpen bool, timestamp time.Time) { + t.Helper() + if err := app.repo.CreateStatus(context.Background(), database.SedeStatus{ + SpaceID: spaceID, IsOpen: isOpen, Timestamp: timestamp, + }); err != nil { + t.Fatalf("CreateStatus: %v", err) } +} - err := app.repo.CreateStatus(context.Background(), status) - if err != nil { - t.Fatalf("Failed to create test status: %v", err) +func doReq(router *gin.Engine, method, path, key string, body []byte) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + var r *http.Request + if body != nil { + r, _ = http.NewRequest(method, path, bytes.NewBuffer(body)) + r.Header.Set("Content-Type", "application/json") + } else { + r, _ = http.NewRequest(method, path, nil) + } + if key != "" { + r.Header.Set("X-API-KEY", key) } + router.ServeHTTP(w, r) + return w } -func TestGetStatus(t *testing.T) { +func TestGetStatus_PerSpace(t *testing.T) { app, cleanup := setupTestApp(t) defer cleanup() - router := app.setupRouter() - t.Run("get status when no status exists", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/status", nil) - router.ServeHTTP(w, req) - - if w.Code != http.StatusInternalServerError { - t.Errorf("Expected status code %d, got %d", http.StatusInternalServerError, w.Code) - } - }) - - t.Run("get status when status exists - open", func(t *testing.T) { - createTestStatus(t, app, true, time.Now().UTC()) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/status", nil) - router.ServeHTTP(w, req) - + pescaraID := app.spaces["pescara"].ID + bolognaID := app.spaces["bologna"].ID + createTestStatusFor(t, app, pescaraID, true, time.Now().UTC()) + createTestStatusFor(t, app, bolognaID, false, time.Now().UTC()) + + for _, tc := range []struct { + path, want string + }{ + {"/s/pescara/status", "true"}, + {"/s/bologna/status", "false"}, + {"/status", "true"}, + } { + w := doReq(router, "GET", tc.path, "", nil) if w.Code != http.StatusOK { - t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) - } - - if w.Body.String() != "true" { - t.Errorf("Expected body 'true', got '%s'", w.Body.String()) + t.Errorf("%s: code %d", tc.path, w.Code) } - }) - - t.Run("get status when status exists - closed", func(t *testing.T) { - createTestStatus(t, app, false, time.Now().UTC()) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/status", nil) - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + if w.Body.String() != tc.want { + t.Errorf("%s: body %q want %q", tc.path, w.Body.String(), tc.want) } - - if w.Body.String() != "false" { - t.Errorf("Expected body 'false', got '%s'", w.Body.String()) - } - }) + } } -func TestToggleStatus(t *testing.T) { +func TestResolveSpace_UnknownSlug(t *testing.T) { app, cleanup := setupTestApp(t) defer cleanup() - router := app.setupRouter() - t.Run("toggle without authentication", func(t *testing.T) { - reqBody := ToggleStatusRequest{ - CardID: "test-card", - Hash: "test-hash", - } - jsonBody, _ := json.Marshal(reqBody) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/toggle", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - if w.Code != http.StatusUnauthorized { - t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, w.Code) - } - }) - - t.Run("toggle with invalid API key", func(t *testing.T) { - reqBody := ToggleStatusRequest{ - CardID: "test-card", - Hash: "test-hash", - } - jsonBody, _ := json.Marshal(reqBody) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/toggle", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-KEY", "invalid-key") - router.ServeHTTP(w, req) - - if w.Code != http.StatusUnauthorized { - t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, w.Code) - } - }) - - t.Run("toggle with valid API key - no existing status", func(t *testing.T) { - reqBody := ToggleStatusRequest{} - jsonBody, _ := json.Marshal(reqBody) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/toggle", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-KEY", "test-api-key-123456") - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) - } - - responseBody := w.Body.String() - if responseBody != "true" { - t.Errorf("Expected status to be toggled to open (true), got: %s", responseBody) - } - }) - - t.Run("toggle with cooldown period active", func(t *testing.T) { - // Create a recent status (within cooldown period) - createTestStatus(t, app, true, time.Now().UTC().Add(-30*time.Second)) - - reqBody := ToggleStatusRequest{} - jsonBody, _ := json.Marshal(reqBody) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/toggle", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-KEY", "test-api-key-123456") - router.ServeHTTP(w, req) - - if w.Code != http.StatusTooManyRequests { - t.Errorf("Expected status code %d, got %d", http.StatusTooManyRequests, w.Code) - } - }) - - t.Run("toggle with invalid JSON", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/toggle", strings.NewReader("invalid json")) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-KEY", "test-api-key-123456") - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("Expected status code %d, got %d", http.StatusBadRequest, w.Code) - } - }) + w := doReq(router, "GET", "/s/nope/status", "", nil) + if w.Code != http.StatusNotFound { + t.Errorf("want 404, got %d", w.Code) + } } -func TestGetStats(t *testing.T) { +func TestLegacyAlias_MatchesDefault(t *testing.T) { app, cleanup := setupTestApp(t) defer cleanup() - router := app.setupRouter() - t.Run("get stats with no data", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/stats", nil) - router.ServeHTTP(w, req) + createTestStatusFor(t, app, app.defaultSpace.ID, true, time.Now().UTC()) - if w.Code != http.StatusOK { - t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + for _, p := range []string{"/status", "/stats", "/spaceapi.json"} { + legacy := doReq(router, "GET", p, "", nil) + namespaced := doReq(router, "GET", "/s/pescara"+p, "", nil) + if legacy.Code != namespaced.Code { + t.Errorf("%s: legacy %d vs namespaced %d", p, legacy.Code, namespaced.Code) } - - var response []WeeklyStatsDetailed - err := json.Unmarshal(w.Body.Bytes(), &response) - if err != nil { - t.Fatalf("Failed to unmarshal response: %v", err) - } - - if len(response) != 0 { - t.Errorf("Expected empty response, got %d items", len(response)) - } - }) - - t.Run("get stats with data", func(t *testing.T) { - // Create some test data - now := time.Now().UTC() - createTestStatus(t, app, true, now.Add(-24*time.Hour)) - createTestStatus(t, app, false, now.Add(-12*time.Hour)) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/stats", nil) - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + if legacy.Body.String() != namespaced.Body.String() { + t.Errorf("%s: body mismatch\nlegacy: %s\nnamespaced: %s", p, legacy.Body.String(), namespaced.Body.String()) } + } +} - var response []WeeklyStatsDetailed - err := json.Unmarshal(w.Body.Bytes(), &response) - if err != nil { - t.Fatalf("Failed to unmarshal response: %v", err) - } +func TestAuth_KeysAreNotInterchangeable(t *testing.T) { + app, cleanup := setupTestApp(t) + defer cleanup() + router := app.setupRouter() - // Should have some data now - for _, stat := range response { - if stat.Day == "" { - t.Error("Expected day to be set") + body, _ := json.Marshal(ToggleStatusRequest{}) + + for _, tc := range []struct { + name, path, key string + wantCode int + }{ + {"pescara correct", "/s/pescara/toggle", pescaraKey, http.StatusOK}, + {"pescara wrong (bologna key)", "/s/pescara/toggle", bolognaKey, http.StatusUnauthorized}, + {"bologna correct", "/s/bologna/toggle", bolognaKey, http.StatusOK}, + {"bologna wrong (pescara key)", "/s/bologna/toggle", pescaraKey, http.StatusUnauthorized}, + {"missing key", "/s/pescara/toggle", "", http.StatusUnauthorized}, + {"legacy with default key", "/toggle", pescaraKey, http.StatusTooManyRequests}, // cooldown from earlier pescara toggle + } { + t.Run(tc.name, func(t *testing.T) { + w := doReq(router, "POST", tc.path, tc.key, body) + if w.Code != tc.wantCode { + t.Errorf("code %d want %d, body=%s", w.Code, tc.wantCode, w.Body.String()) } - if stat.DailyProbability < 0 || stat.DailyProbability > 1 { - t.Errorf("Expected daily probability between 0 and 1, got %f", stat.DailyProbability) - } - } - }) + }) + } } -func TestGetSpaceAPI(t *testing.T) { +func TestToggleStatus_FlipsOnlyTargetSpace(t *testing.T) { app, cleanup := setupTestApp(t) defer cleanup() - router := app.setupRouter() - t.Run("get spaceapi with no status", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/spaceapi.json", nil) - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) - } - - var response SpaceAPIResponse - err := json.Unmarshal(w.Body.Bytes(), &response) - if err != nil { - t.Fatalf("Failed to unmarshal response: %v", err) - } - - // Verify structure - if response.Space != "Metro Olografix" { - t.Errorf("Expected space name 'Metro Olografix', got '%s'", response.Space) - } - - if response.State.Open != false { - t.Errorf("Expected open state false, got %v", response.State.Open) - } - - if response.State.LastChange != 0 { - t.Errorf("Expected last change 0, got %d", response.State.LastChange) - } - - // Verify CORS headers - if w.Header().Get("Access-Control-Allow-Origin") != "*" { - t.Error("Expected CORS header to allow all origins") - } - - if w.Header().Get("Cache-Control") != "no-cache, must-revalidate" { - t.Error("Expected no-cache header") - } - }) - - t.Run("get spaceapi with status", func(t *testing.T) { - testTime := time.Now().UTC() - createTestStatus(t, app, true, testTime) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/spaceapi.json", nil) - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) - } - - var response SpaceAPIResponse - err := json.Unmarshal(w.Body.Bytes(), &response) - if err != nil { - t.Fatalf("Failed to unmarshal response: %v", err) - } + body, _ := json.Marshal(ToggleStatusRequest{}) + w := doReq(router, "POST", "/s/pescara/toggle", pescaraKey, body) + if w.Code != http.StatusOK { + t.Fatalf("pescara toggle failed: %d %s", w.Code, w.Body.String()) + } - if response.State.Open != true { - t.Errorf("Expected open state true, got %v", response.State.Open) - } + pescara, err := app.repo.GetLatestStatus(context.Background(), app.spaces["pescara"].ID) + if err != nil { + t.Fatalf("get pescara: %v", err) + } + if !pescara.IsOpen { + t.Error("pescara should be open after toggle") + } - if response.State.LastChange != testTime.Unix() { - t.Errorf("Expected last change %d, got %d", testTime.Unix(), response.State.LastChange) - } - }) + if _, err := app.repo.GetLatestStatus(context.Background(), app.spaces["bologna"].ID); err == nil { + t.Error("bologna should have no rows after pescara-only toggle") + } } -func TestAuthMiddleware(t *testing.T) { +func TestToggleStatus_CooldownIsPerSpace(t *testing.T) { app, cleanup := setupTestApp(t) defer cleanup() + router := app.setupRouter() - t.Run("no api key", func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/test", nil) + body, _ := json.Marshal(ToggleStatusRequest{}) - middleware := app.authMiddleware() - middleware(c) + if w := doReq(router, "POST", "/s/pescara/toggle", pescaraKey, body); w.Code != http.StatusOK { + t.Fatalf("first pescara toggle: %d", w.Code) + } + if w := doReq(router, "POST", "/s/pescara/toggle", pescaraKey, body); w.Code != http.StatusTooManyRequests { + t.Errorf("second pescara toggle should 429, got %d", w.Code) + } + if w := doReq(router, "POST", "/s/bologna/toggle", bolognaKey, body); w.Code != http.StatusOK { + t.Errorf("bologna toggle should not be rate-limited by pescara: %d", w.Code) + } +} - if w.Code != http.StatusUnauthorized { - t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, w.Code) - } - }) +func TestToggleStatus_InvalidJSON(t *testing.T) { + app, cleanup := setupTestApp(t) + defer cleanup() + router := app.setupRouter() - t.Run("valid api key", func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/test", nil) - c.Request.Header.Set("X-API-KEY", "test-api-key-123456") + w := httptest.NewRecorder() + r, _ := http.NewRequest("POST", "/s/pescara/toggle", strings.NewReader("invalid")) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-API-KEY", pescaraKey) + router.ServeHTTP(w, r) - middleware := app.authMiddleware() - middleware(c) + if w.Code != http.StatusBadRequest { + t.Errorf("want 400, got %d", w.Code) + } +} - // Should not abort (no status set) - if c.IsAborted() { - t.Error("Expected request not to be aborted with valid API key") - } - }) +func TestGetSpaceAPI_PerSpaceMetadata(t *testing.T) { + app, cleanup := setupTestApp(t) + defer cleanup() + router := app.setupRouter() - t.Run("invalid api key", func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/test", nil) - c.Request.Header.Set("X-API-KEY", "invalid-key") + testTime := time.Now().UTC().Truncate(time.Second) + createTestStatusFor(t, app, app.spaces["pescara"].ID, true, testTime) - middleware := app.authMiddleware() - middleware(c) + w := doReq(router, "GET", "/s/pescara/spaceapi.json", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("code %d", w.Code) + } + var resp SpaceAPIResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Space != "Metro Olografix Pescara" { + t.Errorf("space: %q", resp.Space) + } + if resp.Location["address"] != "Viale Marconi 278/1" { + t.Errorf("address: %v", resp.Location["address"]) + } + if !resp.State.Open { + t.Error("expected open state") + } + if resp.State.LastChange != testTime.Unix() { + t.Errorf("lastchange: got %d want %d", resp.State.LastChange, testTime.Unix()) + } + if resp.Contact["email"] != "pescara@example.org" { + t.Errorf("contact: %v", resp.Contact) + } + if len(resp.Links) != 1 || resp.Links[0].URL != "https://moca.camp" { + t.Errorf("links: %+v", resp.Links) + } - if w.Code != http.StatusUnauthorized { - t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, w.Code) - } - }) + w2 := doReq(router, "GET", "/s/bologna/spaceapi.json", "", nil) + var resp2 SpaceAPIResponse + _ = json.Unmarshal(w2.Body.Bytes(), &resp2) + if resp2.Space != "Metro Olografix Bologna" { + t.Errorf("bologna space: %q", resp2.Space) + } + if resp2.State.Open { + t.Error("bologna should not report open (no rows)") + } + if resp2.State.LastChange != 0 { + t.Errorf("bologna lastchange: %d", resp2.State.LastChange) + } } -func TestHashedAPIKey(t *testing.T) { - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.db") +func TestGetStats_EmptySpace(t *testing.T) { + app, cleanup := setupTestApp(t) + defer cleanup() + router := app.setupRouter() - cfg := config.Config{ - Port: "8080", - APIKey: "test-api-key-123456", - Debug: true, - DatabasePath: dbPath, - HashAPIKey: true, // Enable hashing + w := doReq(router, "GET", "/s/bologna/stats", "", nil) + if w.Code != http.StatusOK { + t.Errorf("code %d", w.Code) } - - app, err := NewApp(cfg) - if err != nil { - t.Fatalf("Failed to create test app: %v", err) + var resp []WeeklyStatsDetailed + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(resp) != 0 { + t.Errorf("want empty, got %d", len(resp)) } - defer func() { - if sqlDB, err := app.repo.Db.DB(); err == nil { - sqlDB.Close() - } - }() - - t.Run("hashed api key authentication", func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/test", nil) - c.Request.Header.Set("X-API-KEY", "test-api-key-123456") - - middleware := app.authMiddleware() - middleware(c) - - // Should not abort with correct key - if c.IsAborted() { - t.Error("Expected request not to be aborted with valid hashed API key") - } - }) - - t.Run("hashed api key with wrong key", func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/test", nil) - c.Request.Header.Set("X-API-KEY", "wrong-key") - - middleware := app.authMiddleware() - middleware(c) - - if w.Code != http.StatusUnauthorized { - t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, w.Code) - } - }) } func TestUtilityFunctions(t *testing.T) { t.Run("abortUnauthorized", func(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - abortUnauthorized(c) - if w.Code != http.StatusUnauthorized { - t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, w.Code) - } - - var response map[string]string - json.Unmarshal(w.Body.Bytes(), &response) - - if response["error"] != "Invalid or missing API key" { - t.Errorf("Expected error message, got '%s'", response["error"]) + t.Errorf("code %d", w.Code) } }) - t.Run("handleDatabaseError with nil error", func(t *testing.T) { + t.Run("handleDatabaseError nil", func(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - - result := handleDatabaseError(c, nil) - - if result != false { - t.Error("Expected handleDatabaseError to return false for nil error") - } - - if c.IsAborted() { - t.Error("Expected request not to be aborted for nil error") + if handleDatabaseError(c, nil) { + t.Error("expected false for nil") } }) - t.Run("handleDatabaseError with context deadline exceeded", func(t *testing.T) { + t.Run("handleDatabaseError deadline", func(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - - result := handleDatabaseError(c, context.DeadlineExceeded) - - if result != true { - t.Error("Expected handleDatabaseError to return true for error") + if !handleDatabaseError(c, context.DeadlineExceeded) { + t.Error("expected true") } - if w.Code != http.StatusGatewayTimeout { - t.Errorf("Expected status code %d, got %d", http.StatusGatewayTimeout, w.Code) + t.Errorf("code %d", w.Code) } }) } diff --git a/backend/internal/app/router.go b/backend/internal/app/router.go index 1443326..e69b2d3 100644 --- a/backend/internal/app/router.go +++ b/backend/internal/app/router.go @@ -1,14 +1,19 @@ package app import ( + "errors" "net/http" "time" "github.com/gin-contrib/cors" "github.com/gin-contrib/secure" "github.com/gin-gonic/gin" + "github.com/metro-olografix/sede/internal/database" + "gorm.io/gorm" ) +const spaceContextKey = "space" + func (a *App) setupRouter() *gin.Engine { if !a.config.Debug { gin.SetMode(gin.ReleaseMode) @@ -16,7 +21,6 @@ func (a *App) setupRouter() *gin.Engine { r := gin.New() - // CORS Configuration corsConfig := cors.Config{ AllowMethods: []string{"GET", "POST", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept", "X-API-KEY", "Authorization"}, @@ -31,7 +35,6 @@ func (a *App) setupRouter() *gin.Engine { corsConfig.AllowAllOrigins = true } - // Middleware chain r.Use( gin.Recovery(), a.secureMiddleware(), @@ -43,25 +46,80 @@ func (a *App) setupRouter() *gin.Engine { r.Use(gin.Logger()) } - // Public routes - r.GET("/status", a.getStatus) - r.GET("/stats", a.getStats) - r.GET("/spaceapi.json", a.getSpaceAPI) + // Legacy bare routes — resolve to the default space so existing clients + // (ESP32 button, MCP server, deployed integrations) keep working. + r.GET("/status", a.resolveDefaultSpace(), a.getStatus) + r.GET("/stats", a.resolveDefaultSpace(), a.getStats) + r.GET("/spaceapi.json", a.resolveDefaultSpace(), a.getSpaceAPI) + r.POST("/toggle", a.resolveDefaultSpace(), a.authMiddleware(), a.toggleStatus) - // Authenticated routes - secured := r.Group("/") - secured.Use(a.authMiddleware()) + sg := r.Group("/s/:slug", a.resolveSpaceFromPath()) { - secured.POST("/toggle", a.toggleStatus) + sg.GET("/status", a.getStatus) + sg.GET("/stats", a.getStats) + sg.GET("/spaceapi.json", a.getSpaceAPI) + sg.POST("/toggle", a.authMiddleware(), a.toggleStatus) } if a.config.Debug { r.StaticFS("/ui", http.Dir("./ui")) + uiHandler := http.StripPrefix("/ui", http.FileServer(http.Dir("./ui"))) + r.GET("/s/:slug/ui/*filepath", a.resolveSpaceFromPath(), func(c *gin.Context) { + c.Request.URL.Path = "/ui" + c.Param("filepath") + uiHandler.ServeHTTP(c.Writer, c.Request) + }) } return r } +func (a *App) resolveDefaultSpace() gin.HandlerFunc { + return func(c *gin.Context) { + if a.defaultSpace == nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "default space not configured"}) + return + } + c.Set(spaceContextKey, a.defaultSpace) + c.Next() + } +} + +// resolveSpaceFromPath resolves :slug via the in-memory hot map; the DB is a +// fallback only for rows that arrive after boot (future admin API). A missing +// slug is a flat 404 — we don't distinguish typo vs. truly-absent so the +// endpoint can't be used to enumerate configured spaces. +func (a *App) resolveSpaceFromPath() gin.HandlerFunc { + return func(c *gin.Context) { + slug := c.Param("slug") + if sp, ok := a.spaces[slug]; ok { + c.Set(spaceContextKey, sp) + c.Next() + return + } + sp, err := a.repo.GetSpaceBySlug(c.Request.Context(), slug) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "space not found"}) + return + } + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "space lookup failed"}) + return + } + a.spaces[sp.Slug] = sp + c.Set(spaceContextKey, sp) + c.Next() + } +} + +func spaceFrom(c *gin.Context) *database.Space { + v, ok := c.Get(spaceContextKey) + if !ok { + return nil + } + sp, _ := v.(*database.Space) + return sp +} + func (a *App) secureMiddleware() gin.HandlerFunc { return secure.New(secure.Config{ STSSeconds: 31536000, From f270fb4fcdca104cf18f38ecfe03aa1873d17789 Mon Sep 17 00:00:00 2001 From: Michelangelo <3934987+michelangelomo@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:27:15 +0200 Subject: [PATCH 7/9] docs(multi-space): document /s/{slug} routes, example config, UI base 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 --- .gitignore | 5 ++- README.md | 24 +++++++++---- backend/deploy/docker-compose.yaml | 1 + backend/deploy/spaces.example.yaml | 54 ++++++++++++++++++++++++++++++ backend/ui/index.html | 7 +++- 5 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 backend/deploy/spaces.example.yaml diff --git a/.gitignore b/.gitignore index b2c08c8..74ee96f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ backend/database/ .env **/*.env.unenc # age encryption key -backend/deploy/*-key.txt \ No newline at end of file +backend/deploy/*-key.txt +# real spaces config (keep only the example in the repo) +backend/deploy/config/spaces.yaml +backend/config/spaces.yaml \ No newline at end of file diff --git a/README.md b/README.md index b3df182..70d167e 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,24 @@ ora, aprire [http://localhost:6052](http://localhost:6052) sul proprio browser e ### backend -il backend è un semplice web server in Go, espone: - - - `GET /status`: risponde `true` o `false` - - `POST /toggle`: cambia lo stato della sede e ritorna il nuovo stato - - `GET /stats`: ritorna le statistiche orario con probabilità di trovare la sede aperta o chiusa in base allo storico - - `GET /ui`: attiva solo se `DEBUG=true` +il backend è un semplice web server in Go, multi-tenant: una sola +istanza serve N sedi tramite prefisso di path `/s/{slug}/...`. Le rotte +"bare" restano come alias della sede di default (`DEFAULT_SPACE_SLUG`, +`pescara`) per compatibilità con i client già deployati (pulsante +ESP32, MCP server). + +Endpoint per ciascuna sede: + + - `GET /s/{slug}/status` (alias: `GET /status`): risponde `true` o `false` + - `POST /s/{slug}/toggle` (alias: `POST /toggle`): cambia lo stato. Richiede `X-API-KEY` della sede. + - `GET /s/{slug}/stats` (alias: `GET /stats`): statistiche orarie + - `GET /s/{slug}/spaceapi.json` (alias: `GET /spaceapi.json`): metadati SpaceAPI v15 + - `GET /s/{slug}/ui` (alias: `GET /ui`): heatmap, attiva solo se `DEBUG=true` + +Le sedi sono dichiarate in `config/spaces.yaml` (vedi +`backend/deploy/spaces.example.yaml`): slug, nome, coordinate, API key +(supporta `$VAR`), chat/thread Telegram, metadati SpaceAPI. Il file è +caricato al boot e fa upsert sulle righe del DB per slug. per lanciarlo in locale: diff --git a/backend/deploy/docker-compose.yaml b/backend/deploy/docker-compose.yaml index 45c4cd3..678a79c 100644 --- a/backend/deploy/docker-compose.yaml +++ b/backend/deploy/docker-compose.yaml @@ -7,6 +7,7 @@ services: pull_policy: always volumes: - ./database:/app/database + - ./config:/app/config:ro networks: - "internal-apps" env_file: backend.env diff --git a/backend/deploy/spaces.example.yaml b/backend/deploy/spaces.example.yaml new file mode 100644 index 0000000..c0e20fc --- /dev/null +++ b/backend/deploy/spaces.example.yaml @@ -0,0 +1,54 @@ +# spaces.yaml — one entry per physical space served by this instance. +# +# Fields with $VAR references are resolved from the environment at boot; +# a missing env var fails startup so secrets can't silently be empty. +# Entries are upserted into the DB keyed on slug: change a field and +# restart to roll it out. Deleting an entry leaves its DB row alone +# (and its historical sede_statuses) — safer than implicit cascades. +# +# The bare legacy routes (/status, /toggle, /stats, /spaceapi.json, /ui) +# resolve to DEFAULT_SPACE_SLUG. Point new clients at /s//... . + +spaces: + - slug: pescara + name: Metro Olografix Pescara + address: Viale Marconi 278/1, 65126 Pescara, Italy + lat: 42.454657 + lon: 14.224055 + timezone: Europe/Rome + logo_url: https://olografix.org/images/metro-dark.png + url: https://olografix.org + contact: + email: info@olografix.org + message: We meet every Monday evening from 9:00 PM + api_key: $PESCARA_API_KEY + telegram: + chat_id: -1001234567890 + thread_id: 1 + projects: + - https://github.com/Metro-Olografix + links: + - name: MOCA - Metro Olografix Camp + description: Il più antico campeggio hacker in Italia + url: https://moca.camp + - name: Wikipedia + description: Metro Olografix Wikipedia page + url: https://it.wikipedia.org/wiki/Metro_Olografix + + - slug: bologna + name: Metro Olografix Bologna + address: TBD, Bologna, Italy + lat: 44.494887 + lon: 11.342616 + timezone: Europe/Rome + logo_url: https://olografix.org/images/metro-dark.png + url: https://olografix.org + contact: + email: bologna@olografix.org + message: Opening soon + api_key: $BOLOGNA_API_KEY + telegram: + chat_id: -1009876543210 + thread_id: 1 + projects: [] + links: [] diff --git a/backend/ui/index.html b/backend/ui/index.html index 791351d..cf896a1 100644 --- a/backend/ui/index.html +++ b/backend/ui/index.html @@ -77,9 +77,14 @@

HQ Opening Probability