add region filtering to packets

This commit is contained in:
Enot (ded) Skelly committed 2026-06-03 14:37:18 -07:00
1 parent 42e455bd00
commit a49c64b4b5
9 files changed
+202 -204

No files matched your search

+10 -1
View File
@@ -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
+37 -2
View File
@@ -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"`
+35 -2
View File
@@ -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,
+20 -72
View File
@@ -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": {
+20 -72
View File
@@ -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": {
+14 -49
View File
@@ -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:
+15 -4
View File
@@ -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
+46
View File
@@ -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
}
+5 -2
View File
@@ -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)