diff --git a/cmd/server/db.go b/cmd/server/db.go index aadcebd9..a2095949 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1153,8 +1153,48 @@ func (db *DB) GetTraces(hash string) ([]map[string]interface{}, error) { // Queries transmissions directly (not a VIEW) to avoid observation-level // duplicates that could cause stale lastMessage when an older message has // a later re-observation timestamp. -func (db *DB) GetChannels() ([]map[string]interface{}, error) { - rows, err := db.conn.Query(`SELECT decoded_json, first_seen FROM transmissions WHERE payload_type = 5 ORDER BY first_seen ASC`) +func (db *DB) GetChannels(region ...string) ([]map[string]interface{}, error) { + regionParam := "" + if len(region) > 0 { + regionParam = region[0] + } + regionCodes := normalizeRegionCodes(regionParam) + + var querySQL string + args := make([]interface{}, 0, len(regionCodes)) + + if len(regionCodes) > 0 { + placeholders := make([]string, len(regionCodes)) + for i, code := range regionCodes { + placeholders[i] = "?" + args = append(args, code) + } + regionPlaceholder := strings.Join(placeholders, ",") + if db.isV3 { + querySQL = fmt.Sprintf(`SELECT DISTINCT t.decoded_json, t.first_seen + FROM transmissions t + JOIN observations o ON o.transmission_id = t.id + LEFT JOIN observers obs ON obs.rowid = o.observer_idx + WHERE t.payload_type = 5 + AND obs.rowid IS NOT NULL AND UPPER(TRIM(obs.iata)) IN (%s) + ORDER BY t.first_seen ASC`, regionPlaceholder) + } else { + querySQL = fmt.Sprintf(`SELECT DISTINCT t.decoded_json, t.first_seen + FROM transmissions t + JOIN observations o ON o.transmission_id = t.id + WHERE t.payload_type = 5 + AND EXISTS ( + SELECT 1 FROM observers obs + WHERE obs.id = o.observer_id + AND UPPER(TRIM(obs.iata)) IN (%s) + ) + ORDER BY t.first_seen ASC`, regionPlaceholder) + } + } else { + querySQL = `SELECT decoded_json, first_seen FROM transmissions WHERE payload_type = 5 ORDER BY first_seen ASC` + } + + rows, err := db.conn.Query(querySQL, args...) if err != nil { return nil, err } diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index c72809ea..2783cdb0 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -1504,6 +1504,61 @@ func TestGetChannelsStaleMessage(t *testing.T) { } } +func TestGetChannelsRegionFiltering(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer1', 'SJC')`) + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer2', 'SFO')`) + + // Channel message seen only in SJC + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('AA', 'hash1', '2026-01-15T10:00:00Z', 1, 5, + '{"type":"CHAN","channel":"#sjc-only","text":"Alice: Hello SJC","sender":"Alice"}')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, timestamp) + VALUES (1, 1, 12.0, -90, 1736935200)`) + + // Channel message seen only in SFO + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('BB', 'hash2', '2026-01-15T10:05:00Z', 1, 5, + '{"type":"CHAN","channel":"#sfo-only","text":"Bob: Hello SFO","sender":"Bob"}')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, timestamp) + VALUES (2, 2, 14.0, -88, 1736935500)`) + + // No region filter — both channels + all, err := db.GetChannels() + if err != nil { + t.Fatal(err) + } + if len(all) != 2 { + t.Fatalf("expected 2 channels without region filter, got %d", len(all)) + } + + // Filter SJC — only #sjc-only + sjc, err := db.GetChannels("SJC") + if err != nil { + t.Fatal(err) + } + if len(sjc) != 1 { + t.Fatalf("expected 1 channel for SJC, got %d", len(sjc)) + } + if sjc[0]["name"] != "#sjc-only" { + t.Errorf("expected channel '#sjc-only', got %q", sjc[0]["name"]) + } + + // Filter SFO — only #sfo-only + sfo, err := db.GetChannels("SFO") + if err != nil { + t.Fatal(err) + } + if len(sfo) != 1 { + t.Fatalf("expected 1 channel for SFO, got %d", len(sfo)) + } + if sfo[0]["name"] != "#sfo-only" { + t.Errorf("expected channel '#sfo-only', got %q", sfo[0]["name"]) + } +} + func TestNodeTelemetryFields(t *testing.T) { db := setupTestDB(t) defer db.Close() diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 7985622a..f194bfbd 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1655,18 +1655,23 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { + region := r.URL.Query().Get("region") + // Prefer DB for full history (in-memory store has limited retention) + if s.db != nil { + channels, err := s.db.GetChannels(region) + if err != nil { + writeError(w, 500, err.Error()) + return + } + writeJSON(w, ChannelListResponse{Channels: channels}) + return + } if s.store != nil { - region := r.URL.Query().Get("region") channels := s.store.GetChannels(region) writeJSON(w, ChannelListResponse{Channels: channels}) return } - channels, err := s.db.GetChannels() - if err != nil { - writeError(w, 500, err.Error()) - return - } - writeJSON(w, ChannelListResponse{Channels: channels}) + writeJSON(w, ChannelListResponse{Channels: []map[string]interface{}{}}) } func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) { @@ -1674,17 +1679,22 @@ func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) { limit := queryInt(r, "limit", 100) offset := queryInt(r, "offset", 0) region := r.URL.Query().Get("region") + // Prefer DB for full history (in-memory store has limited retention) + if s.db != nil { + messages, total, err := s.db.GetChannelMessages(hash, limit, offset, region) + if err != nil { + writeError(w, 500, err.Error()) + return + } + writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total}) + return + } if s.store != nil { messages, total := s.store.GetChannelMessages(hash, limit, offset, region) writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total}) return } - messages, total, err := s.db.GetChannelMessages(hash, limit, offset, region) - if err != nil { - writeError(w, 500, err.Error()) - return - } - writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total}) + writeJSON(w, ChannelMessagesResponse{Messages: []map[string]interface{}{}, Total: 0}) } func (s *Server) handleObservers(w http.ResponseWriter, r *http.Request) {