diff --git a/db/queries/queries.sql b/db/queries/queries.sql index 109281a..5b2be2c 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -348,7 +348,7 @@ FROM nodes n LEFT JOIN node_iatas ni ON ni.node_id = n.id WHERE ($1 = 0 OR n.node_type = $1) - AND ($2 = '' OR n.id IN (SELECT node_id FROM node_iatas WHERE iata ILIKE $2)) + AND ($2::text = '' OR n.id IN (SELECT node_id FROM node_iatas WHERE iata = ANY(string_to_array($2::text, ',')))) AND ( $3::text = 'any' OR ($3::text = 'true' AND n.supports_multibyte_paths = TRUE) diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index 5efd946..47af767 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -1439,7 +1439,7 @@ FROM nodes n LEFT JOIN node_iatas ni ON ni.node_id = n.id WHERE ($1 = 0 OR n.node_type = $1) - AND ($2 = '' OR n.id IN (SELECT node_id FROM node_iatas WHERE iata ILIKE $2)) + AND ($2::text = '' OR n.id IN (SELECT node_id FROM node_iatas WHERE iata = ANY(string_to_array($2::text, ',')))) AND ( $3::text = 'any' OR ($3::text = 'true' AND n.supports_multibyte_paths = TRUE) @@ -1460,7 +1460,7 @@ LIMIT $8 type ListNodesParams struct { Column1 interface{} `json:"column_1"` - Column2 interface{} `json:"column_2"` + Column2 string `json:"column_2"` Column3 string `json:"column_3"` Column4 string `json:"column_4"` Column5 []byte `json:"column_5"` diff --git a/db/store.go b/db/store.go index e39d8fd..dfb8c9e 100644 --- a/db/store.go +++ b/db/store.go @@ -876,14 +876,15 @@ func (s *Store) ListObserverAdverts(ctx context.Context, observerID uuid.UUID, c // ListNodes returns a paginated list of nodes with optional filters. // Pass 0 for nodeType, empty string for iata/name, nil for pubkey to skip those filters. // cursor is last_seen epoch ms; pass 0 to start from the beginning. -func (s *Store) ListNodes(ctx context.Context, nodeType int16, iata string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name string, cursor int64, limit int32) (api.Page[api.NodeSummary], error) { +func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name string, cursor int64, limit int32) (api.Page[api.NodeSummary], error) { var cursorTS pgtype.Timestamptz if cursor > 0 { cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true} } + iataFilter := strings.Join(iatas, ",") rows, err := s.q.ListNodes(ctx, sqlc.ListNodesParams{ Column1: nodeType, - Column2: iata, + Column2: iataFilter, Column3: tristate(supportsMultibytePaths), Column4: tristate(supportsMultibyteTraces), Column5: pubkey, diff --git a/docs/docs.go b/docs/docs.go index 70e0f1a..81a257f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -360,10 +360,28 @@ const docTemplate = `{ }, { "type": "string", - "description": "Filter by IATA code (case-insensitive)", + "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": "string", "description": "Partial case-insensitive name match", diff --git a/docs/swagger.json b/docs/swagger.json index f77e2d4..6669fde 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -358,10 +358,28 @@ }, { "type": "string", - "description": "Filter by IATA code (case-insensitive)", + "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": "string", "description": "Partial case-insensitive name match", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 1f482fc..282d5dc 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -908,10 +908,22 @@ paths: in: query name: typeName type: string - - description: Filter by IATA code (case-insensitive) + - 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: Partial case-insensitive name match in: query name: name diff --git a/internal/api/handlers/nodes.go b/internal/api/handlers/nodes.go index b7dbc1c..0e4e917 100644 --- a/internal/api/handlers/nodes.go +++ b/internal/api/handlers/nodes.go @@ -32,7 +32,10 @@ func NodesRouter(reader api.Reader) http.Handler { // @Produce json // @Param type query int false "Node type integer (1=companion, 2=repeater, 3=room_server, 4=sensor)" // @Param typeName query string false "Node type name (companion, repeater, room_server, sensor)" -// @Param iata query string false "Filter by IATA code (case-insensitive)" +// @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 name query string false "Partial case-insensitive name match" // @Param pubkey query string false "Exact public key match (hex)" // @Param supportsMultibytePaths query bool false "Filter by multibyte path support (true/false); omit for no filter" @@ -83,7 +86,15 @@ func listNodes(reader api.Reader) http.HandlerFunc { } pubkey = b } - iata := r.URL.Query().Get("iata") + 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...) + } name := r.URL.Query().Get("name") var supportsMultibytePaths *bool if v := r.URL.Query().Get("supportsMultibytePaths"); v != "" { @@ -103,7 +114,7 @@ func listNodes(reader api.Reader) http.HandlerFunc { } supportsMultibyteTraces = &b } - nodes, err := reader.ListNodes(r.Context(), nodeType, iata, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, cursor, limit) + nodes, err := reader.ListNodes(r.Context(), nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, cursor, limit) if err != nil { respondError(w, http.StatusInternalServerError, "internal server error") return diff --git a/internal/api/reader.go b/internal/api/reader.go index e97104a..b435404 100644 --- a/internal/api/reader.go +++ b/internal/api/reader.go @@ -412,9 +412,9 @@ type Reader interface { // Pass cursor=0 to start from the beginning. ListObserverAdverts(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (Page[AdvertObservation], error) // ListNodes returns a paginated list of nodes with optional filters. - // Pass 0 for nodeType, empty string for iata, nil for pubkey to skip those filters. + // Pass 0 for nodeType, nil iatas, nil for pubkey to skip those filters. // cursor is last_seen epoch ms; pass 0 to start from the beginning. - ListNodes(ctx context.Context, nodeType int16, iata string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name string, cursor int64, limit int32) (Page[NodeSummary], error) + ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name string, cursor int64, limit int32) (Page[NodeSummary], error) // GetNode returns full detail for a single node by UUID. // Returns nil, pgx.ErrNoRows if the node is not found.