From a49c64b4b504b96c419b4e316dd4331619450477 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Wed, 3 Jun 2026 14:37:18 -0700 Subject: [PATCH] add region filtering to packets --- db/queries/queries.sql | 11 +++- db/sqlc/queries.sql.go | 39 +++++++++++++- db/store.go | 37 ++++++++++++- docs/docs.go | 92 +++++++------------------------- docs/swagger.json | 92 +++++++------------------------- docs/swagger.yaml | 63 +++++----------------- internal/api/handlers/packets.go | 19 +++++-- internal/api/handlers/regions.go | 46 ++++++++++++++++ internal/api/reader.go | 7 ++- 9 files changed, 202 insertions(+), 204 deletions(-) diff --git a/db/queries/queries.sql b/db/queries/queries.sql index c7ca34c..109281a 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -224,7 +224,11 @@ LEFT JOIN observers o ON o.id = po.observer_id WHERE ($1::smallint = -1 OR p.payload_type = $1::smallint) AND ($2::smallint = -1 OR p.route_type = $2::smallint) - AND ($3 = '' OR po.iata ILIKE $3) + AND ($3::text = '' OR EXISTS ( + SELECT 1 FROM packet_observations po3 + WHERE po3.packet_hash = p.packet_hash + AND po3.iata = ANY(string_to_array($3::text, ',')) + )) AND ($4::timestamptz IS NULL OR p.first_heard_at >= $4) AND ($5::timestamptz IS NULL OR p.first_heard_at <= $5) AND ($6::timestamptz IS NULL OR p.last_heard_at < $6) @@ -598,6 +602,11 @@ SELECT id, slug, name, description, center_lat, center_lng, zoom_level FROM regions WHERE id = $1; +-- name: GetRegionBySlug :one +SELECT id, slug, name, description, center_lat, center_lng, zoom_level +FROM regions +WHERE slug = $1; + -- name: GetRegionIATAs :many SELECT iata FROM region_iatas WHERE region_id = $1 diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index 792942e..5efd946 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -661,6 +661,37 @@ func (q *Queries) GetRegion(ctx context.Context, id int32) (GetRegionRow, error) return i, err } +const getRegionBySlug = `-- name: GetRegionBySlug :one +SELECT id, slug, name, description, center_lat, center_lng, zoom_level +FROM regions +WHERE slug = $1 +` + +type GetRegionBySlugRow struct { + ID int32 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description *string `json:"description"` + CenterLat *float64 `json:"center_lat"` + CenterLng *float64 `json:"center_lng"` + ZoomLevel *int32 `json:"zoom_level"` +} + +func (q *Queries) GetRegionBySlug(ctx context.Context, slug string) (GetRegionBySlugRow, error) { + row := q.db.QueryRow(ctx, getRegionBySlug, slug) + var i GetRegionBySlugRow + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.Description, + &i.CenterLat, + &i.CenterLng, + &i.ZoomLevel, + ) + return i, err +} + const getRegionIATAs = `-- name: GetRegionIATAs :many SELECT iata FROM region_iatas WHERE region_id = $1 @@ -1817,7 +1848,11 @@ LEFT JOIN observers o ON o.id = po.observer_id WHERE ($1::smallint = -1 OR p.payload_type = $1::smallint) AND ($2::smallint = -1 OR p.route_type = $2::smallint) - AND ($3 = '' OR po.iata ILIKE $3) + AND ($3::text = '' OR EXISTS ( + SELECT 1 FROM packet_observations po3 + WHERE po3.packet_hash = p.packet_hash + AND po3.iata = ANY(string_to_array($3::text, ',')) + )) AND ($4::timestamptz IS NULL OR p.first_heard_at >= $4) AND ($5::timestamptz IS NULL OR p.first_heard_at <= $5) AND ($6::timestamptz IS NULL OR p.last_heard_at < $6) @@ -1828,7 +1863,7 @@ LIMIT $7 type ListPacketsParams struct { Column1 int16 `json:"column_1"` Column2 int16 `json:"column_2"` - Column3 interface{} `json:"column_3"` + Column3 string `json:"column_3"` Column4 pgtype.Timestamptz `json:"column_4"` Column5 pgtype.Timestamptz `json:"column_5"` Column6 pgtype.Timestamptz `json:"column_6"` diff --git a/db/store.go b/db/store.go index 6b4a46d..e39d8fd 100644 --- a/db/store.go +++ b/db/store.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "log" + "strings" "time" sqlc "github.com/MeshCore-Tower/tower-server/db/sqlc" @@ -364,6 +365,37 @@ func (s *Store) GetRegion(ctx context.Context, regionID int32) (*api.Region, err return &result, nil } +// GetRegionBySlug returns full detail for a single region including its associated IATA codes. +// Returns nil, pgx.ErrNoRows if the region is not found. +func (s *Store) GetRegionBySlug(ctx context.Context, slug string) (*api.Region, error) { + region, err := s.q.GetRegionBySlug(ctx, slug) + if err != nil { + return nil, err + } + result := api.Region{ + RegionSummary: api.RegionSummary{ + ID: int(region.ID), + Slug: region.Slug, + Name: region.Name, + }, + Description: region.Description, + CenterLat: region.CenterLat, + CenterLng: region.CenterLng, + } + var zoomLevel *int + if region.ZoomLevel != nil { + z := int(*region.ZoomLevel) + zoomLevel = &z + } + result.ZoomLevel = zoomLevel + iatas, err := s.q.GetRegionIATAs(ctx, region.ID) + if err != nil { + return nil, err + } + result.IATAs = iatas + return &result, nil +} + // UpsertIATADetails updates an existing iata_codes row with display name and coordinates. // The row must already exist (auto-created on first packet arrival). // Safe to call on startup — does nothing if the IATA has not been seen yet. @@ -986,7 +1018,7 @@ func (s *Store) ListNodeObservations(ctx context.Context, nodeID uuid.UUID, curs }, nil } -func (s *Store) ListPackets(ctx context.Context, payloadType, routeType int16, iata string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) { +func (s *Store) ListPackets(ctx context.Context, payloadType, routeType int16, iatas []string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) { var cursorTS pgtype.Timestamptz if cursor > 0 { cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true} @@ -999,10 +1031,11 @@ func (s *Store) ListPackets(ctx context.Context, payloadType, routeType int16, i if !until.IsZero() { untilTS = pgtype.Timestamptz{Time: until, Valid: true} } + iataFilter := strings.Join(iatas, ",") rows, err := s.q.ListPackets(ctx, sqlc.ListPacketsParams{ Column1: payloadType, Column2: routeType, - Column3: iata, + Column3: iataFilter, Column4: sinceTS, Column5: untilTS, Column6: cursorTS, diff --git a/docs/docs.go b/docs/docs.go index 586b883..70e0f1a 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -770,10 +770,28 @@ const docTemplate = `{ }, { "type": "string", - "description": "Filter by IATA code", + "description": "Filter by single IATA code (case-insensitive)", "name": "iata", "in": "query" }, + { + "type": "string", + "description": "Filter by multiple IATA codes, comma-separated e.g. YVR,YYJ", + "name": "iatas", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by region ID, expands to member IATAs", + "name": "regionId", + "in": "query" + }, + { + "type": "string", + "description": "Filter by region slug, expands to member IATAs", + "name": "region", + "in": "query" + }, { "type": "integer", "description": "Filter by first_heard_at \u003e= since (epoch ms)", @@ -803,7 +821,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_MeshCore-Tower_tower-server_internal_api.Page-github_com_MeshCore-Tower_tower-server_internal_api_PacketSummary" + "type": "object" } }, "400": { @@ -1748,20 +1766,6 @@ const docTemplate = `{ } } }, - "github_com_MeshCore-Tower_tower-server_internal_api.PacketLatestObserver": { - "type": "object", - "properties": { - "displayName": { - "type": "string" - }, - "iata": { - "type": "string" - }, - "id": { - "type": "string" - } - } - }, "github_com_MeshCore-Tower_tower-server_internal_api.PacketObservationDetail": { "type": "object", "properties": { @@ -1877,45 +1881,6 @@ const docTemplate = `{ } } }, - "github_com_MeshCore-Tower_tower-server_internal_api.PacketSummary": { - "type": "object", - "properties": { - "firstHeardAt": { - "description": "epoch ms", - "type": "integer" - }, - "lastHeardAt": { - "description": "epoch ms", - "type": "integer" - }, - "latestObserver": { - "$ref": "#/definitions/github_com_MeshCore-Tower_tower-server_internal_api.PacketLatestObserver" - }, - "observationCount": { - "type": "integer" - }, - "packetHash": { - "description": "hex-encoded", - "type": "string" - }, - "payloadType": { - "type": "integer" - }, - "payloadTypeName": { - "type": "string" - }, - "routeType": { - "type": "integer" - }, - "routeTypeName": { - "type": "string" - }, - "summary": { - "description": "human-readable payload summary", - "type": "string" - } - } - }, "github_com_MeshCore-Tower_tower-server_internal_api.PacketTransportCodes": { "type": "object", "properties": { @@ -2029,23 +1994,6 @@ const docTemplate = `{ } } }, - "github_com_MeshCore-Tower_tower-server_internal_api.Page-github_com_MeshCore-Tower_tower-server_internal_api_PacketSummary": { - "type": "object", - "properties": { - "hasMore": { - "type": "boolean" - }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/github_com_MeshCore-Tower_tower-server_internal_api.PacketSummary" - } - }, - "nextCursor": { - "type": "integer" - } - } - }, "github_com_MeshCore-Tower_tower-server_internal_api.PayloadBreakdownItem": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index f8f5b04..f77e2d4 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -768,10 +768,28 @@ }, { "type": "string", - "description": "Filter by IATA code", + "description": "Filter by single IATA code (case-insensitive)", "name": "iata", "in": "query" }, + { + "type": "string", + "description": "Filter by multiple IATA codes, comma-separated e.g. YVR,YYJ", + "name": "iatas", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by region ID, expands to member IATAs", + "name": "regionId", + "in": "query" + }, + { + "type": "string", + "description": "Filter by region slug, expands to member IATAs", + "name": "region", + "in": "query" + }, { "type": "integer", "description": "Filter by first_heard_at \u003e= since (epoch ms)", @@ -801,7 +819,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_MeshCore-Tower_tower-server_internal_api.Page-github_com_MeshCore-Tower_tower-server_internal_api_PacketSummary" + "type": "object" } }, "400": { @@ -1746,20 +1764,6 @@ } } }, - "github_com_MeshCore-Tower_tower-server_internal_api.PacketLatestObserver": { - "type": "object", - "properties": { - "displayName": { - "type": "string" - }, - "iata": { - "type": "string" - }, - "id": { - "type": "string" - } - } - }, "github_com_MeshCore-Tower_tower-server_internal_api.PacketObservationDetail": { "type": "object", "properties": { @@ -1875,45 +1879,6 @@ } } }, - "github_com_MeshCore-Tower_tower-server_internal_api.PacketSummary": { - "type": "object", - "properties": { - "firstHeardAt": { - "description": "epoch ms", - "type": "integer" - }, - "lastHeardAt": { - "description": "epoch ms", - "type": "integer" - }, - "latestObserver": { - "$ref": "#/definitions/github_com_MeshCore-Tower_tower-server_internal_api.PacketLatestObserver" - }, - "observationCount": { - "type": "integer" - }, - "packetHash": { - "description": "hex-encoded", - "type": "string" - }, - "payloadType": { - "type": "integer" - }, - "payloadTypeName": { - "type": "string" - }, - "routeType": { - "type": "integer" - }, - "routeTypeName": { - "type": "string" - }, - "summary": { - "description": "human-readable payload summary", - "type": "string" - } - } - }, "github_com_MeshCore-Tower_tower-server_internal_api.PacketTransportCodes": { "type": "object", "properties": { @@ -2027,23 +1992,6 @@ } } }, - "github_com_MeshCore-Tower_tower-server_internal_api.Page-github_com_MeshCore-Tower_tower-server_internal_api_PacketSummary": { - "type": "object", - "properties": { - "hasMore": { - "type": "boolean" - }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/github_com_MeshCore-Tower_tower-server_internal_api.PacketSummary" - } - }, - "nextCursor": { - "type": "integer" - } - } - }, "github_com_MeshCore-Tower_tower-server_internal_api.PayloadBreakdownItem": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b904504..1f482fc 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -388,15 +388,6 @@ definitions: routeTypeName: type: string type: object - github_com_MeshCore-Tower_tower-server_internal_api.PacketLatestObserver: - properties: - displayName: - type: string - iata: - type: string - id: - type: string - type: object github_com_MeshCore-Tower_tower-server_internal_api.PacketObservationDetail: properties: heardAt: @@ -474,33 +465,6 @@ definitions: spreadFactor: type: integer type: object - github_com_MeshCore-Tower_tower-server_internal_api.PacketSummary: - properties: - firstHeardAt: - description: epoch ms - type: integer - lastHeardAt: - description: epoch ms - type: integer - latestObserver: - $ref: '#/definitions/github_com_MeshCore-Tower_tower-server_internal_api.PacketLatestObserver' - observationCount: - type: integer - packetHash: - description: hex-encoded - type: string - payloadType: - type: integer - payloadTypeName: - type: string - routeType: - type: integer - routeTypeName: - type: string - summary: - description: human-readable payload summary - type: string - type: object github_com_MeshCore-Tower_tower-server_internal_api.PacketTransportCodes: properties: regionCode: @@ -574,17 +538,6 @@ definitions: nextCursor: type: integer type: object - github_com_MeshCore-Tower_tower-server_internal_api.Page-github_com_MeshCore-Tower_tower-server_internal_api_PacketSummary: - properties: - hasMore: - type: boolean - items: - items: - $ref: '#/definitions/github_com_MeshCore-Tower_tower-server_internal_api.PacketSummary' - type: array - nextCursor: - type: integer - type: object github_com_MeshCore-Tower_tower-server_internal_api.PayloadBreakdownItem: properties: count: @@ -1225,10 +1178,22 @@ paths: in: query name: routeType type: integer - - description: Filter by IATA code + - description: Filter by single IATA code (case-insensitive) in: query name: iata type: string + - description: Filter by multiple IATA codes, comma-separated e.g. YVR,YYJ + in: query + name: iatas + type: string + - description: Filter by region ID, expands to member IATAs + in: query + name: regionId + type: integer + - description: Filter by region slug, expands to member IATAs + in: query + name: region + type: string - description: Filter by first_heard_at >= since (epoch ms) in: query name: since @@ -1251,7 +1216,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/github_com_MeshCore-Tower_tower-server_internal_api.Page-github_com_MeshCore-Tower_tower-server_internal_api_PacketSummary' + type: object "400": description: Bad Request schema: diff --git a/internal/api/handlers/packets.go b/internal/api/handlers/packets.go index 5f8673a..e67cf68 100644 --- a/internal/api/handlers/packets.go +++ b/internal/api/handlers/packets.go @@ -29,12 +29,15 @@ func PacketsRouter(reader api.Reader) http.Handler { // @Param payloadType query int false "Filter by payload type integer" // @Param payloadTypeName query string false "Filter by payload type name (advert, grp_txt, txt_msg, trace, anon_req)" // @Param routeType query int false "Filter by route type (0=transport_flood, 1=flood, 2=direct, 3=transport_direct)" -// @Param iata query string false "Filter by IATA code" +// @Param iata query string false "Filter by single IATA code (case-insensitive)" +// @Param iatas query string false "Filter by multiple IATA codes, comma-separated e.g. YVR,YYJ" +// @Param regionId query int false "Filter by region ID, expands to member IATAs" +// @Param region query string false "Filter by region slug, expands to member IATAs" // @Param since query int false "Filter by first_heard_at >= since (epoch ms)" // @Param until query int false "Filter by first_heard_at <= until (epoch ms)" // @Param cursor query int false "last_heard_at epoch ms of last item for pagination" // @Param limit query int false "Max results (default 50)" -// @Success 200 {object} api.Page[api.PacketSummary] +// @Success 200 {object} object // @Failure 400 {object} handlers.APIError // @Failure 500 {object} handlers.APIError // @Router /packets [get] @@ -98,8 +101,16 @@ func listPackets(reader api.Reader) http.HandlerFunc { } limit = int32(l) } - iata := r.URL.Query().Get("iata") - packets, err := reader.ListPackets(r.Context(), payloadType, routeType, iata, since, until, cursor, limit) + iatas := parseIATAs(r) + if regionIDStr := r.URL.Query().Get("regionId"); regionIDStr != "" || r.URL.Query().Get("region") != "" { + regionIATAs, err := resolveRegionIATAs(r.Context(), regionIDStr, r.URL.Query().Get("region"), reader) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + iatas = append(iatas, regionIATAs...) + } + packets, err := reader.ListPackets(r.Context(), payloadType, routeType, iatas, since, until, cursor, limit) if err != nil { respondError(w, http.StatusInternalServerError, "internal server error") return diff --git a/internal/api/handlers/regions.go b/internal/api/handlers/regions.go index 12ed939..f4949ae 100644 --- a/internal/api/handlers/regions.go +++ b/internal/api/handlers/regions.go @@ -1,8 +1,11 @@ package handlers import ( + "context" + "fmt" "net/http" "strconv" + "strings" "github.com/MeshCore-Tower/tower-server/internal/api" "github.com/go-chi/chi/v5" @@ -67,3 +70,46 @@ func getRegion(reader api.Reader) http.HandlerFunc { respond(w, http.StatusOK, region) } } + +// parseIATAs splits a comma-separated iatas query param and uppercases each value. +func parseIATAs(r *http.Request) []string { + raw := r.URL.Query().Get("iatas") + if raw == "" { + // fall back to single iata param for backwards compatibility + if single := r.URL.Query().Get("iata"); single != "" { + return []string{strings.ToUpper(strings.TrimSpace(single))} + } + return nil + } + parts := strings.Split(raw, ",") + iatas := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + iatas = append(iatas, strings.ToUpper(p)) + } + } + return iatas +} + +// resolveRegionIATAs expands a regionId or region slug to a slice of IATA codes. +func resolveRegionIATAs(ctx context.Context, regionID, regionSlug string, reader api.Reader) ([]string, error) { + var region *api.Region + var err error + switch { + case regionID != "": + rid, e := strconv.Atoi(regionID) + if e != nil { + return nil, fmt.Errorf("invalid regionId: %w", e) + } + region, err = reader.GetRegion(ctx, int32(rid)) + case regionSlug != "": + region, err = reader.GetRegionBySlug(ctx, regionSlug) + default: + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("region not found: %w", err) + } + return region.IATAs, nil +} diff --git a/internal/api/reader.go b/internal/api/reader.go index 1e6a335..e97104a 100644 --- a/internal/api/reader.go +++ b/internal/api/reader.go @@ -371,6 +371,9 @@ type Reader interface { // GetRegion returns full detail for a single region including its associated IATA codes. // Returns nil, pgx.ErrNoRows if the region is not found. GetRegion(ctx context.Context, regionID int32) (*Region, error) + // GetRegionBySlug returns full detail for a single region by its URL-safe slug. + // Returns nil, pgx.ErrNoRows if the region is not found. + GetRegionBySlug(ctx context.Context, slug string) (*Region, error) // ListChannels returns a paginated list of channels ordered by last seen. // Includes both hashtag-derived and explicit key channels. // Pass nil hash to skip hash filtering. Pass empty string iata to return all channels. @@ -421,9 +424,9 @@ type Reader interface { ListNodeObservations(ctx context.Context, nodeID uuid.UUID, cursor int64, limit int32) (Page[PacketObservationSummary], error) // ListPackets returns a paginated list of packets with the latest observation rolled in. // Pass 0 for payloadType/routeType to skip those filters. - // Pass empty string for iata, zero times for since/until to skip those filters. + // Pass nil for iatas, zero times for since/until to skip those filters. // cursor is last_heard_at epoch ms; pass 0 to start from the beginning. - ListPackets(ctx context.Context, payloadType, routeType int16, iata string, since, until time.Time, cursor int64, limit int32) (Page[PacketSummary], error) + ListPackets(ctx context.Context, payloadType, routeType int16, iatas []string, since, until time.Time, cursor int64, limit int32) (Page[PacketSummary], error) // GetPacket returns full packet detail including all observations with radio settings. // Returns nil, pgx.ErrNoRows if not found. GetPacket(ctx context.Context, packetHash []byte) (*Packet, error)