diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/Dockerfile b/Dockerfile index 46f4f39..70efdb2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,23 @@ -FROM python:3.12-slim - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - NIXPACKS_POETRY_VERSION=2.2.1 +FROM node:22-alpine AS builder WORKDIR /app -RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential curl \ - && rm -rf /var/lib/apt/lists/* +COPY package.json ./ +RUN npm install -RUN python -m venv --copies /opt/venv -ENV VIRTUAL_ENV=/opt/venv -ENV PATH="/opt/venv/bin:$PATH" +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build -COPY . . +FROM node:22-alpine -RUN pip install --no-cache-dir poetry==$NIXPACKS_POETRY_VERSION \ - && poetry install --no-interaction --no-ansi +WORKDIR /app -ENV PORT=8000 +COPY package.json ./ +RUN npm install --omit=dev -CMD ["sh", "-c", "poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn geo.wsgi:application --bind 0.0.0.0:${PORT:-8000}"] +COPY --from=builder /app/dist ./dist + +EXPOSE 8000 + +CMD ["node", "dist/index.js"] diff --git a/geo/__init__.py b/geo/__init__.py deleted file mode 100644 index aabe992..0000000 --- a/geo/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Geo Django project.""" diff --git a/geo/settings.py b/geo/settings.py deleted file mode 100644 index 4d4af35..0000000 --- a/geo/settings.py +++ /dev/null @@ -1,148 +0,0 @@ -import os -from pathlib import Path -from dotenv import load_dotenv -from infisical_sdk import InfisicalSDKClient -import sentry_sdk -from sentry_sdk.integrations.django import DjangoIntegration - -load_dotenv() - -INFISICAL_API_URL = os.environ["INFISICAL_API_URL"] -INFISICAL_CLIENT_ID = os.environ["INFISICAL_CLIENT_ID"] -INFISICAL_CLIENT_SECRET = os.environ["INFISICAL_CLIENT_SECRET"] -INFISICAL_PROJECT_ID = os.environ["INFISICAL_PROJECT_ID"] -INFISICAL_ENV = os.environ.get("INFISICAL_ENV", "prod") - -client = InfisicalSDKClient(host=INFISICAL_API_URL) -client.auth.universal_auth.login( - client_id=INFISICAL_CLIENT_ID, - client_secret=INFISICAL_CLIENT_SECRET, -) - -# Fetch secrets from /geo and /shared -for secret_path in ["/geo", "/shared"]: - secrets_response = client.secrets.list_secrets( - environment_slug=INFISICAL_ENV, - secret_path=secret_path, - project_id=INFISICAL_PROJECT_ID, - expand_secret_references=True, - view_secret_value=True, - ) - for secret in secrets_response.secrets: - os.environ[secret.secretKey] = secret.secretValue - -BASE_DIR = Path(__file__).resolve().parent.parent - -SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'dev-secret-key-change-in-production') - -DEBUG = os.getenv('DEBUG', 'False') == 'True' - -# Sentry/GlitchTip configuration -SENTRY_DSN = os.getenv('SENTRY_DSN', '') -if SENTRY_DSN: - sentry_sdk.init( - dsn=SENTRY_DSN, - integrations=[DjangoIntegration()], - auto_session_tracking=False, - traces_sample_rate=0.01, - release=os.getenv('RELEASE_VERSION', '1.0.0'), - environment=os.getenv('ENVIRONMENT', 'production'), - send_default_pii=False, - debug=DEBUG, - ) - -ALLOWED_HOSTS = ['*'] - -CSRF_TRUSTED_ORIGINS = ['https://geo.optovia.ru'] - -INSTALLED_APPS = [ - 'whitenoise.runserver_nostatic', - 'django.contrib.contenttypes', - 'django.contrib.staticfiles', - 'corsheaders', - 'graphene_django', - 'geo_app', -] - -MIDDLEWARE = [ - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.security.SecurityMiddleware', - 'whitenoise.middleware.WhiteNoiseMiddleware', - 'django.middleware.common.CommonMiddleware', -] - -ROOT_URLCONF = 'geo.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - ], - }, - }, -] - -WSGI_APPLICATION = 'geo.wsgi.application' - -# No database - we use ArangoDB directly -DATABASES = {} - -# Internationalization -LANGUAGE_CODE = 'ru-ru' -TIME_ZONE = 'UTC' -USE_I18N = True -USE_TZ = True - -# Static files -STATIC_URL = '/static/' -STATIC_ROOT = BASE_DIR / 'staticfiles' - -# Default primary key field type -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - -# CORS -CORS_ALLOW_ALL_ORIGINS = False -CORS_ALLOWED_ORIGINS = ['https://optovia.ru'] -CORS_ALLOW_CREDENTIALS = True - -# GraphQL -GRAPHENE = { - 'SCHEMA': 'geo_app.schema.schema', -} - -# ArangoDB connection (internal M2M) -ARANGODB_INTERNAL_URL = os.getenv('ARANGODB_INTERNAL_URL', 'localhost:8529') -ARANGODB_DATABASE = os.getenv('ARANGODB_DATABASE', 'optovia_maps') -ARANGODB_PASSWORD = os.getenv('ARANGODB_PASSWORD', '') - -# Routing services (external APIs) -GRAPHHOPPER_EXTERNAL_URL = os.getenv('GRAPHHOPPER_EXTERNAL_URL', 'https://graphhopper.optovia.ru') -OPENRAILROUTING_EXTERNAL_URL = os.getenv('OPENRAILROUTING_EXTERNAL_URL', 'https://openrailrouting.optovia.ru') - -# Logging -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - }, - }, - 'loggers': { - 'django.request': { - 'handlers': ['console'], - 'level': 'DEBUG', - 'propagate': False, - }, - 'geo_app': { - 'handlers': ['console'], - 'level': 'DEBUG', - 'propagate': False, - }, - }, -} diff --git a/geo/settings_local.py b/geo/settings_local.py deleted file mode 100644 index 9ca1d10..0000000 --- a/geo/settings_local.py +++ /dev/null @@ -1,125 +0,0 @@ -import os -from pathlib import Path -from dotenv import load_dotenv -import sentry_sdk -from sentry_sdk.integrations.django import DjangoIntegration - -load_dotenv() - - - -BASE_DIR = Path(__file__).resolve().parent.parent - -SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'dev-secret-key-change-in-production') - -DEBUG = True - -# Sentry/GlitchTip configuration -SENTRY_DSN = os.getenv('SENTRY_DSN', '') -if SENTRY_DSN: - sentry_sdk.init( - dsn=SENTRY_DSN, - integrations=[DjangoIntegration()], - auto_session_tracking=False, - traces_sample_rate=0.01, - release=os.getenv('RELEASE_VERSION', '1.0.0'), - environment=os.getenv('ENVIRONMENT', 'production'), - send_default_pii=False, - debug=DEBUG, - ) - -ALLOWED_HOSTS = ['*'] - -CSRF_TRUSTED_ORIGINS = ['https://geo.optovia.ru'] - -INSTALLED_APPS = [ - 'whitenoise.runserver_nostatic', - 'django.contrib.contenttypes', - 'django.contrib.staticfiles', - 'corsheaders', - 'graphene_django', - 'geo_app', -] - -MIDDLEWARE = [ - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.security.SecurityMiddleware', - 'whitenoise.middleware.WhiteNoiseMiddleware', - 'django.middleware.common.CommonMiddleware', -] - -ROOT_URLCONF = 'geo.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - ], - }, - }, -] - -WSGI_APPLICATION = 'geo.wsgi.application' - -# No database - we use ArangoDB directly -DATABASES = {} - -# Internationalization -LANGUAGE_CODE = 'ru-ru' -TIME_ZONE = 'UTC' -USE_I18N = True -USE_TZ = True - -# Static files -STATIC_URL = '/static/' -STATIC_ROOT = BASE_DIR / 'staticfiles' - -# Default primary key field type -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - -# CORS -CORS_ALLOW_ALL_ORIGINS = False -CORS_ALLOWED_ORIGINS = ['http://localhost:3000', 'https://optovia.ru'] -CORS_ALLOW_CREDENTIALS = True - -# GraphQL -GRAPHENE = { - 'SCHEMA': 'geo_app.schema.schema', -} - -# ArangoDB connection (internal M2M) -ARANGODB_INTERNAL_URL = os.getenv('ARANGODB_INTERNAL_URL', 'localhost:8529') -ARANGODB_DATABASE = os.getenv('ARANGODB_DATABASE', 'optovia_maps') -ARANGODB_PASSWORD = os.getenv('ARANGODB_PASSWORD', '') - -# Routing services (external APIs) -GRAPHHOPPER_EXTERNAL_URL = os.getenv('GRAPHHOPPER_EXTERNAL_URL', 'https://graphhopper.optovia.ru') -OPENRAILROUTING_EXTERNAL_URL = os.getenv('OPENRAILROUTING_EXTERNAL_URL', 'https://openrailrouting.optovia.ru') - -# Logging -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - }, - }, - 'loggers': { - 'django.request': { - 'handlers': ['console'], - 'level': 'DEBUG', - 'propagate': False, - }, - 'geo_app': { - 'handlers': ['console'], - 'level': 'DEBUG', - 'propagate': False, - }, - }, -} diff --git a/geo/urls.py b/geo/urls.py deleted file mode 100644 index f9c5590..0000000 --- a/geo/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import path -from graphene_django.views import GraphQLView -from django.views.decorators.csrf import csrf_exempt - -urlpatterns = [ - path('graphql/public/', csrf_exempt(GraphQLView.as_view(graphiql=True))), -] diff --git a/geo/wsgi.py b/geo/wsgi.py deleted file mode 100644 index 5efec92..0000000 --- a/geo/wsgi.py +++ /dev/null @@ -1,5 +0,0 @@ -import os -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'geo.settings') -application = get_wsgi_application() diff --git a/geo_app/__init__.py b/geo_app/__init__.py deleted file mode 100644 index 5e5fe7a..0000000 --- a/geo_app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Geo app - logistics graph operations.""" diff --git a/geo_app/apps.py b/geo_app/apps.py deleted file mode 100644 index a5a4e82..0000000 --- a/geo_app/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class GeoAppConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'geo_app' diff --git a/geo_app/arango_client.py b/geo_app/arango_client.py deleted file mode 100644 index 14a6f47..0000000 --- a/geo_app/arango_client.py +++ /dev/null @@ -1,49 +0,0 @@ -"""ArangoDB client singleton.""" -import logging -from arango import ArangoClient -from django.conf import settings - -logger = logging.getLogger(__name__) - -_db = None - - -def get_db(): - """Get ArangoDB database connection (singleton).""" - global _db - if _db is None: - hosts = settings.ARANGODB_INTERNAL_URL - if not hosts.startswith("http"): - hosts = f"http://{hosts}" - - client = ArangoClient(hosts=hosts) - _db = client.db( - settings.ARANGODB_DATABASE, - username='root', - password=settings.ARANGODB_PASSWORD, - ) - logger.info( - "Connected to ArangoDB: %s/%s", - hosts, - settings.ARANGODB_DATABASE, - ) - return _db - - -def ensure_graph(): - """Ensure named graph exists for K_SHORTEST_PATHS queries.""" - db = get_db() - graph_name = 'optovia_graph' - - if db.has_graph(graph_name): - return db.graph(graph_name) - - logger.info("Creating graph: %s", graph_name) - return db.create_graph( - graph_name, - edge_definitions=[{ - 'edge_collection': 'edges', - 'from_vertex_collections': ['nodes'], - 'to_vertex_collections': ['nodes'], - }], - ) diff --git a/geo_app/cluster_index.py b/geo_app/cluster_index.py deleted file mode 100644 index d750107..0000000 --- a/geo_app/cluster_index.py +++ /dev/null @@ -1,236 +0,0 @@ -""" -Server-side map clustering using Uber H3 hexagonal grid. - -Maps zoom levels to h3 resolutions and groups nodes by cell. -""" -import logging -import h3 - -logger = logging.getLogger(__name__) - -# Map zoom level to h3 resolution -# Higher zoom = higher resolution = smaller cells -ZOOM_TO_RES = { - 0: 0, 1: 0, 2: 1, 3: 1, 4: 2, 5: 2, - 6: 3, 7: 3, 8: 4, 9: 4, 10: 5, 11: 5, - 12: 6, 13: 7, 14: 8, 15: 9, 16: 10 -} - - -def _fetch_nodes( - db, - west, - south, - east, - north, - transport_type=None, - node_type=None, - product_uuid=None, - hub_uuid=None, - supplier_uuid=None, -): - """Fetch nodes from database for a bounding box. - - Args: - db: Database connection - west, south, east, north: Bounding box coordinates - transport_type: Filter by transport type (auto, rail, sea, air) - node_type: Type of nodes to fetch ('logistics', 'offer', 'supplier') - """ - bind_vars = { - 'west': west, - 'south': south, - 'east': east, - 'north': north, - 'product_uuid': product_uuid, - 'hub_uuid': hub_uuid, - 'supplier_uuid': supplier_uuid, - } - - # Select AQL query based on node_type - if node_type == 'offer': - aql = """ - FOR node IN nodes - FILTER node.node_type == 'offer' - FILTER node.latitude != null AND node.longitude != null - FILTER node.latitude >= @south AND node.latitude <= @north - FILTER node.longitude >= @west AND node.longitude <= @east - FILTER @product_uuid == null OR node.product_uuid == @product_uuid - FILTER @supplier_uuid == null OR node.supplier_uuid == @supplier_uuid - LET has_hub = @hub_uuid == null ? true : LENGTH( - FOR edge IN edges - FILTER edge.transport_type == 'offer' - FILTER ( - (edge._from == CONCAT('nodes/', node._key) AND edge._to == CONCAT('nodes/', @hub_uuid)) OR - (edge._to == CONCAT('nodes/', node._key) AND edge._from == CONCAT('nodes/', @hub_uuid)) - ) - LIMIT 1 - RETURN 1 - ) > 0 - FILTER has_hub - RETURN node - """ - elif node_type == 'supplier': - # Get suppliers that have offers (aggregate through offers) - aql = """ - FOR offer IN nodes - FILTER offer.node_type == 'offer' - FILTER offer.supplier_uuid != null - FILTER @product_uuid == null OR offer.product_uuid == @product_uuid - FILTER @supplier_uuid == null OR offer.supplier_uuid == @supplier_uuid - LET has_hub = @hub_uuid == null ? true : LENGTH( - FOR edge IN edges - FILTER edge.transport_type == 'offer' - FILTER ( - (edge._from == CONCAT('nodes/', offer._key) AND edge._to == CONCAT('nodes/', @hub_uuid)) OR - (edge._to == CONCAT('nodes/', offer._key) AND edge._from == CONCAT('nodes/', @hub_uuid)) - ) - LIMIT 1 - RETURN 1 - ) > 0 - FILTER has_hub - LET supplier = DOCUMENT(CONCAT('nodes/', offer.supplier_uuid)) - FILTER supplier != null - FILTER supplier.latitude != null AND supplier.longitude != null - FILTER supplier.latitude >= @south AND supplier.latitude <= @north - FILTER supplier.longitude >= @west AND supplier.longitude <= @east - COLLECT sup_uuid = offer.supplier_uuid INTO offers - LET sup = DOCUMENT(CONCAT('nodes/', sup_uuid)) - RETURN { - _key: sup_uuid, - name: sup.name, - latitude: sup.latitude, - longitude: sup.longitude, - country: sup.country, - country_code: sup.country_code, - node_type: 'supplier', - offers_count: LENGTH(offers) - } - """ - else: # logistics (default) - aql = """ - FOR node IN nodes - FILTER node.node_type == 'logistics' OR node.node_type == null - FILTER node.latitude != null AND node.longitude != null - FILTER node.latitude >= @south AND node.latitude <= @north - FILTER node.longitude >= @west AND node.longitude <= @east - FILTER @hub_uuid == null OR node._key == @hub_uuid - LET has_offer = (@product_uuid == null AND @supplier_uuid == null) ? true : LENGTH( - FOR edge IN edges - FILTER edge.transport_type == 'offer' - FILTER edge._from == CONCAT('nodes/', node._key) OR edge._to == CONCAT('nodes/', node._key) - LET offer_id = edge._from == CONCAT('nodes/', node._key) ? edge._to : edge._from - LET offer = DOCUMENT(offer_id) - FILTER offer != null AND offer.node_type == 'offer' - FILTER @product_uuid == null OR offer.product_uuid == @product_uuid - FILTER @supplier_uuid == null OR offer.supplier_uuid == @supplier_uuid - LIMIT 1 - RETURN 1 - ) > 0 - FILTER has_offer - RETURN node - """ - - cursor = db.aql.execute(aql, bind_vars=bind_vars) - nodes = list(cursor) - - # Filter by transport type if specified (only for logistics nodes) - if node_type in (None, 'logistics'): - if transport_type: - nodes = [ - n for n in nodes - if transport_type in (n.get('transport_types') or []) - ] - else: - # Default: only rail/sea hubs - nodes = [ - n for n in nodes - if ('rail' in (n.get('transport_types') or [])) or ('sea' in (n.get('transport_types') or [])) - ] - - return nodes - - -def get_clustered_nodes( - db, - west, - south, - east, - north, - zoom, - transport_type=None, - node_type=None, - product_uuid=None, - hub_uuid=None, - supplier_uuid=None, -): - """ - Get clustered nodes for given bounding box and zoom level. - - Uses H3 hexagonal grid to group nearby nodes. - - Args: - db: Database connection - west, south, east, north: Bounding box coordinates - zoom: Map zoom level - transport_type: Filter by transport type (for logistics nodes) - node_type: Type of nodes ('logistics', 'offer', 'supplier') - """ - resolution = ZOOM_TO_RES.get(int(zoom), 5) - nodes = _fetch_nodes( - db, - west, - south, - east, - north, - transport_type, - node_type, - product_uuid, - hub_uuid, - supplier_uuid, - ) - - if not nodes: - return [] - - # Group nodes by h3 cell - cells = {} - for node in nodes: - lat = node.get('latitude') - lng = node.get('longitude') - - cell = h3.latlng_to_cell(lat, lng, resolution) - if cell not in cells: - cells[cell] = [] - cells[cell].append(node) - - # Build results - results = [] - for cell, nodes_in_cell in cells.items(): - count = len(nodes_in_cell) - - if count == 1: - # Single point — return actual node data - node = nodes_in_cell[0] - results.append({ - 'id': node.get('_key'), - 'latitude': node.get('latitude'), - 'longitude': node.get('longitude'), - 'count': 1, - 'expansion_zoom': None, - 'name': node.get('name'), - }) - else: - # Cluster — return cell centroid - lat, lng = h3.cell_to_latlng(cell) - results.append({ - 'id': f"cluster-{cell}", - 'latitude': lat, - 'longitude': lng, - 'count': count, - 'expansion_zoom': min(zoom + 2, 16), - 'name': None, - }) - - logger.info("Returning %d clusters/points for zoom=%d res=%d", len(results), zoom, resolution) - return results diff --git a/geo_app/schema.py b/geo_app/schema.py deleted file mode 100644 index 2f00466..0000000 --- a/geo_app/schema.py +++ /dev/null @@ -1,1846 +0,0 @@ -"""GraphQL schema for Geo service.""" -import logging -import math -import requests -import graphene -from django.conf import settings -from .arango_client import get_db, ensure_graph -from .route_engine import graph_find_targets, resolve_start_hub, snap_to_nearest_hub -from .cluster_index import get_clustered_nodes - -logger = logging.getLogger(__name__) - - -class EdgeType(graphene.ObjectType): - """Edge between two nodes (route).""" - to_uuid = graphene.String() - to_name = graphene.String() - to_latitude = graphene.Float() - to_longitude = graphene.Float() - distance_km = graphene.Float() - travel_time_seconds = graphene.Int() - transport_type = graphene.String() - - -class NodeType(graphene.ObjectType): - """Logistics node with edges to neighbors.""" - uuid = graphene.String() - name = graphene.String() - latitude = graphene.Float() - longitude = graphene.Float() - country = graphene.String() - country_code = graphene.String() - synced_at = graphene.String() - transport_types = graphene.List(graphene.String) - edges = graphene.List(EdgeType) - distance_km = graphene.Float() - - -class NodeConnectionsType(graphene.ObjectType): - """Auto + rail edges for a node, rail uses nearest rail node.""" - hub = graphene.Field(NodeType) - rail_node = graphene.Field(NodeType) - auto_edges = graphene.List(EdgeType) - rail_edges = graphene.List(EdgeType) - - -class RouteType(graphene.ObjectType): - """Route between two points with geometry.""" - distance_km = graphene.Float() - geometry = graphene.JSONString(description="GeoJSON LineString coordinates") - - -class RouteStageType(graphene.ObjectType): - """Single stage in a multi-hop route.""" - from_uuid = graphene.String() - from_name = graphene.String() - from_lat = graphene.Float() - from_lon = graphene.Float() - to_uuid = graphene.String() - to_name = graphene.String() - to_lat = graphene.Float() - to_lon = graphene.Float() - distance_km = graphene.Float() - travel_time_seconds = graphene.Int() - transport_type = graphene.String() - - -class RoutePathType(graphene.ObjectType): - """Complete route through graph with multiple stages.""" - total_distance_km = graphene.Float() - total_time_seconds = graphene.Int() - stages = graphene.List(RouteStageType) - - -class ProductRouteOptionType(graphene.ObjectType): - """Route options for a product source to the destination.""" - source_uuid = graphene.String() - source_name = graphene.String() - source_lat = graphene.Float() - source_lon = graphene.Float() - distance_km = graphene.Float() - routes = graphene.List(RoutePathType) - - -class ClusterPointType(graphene.ObjectType): - """Cluster or individual point for map display.""" - id = graphene.String(description="UUID for points, 'cluster-N' for clusters") - latitude = graphene.Float() - longitude = graphene.Float() - count = graphene.Int(description="1 for single point, >1 for cluster") - expansion_zoom = graphene.Int(description="Zoom level to expand cluster") - name = graphene.String(description="Node name (only for single points)") - - -class ProductType(graphene.ObjectType): - """Unique product from offers.""" - uuid = graphene.String() - name = graphene.String() - offers_count = graphene.Int(description="Number of offers for this product") - - def resolve_offers_count(self, info): - db = get_db() - aql = """ - FOR node IN nodes - FILTER node.node_type == 'offer' - FILTER node.product_uuid == @product_uuid - COLLECT WITH COUNT INTO length - RETURN length - """ - try: - cursor = db.aql.execute(aql, bind_vars={'product_uuid': self.uuid}) - return next(cursor, 0) - except Exception as e: - logger.error("Error counting offers for product %s: %s", self.uuid, e) - return 0 - - -class SupplierType(graphene.ObjectType): - """Unique supplier from offers.""" - uuid = graphene.String() - name = graphene.String() - latitude = graphene.Float() - longitude = graphene.Float() - distance_km = graphene.Float() - - -class OfferNodeType(graphene.ObjectType): - """Offer node with location and product info.""" - uuid = graphene.String() - product_uuid = graphene.String() - product_name = graphene.String() - supplier_uuid = graphene.String() - supplier_name = graphene.String() - latitude = graphene.Float() - longitude = graphene.Float() - country = graphene.String() - country_code = graphene.String() - price_per_unit = graphene.String() - currency = graphene.String() - quantity = graphene.String() - unit = graphene.String() - distance_km = graphene.Float() - - -class OfferWithRouteType(graphene.ObjectType): - """Offer with route information to destination.""" - uuid = graphene.String() - product_uuid = graphene.String() - product_name = graphene.String() - supplier_uuid = graphene.String() - supplier_name = graphene.String() - latitude = graphene.Float() - longitude = graphene.Float() - country = graphene.String() - country_code = graphene.String() - price_per_unit = graphene.String() - currency = graphene.String() - quantity = graphene.String() - unit = graphene.String() - distance_km = graphene.Float() - routes = graphene.List(lambda: RoutePathType) - - -class OfferCalculationType(graphene.ObjectType): - """Calculation result that may include one or multiple offers.""" - offers = graphene.List(lambda: OfferWithRouteType) - - -class Query(graphene.ObjectType): - """Root query.""" - MAX_EXPANSIONS = 20000 - - node = graphene.Field( - NodeType, - uuid=graphene.String(required=True), - description="Get node by UUID with all edges to neighbors", - ) - - nodes = graphene.List( - NodeType, - description="Get all nodes (without edges for performance)", - limit=graphene.Int(), - offset=graphene.Int(), - transport_type=graphene.String(), - country=graphene.String(description="Filter by country name"), - search=graphene.String(description="Search by node name (case-insensitive)"), - west=graphene.Float(description="Bounding box west longitude"), - south=graphene.Float(description="Bounding box south latitude"), - east=graphene.Float(description="Bounding box east longitude"), - north=graphene.Float(description="Bounding box north latitude"), - ) - nodes_count = graphene.Int( - transport_type=graphene.String(), - country=graphene.String(description="Filter by country name"), - west=graphene.Float(description="Bounding box west longitude"), - south=graphene.Float(description="Bounding box south latitude"), - east=graphene.Float(description="Bounding box east longitude"), - north=graphene.Float(description="Bounding box north latitude"), - description="Get total count of nodes (with optional transport/country/bounds filter)", - ) - - hub_countries = graphene.List( - graphene.String, - description="List of countries that have logistics hubs", - ) - - nearest_nodes = graphene.List( - NodeType, - lat=graphene.Float(required=True, description="Latitude"), - lon=graphene.Float(required=True, description="Longitude"), - limit=graphene.Int(default_value=5, description="Max results"), - description="Find nearest logistics nodes to given coordinates", - ) - - node_connections = graphene.Field( - NodeConnectionsType, - uuid=graphene.String(required=True), - limit_auto=graphene.Int(default_value=12), - limit_rail=graphene.Int(default_value=12), - description="Get auto + rail edges for a node (rail uses nearest rail node)", - ) - - auto_route = graphene.Field( - RouteType, - from_lat=graphene.Float(required=True), - from_lon=graphene.Float(required=True), - to_lat=graphene.Float(required=True), - to_lon=graphene.Float(required=True), - description="Get auto route between two points via GraphHopper", - ) - - rail_route = graphene.Field( - RouteType, - from_lat=graphene.Float(required=True), - from_lon=graphene.Float(required=True), - to_lat=graphene.Float(required=True), - to_lon=graphene.Float(required=True), - description="Get rail route between two points via OpenRailRouting", - ) - - - clustered_nodes = graphene.List( - ClusterPointType, - west=graphene.Float(required=True, description="Bounding box west longitude"), - south=graphene.Float(required=True, description="Bounding box south latitude"), - east=graphene.Float(required=True, description="Bounding box east longitude"), - north=graphene.Float(required=True, description="Bounding box north latitude"), - zoom=graphene.Int(required=True, description="Map zoom level (0-16)"), - transport_type=graphene.String(description="Filter by transport type"), - node_type=graphene.String(description="Node type: logistics, offer, supplier"), - product_uuid=graphene.String(description="Filter by product UUID"), - hub_uuid=graphene.String(description="Filter by hub UUID"), - supplier_uuid=graphene.String(description="Filter by supplier UUID"), - description="Get clustered nodes for map display (server-side clustering)", - ) - - # Catalog navigation queries - products = graphene.List( - ProductType, - description="Get unique products from all offers", - ) - - offers_by_product = graphene.List( - OfferNodeType, - product_uuid=graphene.String(required=True), - description="Get all offers for a product", - ) - - hubs_near_offer = graphene.List( - NodeType, - offer_uuid=graphene.String(required=True), - limit=graphene.Int(default_value=12), - description="Get nearest hubs to an offer location", - ) - - suppliers = graphene.List( - SupplierType, - description="Get unique suppliers from all offers", - ) - - products_by_supplier = graphene.List( - ProductType, - supplier_uuid=graphene.String(required=True), - description="Get products offered by a supplier", - ) - - offers_by_supplier_product = graphene.List( - OfferNodeType, - supplier_uuid=graphene.String(required=True), - product_uuid=graphene.String(required=True), - description="Get offers from a supplier for a specific product", - ) - - products_near_hub = graphene.List( - ProductType, - hub_uuid=graphene.String(required=True), - radius_km=graphene.Float(default_value=500), - description="Get products available near a hub", - ) - - suppliers_for_product = graphene.List( - SupplierType, - product_uuid=graphene.String(required=True), - description="Get suppliers that offer a specific product", - ) - - hubs_for_product = graphene.List( - NodeType, - product_uuid=graphene.String(required=True), - radius_km=graphene.Float(default_value=500), - description="Get hubs where a product is available nearby", - ) - - offers_for_hub = graphene.List( - ProductRouteOptionType, - hub_uuid=graphene.String(required=True), - product_uuid=graphene.String(required=True), - limit=graphene.Int(default_value=10), - description="Get offers for a product with routes to hub (auto → rail* → auto)", - ) - - # New unified endpoints (coordinate-based) - nearest_hubs = graphene.List( - NodeType, - lat=graphene.Float(required=True, description="Latitude"), - lon=graphene.Float(required=True, description="Longitude"), - radius=graphene.Float(default_value=1000, description="Search radius in km"), - product_uuid=graphene.String(description="Filter hubs by product availability"), - source_uuid=graphene.String(description="Source node UUID for graph-based nearest hubs"), - use_graph=graphene.Boolean(default_value=False, description="Use graph-based reachability for product filter"), - limit=graphene.Int(default_value=12, description="Max results"), - description="Find nearest hubs to coordinates (optionally filtered by product)", - ) - - nearest_offers = graphene.List( - OfferWithRouteType, - lat=graphene.Float(required=True, description="Latitude"), - lon=graphene.Float(required=True, description="Longitude"), - radius=graphene.Float(default_value=500, description="Search radius in km"), - product_uuid=graphene.String(description="Filter by product UUID"), - hub_uuid=graphene.String(description="Hub UUID - if provided, calculates routes to this hub"), - limit=graphene.Int(default_value=50, description="Max results"), - description="Find nearest offers to coordinates with optional routes to hub", - ) - - quote_calculations = graphene.List( - OfferCalculationType, - lat=graphene.Float(required=True, description="Latitude"), - lon=graphene.Float(required=True, description="Longitude"), - radius=graphene.Float(default_value=500, description="Search radius in km"), - product_uuid=graphene.String(description="Filter by product UUID"), - hub_uuid=graphene.String(description="Hub UUID - if provided, calculates routes to this hub"), - quantity=graphene.Float(description="Requested quantity to cover (optional)"), - limit=graphene.Int(default_value=10, description="Max calculations"), - description="Get quote calculations (single offer or split offers)", - ) - - nearest_suppliers = graphene.List( - SupplierType, - lat=graphene.Float(required=True, description="Latitude"), - lon=graphene.Float(required=True, description="Longitude"), - radius=graphene.Float(default_value=1000, description="Search radius in km"), - product_uuid=graphene.String(description="Filter by product UUID"), - limit=graphene.Int(default_value=12, description="Max results"), - description="Find nearest suppliers to coordinates (optionally filtered by product)", - ) - - route_to_coordinate = graphene.Field( - ProductRouteOptionType, - offer_uuid=graphene.String(required=True, description="Starting offer UUID"), - lat=graphene.Float(required=True, description="Destination latitude"), - lon=graphene.Float(required=True, description="Destination longitude"), - description="Get route from offer to target coordinates (finds nearest hub to coordinate)", - ) - - # New unified list endpoints - hubs_list = graphene.List( - NodeType, - limit=graphene.Int(default_value=50, description="Max results"), - offset=graphene.Int(default_value=0, description="Offset for pagination"), - country=graphene.String(description="Filter by country name"), - transport_type=graphene.String(description="Filter by transport type"), - west=graphene.Float(description="Bounding box west longitude"), - south=graphene.Float(description="Bounding box south latitude"), - east=graphene.Float(description="Bounding box east longitude"), - north=graphene.Float(description="Bounding box north latitude"), - description="Get paginated list of logistics hubs", - ) - - suppliers_list = graphene.List( - SupplierType, - limit=graphene.Int(default_value=50, description="Max results"), - offset=graphene.Int(default_value=0, description="Offset for pagination"), - country=graphene.String(description="Filter by country name"), - west=graphene.Float(description="Bounding box west longitude"), - south=graphene.Float(description="Bounding box south latitude"), - east=graphene.Float(description="Bounding box east longitude"), - north=graphene.Float(description="Bounding box north latitude"), - description="Get paginated list of suppliers from graph", - ) - - products_list = graphene.List( - ProductType, - limit=graphene.Int(default_value=50, description="Max results"), - offset=graphene.Int(default_value=0, description="Offset for pagination"), - west=graphene.Float(description="Bounding box west longitude"), - south=graphene.Float(description="Bounding box south latitude"), - east=graphene.Float(description="Bounding box east longitude"), - north=graphene.Float(description="Bounding box north latitude"), - description="Get paginated list of products from graph", - ) - - @staticmethod - def _build_routes(db, from_uuid, to_uuid, limit): - """Shared helper to compute K shortest routes between two nodes.""" - aql = """ - FOR path IN ANY K_SHORTEST_PATHS - @from_vertex TO @to_vertex - GRAPH 'optovia_graph' - OPTIONS { weightAttribute: 'distance_km' } - LIMIT @limit - RETURN { - vertices: path.vertices, - edges: path.edges, - weight: path.weight - } - """ - - try: - cursor = db.aql.execute( - aql, - bind_vars={ - 'from_vertex': f'nodes/{from_uuid}', - 'to_vertex': f'nodes/{to_uuid}', - 'limit': limit, - }, - ) - paths = list(cursor) - except Exception as e: - logger.error("K_SHORTEST_PATHS query failed: %s", e) - return [] - - if not paths: - logger.info("No paths found from %s to %s", from_uuid, to_uuid) - return [] - - routes = [] - for path in paths: - vertices = path.get('vertices', []) - edges = path.get('edges', []) - - # Build vertex lookup by _id - vertex_by_id = {v['_id']: v for v in vertices} - - stages = [] - for edge in edges: - from_node = vertex_by_id.get(edge['_from'], {}) - to_node = vertex_by_id.get(edge['_to'], {}) - - stages.append(RouteStageType( - from_uuid=from_node.get('_key'), - from_name=from_node.get('name'), - from_lat=from_node.get('latitude'), - from_lon=from_node.get('longitude'), - to_uuid=to_node.get('_key'), - to_name=to_node.get('name'), - to_lat=to_node.get('latitude'), - to_lon=to_node.get('longitude'), - distance_km=edge.get('distance_km'), - travel_time_seconds=edge.get('travel_time_seconds'), - transport_type=edge.get('transport_type'), - )) - - total_time = sum(s.travel_time_seconds or 0 for s in stages) - - routes.append(RoutePathType( - total_distance_km=path.get('weight'), - total_time_seconds=total_time, - stages=stages, - )) - - return routes - - def resolve_node(self, info, uuid): - """ - Get a single node with all its outgoing edges. - - Returns node info + list of edges to neighbors with distances. - """ - db = get_db() - - # Get node - nodes_col = db.collection('nodes') - node = nodes_col.get(uuid) - if not node: - return None - - # Get all outgoing edges from this node - edges_col = db.collection('edges') - aql = """ - FOR edge IN edges - FILTER edge._from == @from_id - LET to_node = DOCUMENT(edge._to) - RETURN { - to_uuid: to_node._key, - to_name: to_node.name, - to_latitude: to_node.latitude, - to_longitude: to_node.longitude, - distance_km: edge.distance_km, - travel_time_seconds: edge.travel_time_seconds, - transport_type: edge.transport_type - } - """ - cursor = db.aql.execute(aql, bind_vars={'from_id': f"nodes/{uuid}"}) - edges = list(cursor) - - logger.info("Node %s has %d edges", uuid, len(edges)) - - return NodeType( - uuid=node['_key'], - name=node.get('name'), - latitude=node.get('latitude'), - longitude=node.get('longitude'), - country=node.get('country'), - country_code=node.get('country_code'), - synced_at=node.get('synced_at'), - transport_types=node.get('transport_types') or [], - edges=[EdgeType(**e) for e in edges], - ) - - def resolve_nodes(self, info, limit=None, offset=None, transport_type=None, country=None, search=None, - west=None, south=None, east=None, north=None): - """Get all logistics nodes (without edges for list view).""" - db = get_db() - - # Build bounds filter if all bounds are provided - bounds_filter = "" - if west is not None and south is not None and east is not None and north is not None: - bounds_filter = """ - FILTER node.latitude != null AND node.longitude != null - FILTER node.latitude >= @south AND node.latitude <= @north - FILTER node.longitude >= @west AND node.longitude <= @east - """ - - # Only return logistics nodes (not buyer/seller addresses) - aql = f""" - FOR node IN nodes - FILTER node.node_type == 'logistics' OR node.node_type == null - LET types = node.transport_types != null ? node.transport_types : [] - FILTER @transport_type == null OR @transport_type IN types - FILTER @country == null OR node.country == @country - FILTER @search == null OR CONTAINS(LOWER(node.name), LOWER(@search)) OR CONTAINS(LOWER(node.country), LOWER(@search)) - {bounds_filter} - SORT node.name ASC - LIMIT @offset, @limit - RETURN node - """ - bind_vars = { - 'transport_type': transport_type, - 'country': country, - 'search': search, - 'offset': 0 if offset is None else offset, - 'limit': 1000000 if limit is None else limit, - } - - # Only add bounds to bind_vars if they are used - if west is not None and south is not None and east is not None and north is not None: - bind_vars.update({ - 'west': west, - 'south': south, - 'east': east, - 'north': north, - }) - - cursor = db.aql.execute(aql, bind_vars=bind_vars) - - nodes = [] - for node in cursor: - nodes.append(NodeType( - uuid=node['_key'], - name=node.get('name'), - latitude=node.get('latitude'), - longitude=node.get('longitude'), - country=node.get('country'), - country_code=node.get('country_code'), - synced_at=node.get('synced_at'), - transport_types=node.get('transport_types') or [], - edges=[], # Don't load edges for list - )) - - logger.info("Returning %d nodes", len(nodes)) - return nodes - - def resolve_nodes_count(self, info, transport_type=None, country=None, - west=None, south=None, east=None, north=None): - db = get_db() - - # Build bounds filter if all bounds are provided - bounds_filter = "" - if west is not None and south is not None and east is not None and north is not None: - bounds_filter = """ - FILTER node.latitude != null AND node.longitude != null - FILTER node.latitude >= @south AND node.latitude <= @north - FILTER node.longitude >= @west AND node.longitude <= @east - """ - - aql = f""" - FOR node IN nodes - FILTER node.node_type == 'logistics' OR node.node_type == null - LET types = node.transport_types != null ? node.transport_types : [] - FILTER @transport_type == null OR @transport_type IN types - FILTER @country == null OR node.country == @country - {bounds_filter} - COLLECT WITH COUNT INTO length - RETURN length - """ - bind_vars = { - 'transport_type': transport_type, - 'country': country, - } - - # Only add bounds to bind_vars if they are used - if west is not None and south is not None and east is not None and north is not None: - bind_vars.update({ - 'west': west, - 'south': south, - 'east': east, - 'north': north, - }) - - cursor = db.aql.execute(aql, bind_vars=bind_vars) - return next(cursor, 0) - - def resolve_hub_countries(self, info): - """Get unique country names from logistics hubs.""" - db = get_db() - aql = """ - FOR node IN nodes - FILTER node.node_type == 'logistics' OR node.node_type == null - LET types = node.transport_types != null ? node.transport_types : [] - FILTER ('rail' IN types) OR ('sea' IN types) - FILTER node.country != null - COLLECT country = node.country - SORT country ASC - RETURN country - """ - cursor = db.aql.execute(aql) - return list(cursor) - - def resolve_nearest_nodes(self, info, lat, lon, limit=5): - """Find nearest logistics nodes to given coordinates.""" - db = get_db() - - # Get all logistics nodes and calculate distance - aql = """ - FOR node IN nodes - FILTER node.node_type == 'logistics' OR node.node_type == null - FILTER node.latitude != null AND node.longitude != null - LET dist = DISTANCE(node.latitude, node.longitude, @lat, @lon) / 1000 - SORT dist ASC - LIMIT @limit - RETURN MERGE(node, {distance_km: dist}) - """ - cursor = db.aql.execute( - aql, - bind_vars={'lat': lat, 'lon': lon, 'limit': limit}, - ) - - nodes = [] - for node in cursor: - nodes.append(NodeType( - uuid=node['_key'], - name=node.get('name'), - latitude=node.get('latitude'), - longitude=node.get('longitude'), - country=node.get('country'), - country_code=node.get('country_code'), - synced_at=node.get('synced_at'), - transport_types=node.get('transport_types') or [], - edges=[], - )) - - return nodes - - def resolve_node_connections(self, info, uuid, limit_auto=12, limit_rail=12): - """Get auto edges from hub and rail edges from nearest rail node.""" - db = get_db() - - nodes_col = db.collection('nodes') - hub = nodes_col.get(uuid) - if not hub: - return None - - aql = """ - LET auto_edges = ( - FOR edge IN edges - FILTER edge._from == @from_id AND edge.transport_type == "auto" - LET to_node = DOCUMENT(edge._to) - FILTER to_node != null - SORT edge.distance_km ASC - LIMIT @limit_auto - RETURN { - to_uuid: to_node._key, - to_name: to_node.name, - to_latitude: to_node.latitude, - to_longitude: to_node.longitude, - distance_km: edge.distance_km, - travel_time_seconds: edge.travel_time_seconds, - transport_type: edge.transport_type - } - ) - - LET hub_has_rail = @hub_has_rail - - LET rail_node = hub_has_rail ? DOCUMENT(@from_id) : FIRST( - FOR node IN nodes - FILTER node.latitude != null AND node.longitude != null - FILTER 'rail' IN node.transport_types - SORT DISTANCE(@hub_lat, @hub_lon, node.latitude, node.longitude) - LIMIT 1 - RETURN node - ) - - LET rail_edges = rail_node == null ? [] : ( - FOR edge IN edges - FILTER edge._from == CONCAT("nodes/", rail_node._key) AND edge.transport_type == "rail" - LET to_node = DOCUMENT(edge._to) - FILTER to_node != null - SORT edge.distance_km ASC - LIMIT @limit_rail - RETURN { - to_uuid: to_node._key, - to_name: to_node.name, - to_latitude: to_node.latitude, - to_longitude: to_node.longitude, - distance_km: edge.distance_km, - travel_time_seconds: edge.travel_time_seconds, - transport_type: edge.transport_type - } - ) - - RETURN { - hub: DOCUMENT(@from_id), - rail_node: rail_node, - auto_edges: auto_edges, - rail_edges: rail_edges - } - """ - - cursor = db.aql.execute( - aql, - bind_vars={ - 'from_id': f"nodes/{uuid}", - 'hub_lat': hub.get('latitude'), - 'hub_lon': hub.get('longitude'), - 'hub_has_rail': 'rail' in (hub.get('transport_types') or []), - 'limit_auto': limit_auto, - 'limit_rail': limit_rail, - }, - ) - result = next(cursor, None) - if not result: - return None - - def build_node(doc): - if not doc: - return None - return NodeType( - uuid=doc['_key'], - name=doc.get('name'), - latitude=doc.get('latitude'), - longitude=doc.get('longitude'), - country=doc.get('country'), - country_code=doc.get('country_code'), - synced_at=doc.get('synced_at'), - transport_types=doc.get('transport_types') or [], - edges=[], - ) - - return NodeConnectionsType( - hub=build_node(result.get('hub')), - rail_node=build_node(result.get('rail_node')), - auto_edges=[EdgeType(**e) for e in result.get('auto_edges') or []], - rail_edges=[EdgeType(**e) for e in result.get('rail_edges') or []], - ) - - def resolve_auto_route(self, info, from_lat, from_lon, to_lat, to_lon): - """Get auto route via GraphHopper.""" - url = f"{settings.GRAPHHOPPER_EXTERNAL_URL}/route" - params = { - 'point': [f"{from_lat},{from_lon}", f"{to_lat},{to_lon}"], - 'profile': 'car', - 'instructions': 'false', - 'calc_points': 'true', - 'points_encoded': 'false', - } - - try: - response = requests.get(url, params=params, timeout=30) - response.raise_for_status() - data = response.json() - - if 'paths' in data and len(data['paths']) > 0: - path = data['paths'][0] - distance_km = round(path.get('distance', 0) / 1000, 2) - points = path.get('points', {}) - coordinates = points.get('coordinates', []) - - return RouteType( - distance_km=distance_km, - geometry=coordinates, - ) - except requests.RequestException as e: - logger.error("GraphHopper request failed: %s", e) - - return None - - def resolve_rail_route(self, info, from_lat, from_lon, to_lat, to_lon): - """Get rail route via OpenRailRouting.""" - url = f"{settings.OPENRAILROUTING_EXTERNAL_URL}/route" - params = { - 'point': [f"{from_lat},{from_lon}", f"{to_lat},{to_lon}"], - 'profile': 'all_tracks', - 'calc_points': 'true', - 'points_encoded': 'false', - } - - try: - response = requests.get(url, params=params, timeout=60) - response.raise_for_status() - data = response.json() - - if 'paths' in data and len(data['paths']) > 0: - path = data['paths'][0] - distance_km = round(path.get('distance', 0) / 1000, 2) - points = path.get('points', {}) - coordinates = points.get('coordinates', []) - - return RouteType( - distance_km=distance_km, - geometry=coordinates, - ) - except requests.RequestException as e: - logger.error("OpenRailRouting request failed: %s", e) - - return None - - def resolve_clustered_nodes( - self, - info, - west, - south, - east, - north, - zoom, - transport_type=None, - node_type=None, - product_uuid=None, - hub_uuid=None, - supplier_uuid=None, - ): - """Get clustered nodes for map display using server-side SuperCluster.""" - db = get_db() - clusters = get_clustered_nodes( - db, - west, - south, - east, - north, - zoom, - transport_type, - node_type, - product_uuid, - hub_uuid, - supplier_uuid, - ) - return [ClusterPointType(**c) for c in clusters] - - def resolve_products(self, info): - """Get unique products from all offers.""" - db = get_db() - aql = """ - FOR node IN nodes - FILTER node.node_type == 'offer' - FILTER node.product_uuid != null - COLLECT product_uuid = node.product_uuid INTO offers - LET first_offer = FIRST(offers).node - RETURN { - uuid: product_uuid, - name: first_offer.product_name - } - """ - try: - cursor = db.aql.execute(aql) - products = [ProductType(uuid=p['uuid'], name=p.get('name')) for p in cursor] - logger.info("Found %d unique products", len(products)) - return products - except Exception as e: - logger.error("Error getting products: %s", e) - return [] - - def resolve_offers_by_product(self, info, product_uuid): - """Get all offers for a product.""" - db = get_db() - aql = """ - FOR node IN nodes - FILTER node.node_type == 'offer' - FILTER node.product_uuid == @product_uuid - RETURN node - """ - try: - cursor = db.aql.execute(aql, bind_vars={'product_uuid': product_uuid}) - offers = [] - for node in cursor: - offers.append(OfferNodeType( - uuid=node['_key'], - product_uuid=node.get('product_uuid'), - product_name=node.get('product_name'), - supplier_uuid=node.get('supplier_uuid'), - latitude=node.get('latitude'), - longitude=node.get('longitude'), - country=node.get('country'), - country_code=node.get('country_code'), - price_per_unit=node.get('price_per_unit'), - currency=node.get('currency'), - quantity=node.get('quantity'), - unit=node.get('unit'), - )) - logger.info("Found %d offers for product %s", len(offers), product_uuid) - return offers - except Exception as e: - logger.error("Error getting offers by product: %s", e) - return [] - - def resolve_hubs_near_offer(self, info, offer_uuid, limit=12): - """Get nearest hubs to an offer location (graph-based).""" - db = get_db() - nodes_col = db.collection('nodes') - offer = nodes_col.get(offer_uuid) - if not offer: - logger.info("Offer %s not found", offer_uuid) - return [] - - lat = offer.get('latitude') - lon = offer.get('longitude') - if lat is None or lon is None: - logger.info("Offer %s has no coordinates", offer_uuid) - return [] - - return self.resolve_nearest_hubs( - info, - lat=lat, - lon=lon, - source_uuid=offer_uuid, - limit=limit, - ) - - def resolve_suppliers(self, info): - """Get unique suppliers from all offers.""" - db = get_db() - aql = """ - FOR node IN nodes - FILTER node.node_type == 'offer' - FILTER node.supplier_uuid != null - COLLECT supplier_uuid = node.supplier_uuid - RETURN { uuid: supplier_uuid } - """ - try: - cursor = db.aql.execute(aql) - suppliers = [SupplierType(uuid=s['uuid']) for s in cursor] - logger.info("Found %d unique suppliers", len(suppliers)) - return suppliers - except Exception as e: - logger.error("Error getting suppliers: %s", e) - return [] - - def resolve_products_by_supplier(self, info, supplier_uuid): - """Get products offered by a supplier.""" - db = get_db() - aql = """ - FOR node IN nodes - FILTER node.node_type == 'offer' - FILTER node.supplier_uuid == @supplier_uuid - FILTER node.product_uuid != null - COLLECT product_uuid = node.product_uuid INTO offers - LET first_offer = FIRST(offers).node - RETURN { - uuid: product_uuid, - name: first_offer.product_name - } - """ - try: - cursor = db.aql.execute(aql, bind_vars={'supplier_uuid': supplier_uuid}) - products = [ProductType(uuid=p['uuid'], name=p.get('name')) for p in cursor] - logger.info("Found %d products for supplier %s", len(products), supplier_uuid) - return products - except Exception as e: - logger.error("Error getting products by supplier: %s", e) - return [] - - def resolve_offers_by_supplier_product(self, info, supplier_uuid, product_uuid): - """Get offers from a supplier for a specific product.""" - db = get_db() - aql = """ - FOR node IN nodes - FILTER node.node_type == 'offer' - FILTER node.supplier_uuid == @supplier_uuid - FILTER node.product_uuid == @product_uuid - RETURN node - """ - try: - cursor = db.aql.execute(aql, bind_vars={ - 'supplier_uuid': supplier_uuid, - 'product_uuid': product_uuid - }) - offers = [] - for node in cursor: - offers.append(OfferNodeType( - uuid=node['_key'], - product_uuid=node.get('product_uuid'), - product_name=node.get('product_name'), - supplier_uuid=node.get('supplier_uuid'), - latitude=node.get('latitude'), - longitude=node.get('longitude'), - country=node.get('country'), - country_code=node.get('country_code'), - price_per_unit=node.get('price_per_unit'), - currency=node.get('currency'), - quantity=node.get('quantity'), - unit=node.get('unit'), - )) - logger.info("Found %d offers for supplier %s product %s", len(offers), supplier_uuid, product_uuid) - return offers - except Exception as e: - logger.error("Error getting offers by supplier product: %s", e) - return [] - - def resolve_products_near_hub(self, info, hub_uuid, radius_km=500): - """Get products available via graph routes from a hub.""" - db = get_db() - nodes_col = db.collection('nodes') - hub = nodes_col.get(hub_uuid) - if not hub: - logger.info("Hub %s not found", hub_uuid) - return [] - - matches = graph_find_targets( - db, - start_uuid=hub_uuid, - target_predicate=lambda doc: doc.get('node_type') == 'offer', - route_builder=_build_route_from_edges, - limit=1000, - max_expansions=Query.MAX_EXPANSIONS, - ) - - products = {} - for match in matches: - offer = match.get('node') or {} - product_uuid = offer.get('product_uuid') - if not product_uuid: - continue - if product_uuid not in products: - products[product_uuid] = offer.get('product_name') - - result = [ProductType(uuid=uuid, name=name) for uuid, name in products.items()] - logger.info("Found %d products via graph for hub %s", len(result), hub_uuid) - return result - - def resolve_suppliers_for_product(self, info, product_uuid): - """Get unique suppliers that have offers for this product.""" - db = get_db() - aql = """ - FOR node IN nodes - FILTER node.node_type == 'offer' - FILTER node.product_uuid == @product_uuid - FILTER node.supplier_uuid != null - COLLECT supplier_uuid = node.supplier_uuid - RETURN { uuid: supplier_uuid } - """ - try: - cursor = db.aql.execute(aql, bind_vars={'product_uuid': product_uuid}) - suppliers = [SupplierType(uuid=s['uuid']) for s in cursor] - logger.info("Found %d suppliers for product %s", len(suppliers), product_uuid) - return suppliers - except Exception as e: - logger.error("Error getting suppliers for product: %s", e) - return [] - - def resolve_hubs_for_product(self, info, product_uuid, radius_km=500): - """Get hubs that can accept this product (graph-based).""" - return self.resolve_hubs_for_product_graph(info, product_uuid) - - def resolve_hubs_for_product_graph(self, info, product_uuid, limit=500): - """Get hubs that can be reached by graph routes for a product.""" - db = get_db() - ensure_graph() - - aql = """ - FOR hub IN nodes - FILTER hub.node_type == 'logistics' OR hub.node_type == null - LET types = hub.transport_types != null ? hub.transport_types : [] - FILTER ('rail' IN types) OR ('sea' IN types) - FILTER hub.latitude != null AND hub.longitude != null - RETURN hub - """ - try: - cursor = db.aql.execute(aql) - hubs_with_distance = [] - for hub in cursor: - hub_uuid = hub.get('_key') - if not hub_uuid: - continue - try: - routes = self.resolve_offers_by_hub(info, hub_uuid, product_uuid, limit=1) - except Exception as route_error: - logger.error("Error resolving offers for hub %s: %s", hub_uuid, route_error) - continue - if not routes: - continue - distance_km = routes[0].distance_km if routes[0] else None - hubs_with_distance.append((distance_km, hub)) - - # Sort by graph distance when available - hubs_with_distance.sort(key=lambda item: (item[0] is None, item[0] or 0)) - - hubs = [] - for distance_km, hub in hubs_with_distance[:limit]: - hubs.append(NodeType( - uuid=hub.get('_key'), - name=hub.get('name'), - latitude=hub.get('latitude'), - longitude=hub.get('longitude'), - country=hub.get('country'), - country_code=hub.get('country_code'), - transport_types=hub.get('transport_types'), - distance_km=distance_km, - )) - - logger.info("Found %d graph-reachable hubs for product %s", len(hubs), product_uuid) - return hubs - except Exception as e: - logger.error("Error getting graph hubs for product %s: %s", product_uuid, e) - return [] - - def resolve_offers_for_hub(self, info, hub_uuid, product_uuid, limit=10): - """Alias for offers by hub (graph-based).""" - return self.resolve_offers_by_hub(info, hub_uuid, product_uuid, limit=limit) - - def resolve_offers_by_hub(self, info, hub_uuid, product_uuid=None, limit=10): - """ - Get offers for a product with routes to hub. - Uses unified graph routing: auto → rail* → auto - """ - db = get_db() - nodes_col = db.collection('nodes') - - hub = nodes_col.get(hub_uuid) - if not hub: - logger.info("Hub %s not found", hub_uuid) - return [] - - hub_lat = hub.get('latitude') - hub_lon = hub.get('longitude') - if hub_lat is None or hub_lon is None: - logger.info("Hub %s missing coordinates", hub_uuid) - return [] - - matches = graph_find_targets( - db, - start_uuid=hub_uuid, - target_predicate=lambda doc: doc.get('node_type') == 'offer' and ( - product_uuid is None or doc.get('product_uuid') == product_uuid - ), - route_builder=_build_route_from_edges, - limit=limit, - max_expansions=Query.MAX_EXPANSIONS, - ) - - found_routes = [] - for match in matches: - node_doc = match.get('node') or {} - route = match.get('route') - distance_km = match.get('distance_km') - if distance_km is None: - src_lat = node_doc.get('latitude') - src_lon = node_doc.get('longitude') - if src_lat is not None and src_lon is not None: - distance_km = _distance_km(src_lat, src_lon, hub_lat, hub_lon) - - found_routes.append(ProductRouteOptionType( - source_uuid=node_doc.get('_key'), - source_name=node_doc.get('name') or node_doc.get('product_name'), - source_lat=node_doc.get('latitude'), - source_lon=node_doc.get('longitude'), - distance_km=distance_km, - routes=[route] if route else [], - )) - - if not found_routes: - logger.info("No offers found near hub %s", hub_uuid) - return [] - - if product_uuid: - logger.info("Found %d offers for product %s near hub %s", len(found_routes), product_uuid, hub_uuid) - else: - logger.info("Found %d offers near hub %s", len(found_routes), hub_uuid) - return found_routes - - def resolve_nearest_hubs(self, info, lat, lon, radius=1000, product_uuid=None, source_uuid=None, use_graph=False, limit=12): - """Find nearest hubs to coordinates, optionally filtered by product availability.""" - db = get_db() - - # Graph-based nearest hubs when source_uuid provided - if source_uuid: - start_hub = resolve_start_hub(db, source_uuid=source_uuid) - if not start_hub: - logger.warning("Source node %s not found for nearest hubs, falling back to coordinate search", source_uuid) - else: - start_uuid = start_hub.get('_key') - - def is_target_hub(doc): - if doc.get('_key') == start_uuid: - return False - if doc.get('node_type') not in ('logistics', None): - return False - types = doc.get('transport_types') or [] - return ('rail' in types) or ('sea' in types) - - matches = graph_find_targets( - db, - start_uuid=start_uuid, - target_predicate=is_target_hub, - route_builder=_build_route_from_edges, - limit=limit, - max_expansions=Query.MAX_EXPANSIONS, - ) - - hubs = [] - for match in matches: - node = match.get('node') or {} - hubs.append(NodeType( - uuid=node.get('_key'), - name=node.get('name'), - latitude=node.get('latitude'), - longitude=node.get('longitude'), - country=node.get('country'), - country_code=node.get('country_code'), - synced_at=node.get('synced_at'), - transport_types=node.get('transport_types') or [], - edges=[], - distance_km=match.get('distance_km'), - )) - return hubs - - if product_uuid: - return self.resolve_hubs_for_product_graph(info, product_uuid, limit=limit) - - start_hub = resolve_start_hub(db, lat=lat, lon=lon) - if not start_hub: - return [] - - start_uuid = start_hub.get('_key') - - def is_target_hub(doc): - if doc.get('_key') == start_uuid: - return False - if doc.get('node_type') not in ('logistics', None): - return False - types = doc.get('transport_types') or [] - return ('rail' in types) or ('sea' in types) - - matches = graph_find_targets( - db, - start_uuid=start_uuid, - target_predicate=is_target_hub, - route_builder=_build_route_from_edges, - limit=max(limit - 1, 0), - max_expansions=Query.MAX_EXPANSIONS, - ) - - hubs = [ - NodeType( - uuid=start_hub.get('_key'), - name=start_hub.get('name'), - latitude=start_hub.get('latitude'), - longitude=start_hub.get('longitude'), - country=start_hub.get('country'), - country_code=start_hub.get('country_code'), - synced_at=start_hub.get('synced_at'), - transport_types=start_hub.get('transport_types') or [], - edges=[], - distance_km=0, - ) - ] - - for match in matches: - node = match.get('node') or {} - hubs.append(NodeType( - uuid=node.get('_key'), - name=node.get('name'), - latitude=node.get('latitude'), - longitude=node.get('longitude'), - country=node.get('country'), - country_code=node.get('country_code'), - synced_at=node.get('synced_at'), - transport_types=node.get('transport_types') or [], - edges=[], - distance_km=match.get('distance_km'), - )) - - logger.info("Found %d hubs via graph near (%.3f, %.3f)", len(hubs), lat, lon) - return hubs[:limit] - - def resolve_nearest_offers(self, info, lat, lon, radius=500, product_uuid=None, hub_uuid=None, limit=50): - """Find nearest offers to coordinates, optionally filtered by product. If hub_uuid provided, calculates routes.""" - db = get_db() - try: - nodes_col = db.collection('nodes') - - start_hub = resolve_start_hub(db, source_uuid=hub_uuid, lat=lat, lon=lon) - if not start_hub: - logger.info("No hub found near coordinates (%.3f, %.3f)", lat, lon) - return [] - - hub_uuid = start_hub.get('_key') - expanded_limit = max(limit * 5, limit) - route_options = Query.resolve_offers_by_hub( - Query, info, hub_uuid, product_uuid, expanded_limit - ) - offers = [] - for option in route_options or []: - if not option.routes: - continue - node = nodes_col.get(option.source_uuid) - if not node: - continue - offers.append(OfferWithRouteType( - uuid=node['_key'], - product_uuid=node.get('product_uuid'), - product_name=node.get('product_name'), - supplier_uuid=node.get('supplier_uuid'), - supplier_name=node.get('supplier_name'), - latitude=node.get('latitude'), - longitude=node.get('longitude'), - country=node.get('country'), - country_code=node.get('country_code'), - price_per_unit=node.get('price_per_unit'), - currency=node.get('currency'), - quantity=node.get('quantity'), - unit=node.get('unit'), - distance_km=option.distance_km, - routes=option.routes, - )) - if len(offers) >= limit: - break - logger.info("Found %d offers by graph for hub %s", len(offers), hub_uuid) - return offers - except Exception as e: - logger.error("Error finding nearest offers: %s", e) - return [] - - def resolve_quote_calculations(self, info, lat, lon, radius=500, product_uuid=None, hub_uuid=None, quantity=None, limit=10): - """Return calculations as arrays of offers. If quantity provided, may return split offers.""" - def _parse_number(value): - if value is None: - return None - try: - return float(str(value).replace(',', '.')) - except (TypeError, ValueError): - return None - - def _offer_quantity(offer): - return _parse_number(getattr(offer, 'quantity', None)) - - def _offer_price(offer): - return _parse_number(getattr(offer, 'price_per_unit', None)) - - def _offer_distance(offer): - if getattr(offer, 'distance_km', None) is not None: - return offer.distance_km or 0 - if getattr(offer, 'routes', None): - route = offer.routes[0] if offer.routes else None - if route and route.total_distance_km is not None: - return route.total_distance_km or 0 - return 0 - - offers = self.resolve_nearest_offers( - info, - lat=lat, - lon=lon, - radius=radius, - product_uuid=product_uuid, - hub_uuid=hub_uuid, - limit=max(limit * 4, limit), - ) - - if not offers: - return [] - - quantity_value = _parse_number(quantity) - offers_sorted = sorted( - offers, - key=lambda o: ( - _offer_price(o) if _offer_price(o) is not None else float('inf'), - _offer_distance(o), - ), - ) - - calculations = [] - - # If no requested quantity, return single-offer calculations. - if not quantity_value or quantity_value <= 0: - return [OfferCalculationType(offers=[offer]) for offer in offers_sorted[:limit]] - - # Try single offer that covers quantity. - single = next( - (offer for offer in offers_sorted if (_offer_quantity(offer) or 0) >= quantity_value), - None, - ) - if single: - calculations.append(OfferCalculationType(offers=[single])) - - # Build a split offer (two offers) if possible. - if len(offers_sorted) >= 2: - primary = offers_sorted[0] - remaining = quantity_value - (_offer_quantity(primary) or 0) - if remaining <= 0: - secondary = offers_sorted[1] - else: - secondary = next( - (offer for offer in offers_sorted[1:] if (_offer_quantity(offer) or 0) >= remaining), - offers_sorted[1], - ) - if secondary: - calculations.append(OfferCalculationType(offers=[primary, secondary])) - - # Fallback: ensure at least one calculation. - if not calculations: - calculations.append(OfferCalculationType(offers=[offers_sorted[0]])) - - return calculations[:limit] - - def resolve_nearest_suppliers(self, info, lat, lon, radius=1000, product_uuid=None, limit=12): - """Find nearest suppliers to coordinates, optionally filtered by product.""" - db = get_db() - - if product_uuid: - aql = """ - FOR offer IN nodes - FILTER offer.node_type == 'offer' - FILTER offer.supplier_uuid != null - FILTER offer.product_uuid == @product_uuid - COLLECT supplier_uuid = offer.supplier_uuid INTO offers - LET first_offer = FIRST(offers).offer - - // Try to find supplier node for full info - LET supplier_node = FIRST( - FOR s IN nodes - FILTER s._key == supplier_uuid - FILTER s.node_type == 'supplier' - RETURN s - ) - - LIMIT @limit - RETURN { - uuid: supplier_uuid, - name: supplier_node != null ? supplier_node.name : first_offer.supplier_name, - latitude: supplier_node != null ? supplier_node.latitude : first_offer.latitude, - longitude: supplier_node != null ? supplier_node.longitude : first_offer.longitude, - distance_km: null - } - """ - bind_vars = {'product_uuid': product_uuid, 'limit': limit} - else: - aql = """ - FOR offer IN nodes - FILTER offer.node_type == 'offer' - FILTER offer.supplier_uuid != null - FILTER offer.latitude != null AND offer.longitude != null - LET dist = DISTANCE(offer.latitude, offer.longitude, @lat, @lon) / 1000 - COLLECT supplier_uuid = offer.supplier_uuid INTO offers - LET first_offer = FIRST(offers).offer - LET supplier_dist = DISTANCE(first_offer.latitude, first_offer.longitude, @lat, @lon) / 1000 - - // Try to find supplier node for full info - LET supplier_node = FIRST( - FOR s IN nodes - FILTER s._key == supplier_uuid - FILTER s.node_type == 'supplier' - RETURN s - ) - - SORT supplier_dist ASC - LIMIT @limit - RETURN { - uuid: supplier_uuid, - name: supplier_node != null ? supplier_node.name : first_offer.supplier_name, - latitude: supplier_node != null ? supplier_node.latitude : first_offer.latitude, - longitude: supplier_node != null ? supplier_node.longitude : first_offer.longitude, - distance_km: supplier_dist - } - """ - bind_vars = {'lat': lat, 'lon': lon, 'limit': limit} - - try: - cursor = db.aql.execute(aql, bind_vars=bind_vars) - suppliers = [] - for s in cursor: - suppliers.append(SupplierType( - uuid=s['uuid'], - name=s.get('name'), - latitude=s.get('latitude'), - longitude=s.get('longitude'), - distance_km=s.get('distance_km'), - )) - if product_uuid: - logger.info("Found %d suppliers for product %s", len(suppliers), product_uuid) - else: - logger.info("Found %d suppliers near (%.3f, %.3f)", len(suppliers), lat, lon) - return suppliers - except Exception as e: - logger.error("Error finding nearest suppliers: %s", e) - return [] - - def resolve_route_to_coordinate(self, info, offer_uuid, lat, lon): - """Get route from offer to target coordinates (finds nearest hub automatically).""" - db = get_db() - nodes_col = db.collection('nodes') - - try: - offer = nodes_col.get(offer_uuid) - if not offer: - logger.info("Offer %s not found", offer_uuid) - return None - - nearest_hub = snap_to_nearest_hub(db, lat, lon) - if not nearest_hub: - logger.info("No hub found near coordinates (%.3f, %.3f)", lat, lon) - return None - - hub_uuid = nearest_hub['_key'] - logger.info("Found nearest hub %s to coordinates (%.3f, %.3f)", hub_uuid, lat, lon) - - matches = graph_find_targets( - db, - start_uuid=hub_uuid, - target_predicate=lambda doc: doc.get('_key') == offer_uuid, - route_builder=_build_route_from_edges, - limit=1, - max_expansions=Query.MAX_EXPANSIONS, - ) - if not matches: - return None - - match = matches[0] - route = match.get('route') - distance_km = match.get('distance_km') - if distance_km is None: - src_lat = offer.get('latitude') - src_lon = offer.get('longitude') - if src_lat is not None and src_lon is not None: - distance_km = _distance_km(src_lat, src_lon, nearest_hub.get('latitude'), nearest_hub.get('longitude')) - - return ProductRouteOptionType( - source_uuid=offer.get('_key'), - source_name=offer.get('name') or offer.get('product_name'), - source_lat=offer.get('latitude'), - source_lon=offer.get('longitude'), - distance_km=distance_km, - routes=[route] if route else [], - ) - except Exception as e: - logger.error("Error finding route to coordinates: %s", e) - return None - - def resolve_hubs_list(self, info, limit=50, offset=0, country=None, transport_type=None, - west=None, south=None, east=None, north=None): - """Get paginated list of logistics hubs.""" - db = get_db() - - # Build bounds filter if all bounds are provided - bounds_filter = "" - if west is not None and south is not None and east is not None and north is not None: - bounds_filter = """ - FILTER node.latitude != null AND node.longitude != null - FILTER node.latitude >= @south AND node.latitude <= @north - FILTER node.longitude >= @west AND node.longitude <= @east - """ - - aql = f""" - FOR node IN nodes - FILTER node.node_type == 'logistics' OR node.node_type == null - FILTER node.product_uuid == null - LET types = node.transport_types != null ? node.transport_types : [] - FILTER @transport_type == null OR @transport_type IN types - FILTER @transport_type != null OR ('rail' IN types) OR ('sea' IN types) - FILTER @country == null OR node.country == @country - {bounds_filter} - SORT node.name ASC - LIMIT @offset, @limit - RETURN node - """ - - bind_vars = { - 'transport_type': transport_type, - 'country': country, - 'offset': offset, - 'limit': limit, - } - - # Only add bounds to bind_vars if they are used - if west is not None and south is not None and east is not None and north is not None: - bind_vars.update({ - 'west': west, - 'south': south, - 'east': east, - 'north': north, - }) - - try: - cursor = db.aql.execute(aql, bind_vars=bind_vars) - - hubs = [] - for node in cursor: - hubs.append(NodeType( - uuid=node['_key'], - name=node.get('name'), - latitude=node.get('latitude'), - longitude=node.get('longitude'), - country=node.get('country'), - country_code=node.get('country_code'), - synced_at=node.get('synced_at'), - transport_types=node.get('transport_types') or [], - edges=[], - )) - logger.info("Returning %d hubs (offset=%d, limit=%d)", len(hubs), offset, limit) - return hubs - except Exception as e: - logger.error("Error getting hubs list: %s", e) - return [] - - def resolve_suppliers_list(self, info, limit=50, offset=0, country=None, - west=None, south=None, east=None, north=None): - """Get paginated list of suppliers from graph.""" - db = get_db() - - # Build bounds filter if all bounds are provided - bounds_filter = "" - if west is not None and south is not None and east is not None and north is not None: - bounds_filter = """ - FILTER node.latitude != null AND node.longitude != null - FILTER node.latitude >= @south AND node.latitude <= @north - FILTER node.longitude >= @west AND node.longitude <= @east - """ - - aql = f""" - FOR node IN nodes - FILTER node.node_type == 'supplier' - FILTER @country == null OR node.country == @country - {bounds_filter} - SORT node.name ASC - LIMIT @offset, @limit - RETURN node - """ - - bind_vars = { - 'country': country, - 'offset': offset, - 'limit': limit, - } - - # Only add bounds to bind_vars if they are used - if west is not None and south is not None and east is not None and north is not None: - bind_vars.update({ - 'west': west, - 'south': south, - 'east': east, - 'north': north, - }) - - try: - cursor = db.aql.execute(aql, bind_vars=bind_vars) - - suppliers = [] - for node in cursor: - suppliers.append(SupplierType( - uuid=node['_key'], - name=node.get('name'), - latitude=node.get('latitude'), - longitude=node.get('longitude'), - )) - logger.info("Returning %d suppliers (offset=%d, limit=%d)", len(suppliers), offset, limit) - return suppliers - except Exception as e: - logger.error("Error getting suppliers list: %s", e) - return [] - - def resolve_products_list(self, info, limit=50, offset=0, - west=None, south=None, east=None, north=None): - """Get paginated list of products from graph.""" - db = get_db() - - # Build bounds filter if all bounds are provided - bounds_filter = "" - if west is not None and south is not None and east is not None and north is not None: - bounds_filter = """ - FILTER node.latitude != null AND node.longitude != null - FILTER node.latitude >= @south AND node.latitude <= @north - FILTER node.longitude >= @west AND node.longitude <= @east - """ - - aql = f""" - FOR node IN nodes - FILTER node.node_type == 'offer' - FILTER node.product_uuid != null - {bounds_filter} - COLLECT product_uuid = node.product_uuid INTO offers - LET first_offer = FIRST(offers).node - SORT first_offer.product_name ASC - LIMIT @offset, @limit - RETURN {{ - uuid: product_uuid, - name: first_offer.product_name - }} - """ - - bind_vars = { - 'offset': offset, - 'limit': limit, - } - - # Only add bounds to bind_vars if they are used - if west is not None and south is not None and east is not None and north is not None: - bind_vars.update({ - 'west': west, - 'south': south, - 'east': east, - 'north': north, - }) - - try: - cursor = db.aql.execute(aql, bind_vars=bind_vars) - - products = [ProductType(uuid=p['uuid'], name=p.get('name')) for p in cursor] - logger.info("Returning %d products (offset=%d, limit=%d)", len(products), offset, limit) - return products - except Exception as e: - logger.error("Error getting products list: %s", e) - return [] - - -schema = graphene.Schema(query=Query) - -# Helper methods attached to Query for route assembly -def _build_stage(from_doc, to_doc, transport_type, edges): - distance_km = sum(e.get('distance_km') or 0 for e in edges) - travel_time = sum(e.get('travel_time_seconds') or 0 for e in edges) - return RouteStageType( - from_uuid=from_doc.get('_key') if from_doc else None, - from_name=from_doc.get('name') if from_doc else None, - from_lat=from_doc.get('latitude') if from_doc else None, - from_lon=from_doc.get('longitude') if from_doc else None, - to_uuid=to_doc.get('_key') if to_doc else None, - to_name=to_doc.get('name') if to_doc else None, - to_lat=to_doc.get('latitude') if to_doc else None, - to_lon=to_doc.get('longitude') if to_doc else None, - distance_km=distance_km, - travel_time_seconds=travel_time, - transport_type=transport_type, - ) - - -def _build_route_from_edges(path_edges, node_docs): - """Собрать RoutePathType из списка ребёр (source->dest), схлопывая типы.""" - if not path_edges: - return None - - # Фильтруем offer edges - это не транспортные этапы, а связь оффера с локацией - path_edges = [(f, t, e) for f, t, e in path_edges if e.get('transport_type') != 'offer'] - if not path_edges: - return None - - stages = [] - current_edges = [] - current_type = None - segment_start = None - - for from_key, to_key, edge in path_edges: - edge_type = edge.get('transport_type') - if current_type is None: - current_type = edge_type - current_edges = [edge] - segment_start = from_key - elif edge_type == current_type: - current_edges.append(edge) - else: - # закрываем предыдущий сегмент - stages.append(_build_stage( - node_docs.get(segment_start), - node_docs.get(from_key), - current_type, - current_edges, - )) - current_type = edge_type - current_edges = [edge] - segment_start = from_key - - # Последний сгусток - last_to = path_edges[-1][1] - stages.append(_build_stage( - node_docs.get(segment_start), - node_docs.get(last_to), - current_type, - current_edges, - )) - - total_distance = sum(s.distance_km or 0 for s in stages) - total_time = sum(s.travel_time_seconds or 0 for s in stages) - - return RoutePathType( - total_distance_km=total_distance, - total_time_seconds=total_time, - stages=stages, - ) - - -# Bind helpers to class for access in resolver -Query._build_route_from_edges = _build_route_from_edges - - -def _distance_km(lat1, lon1, lat2, lon2): - """Haversine distance in km.""" - r = 6371 - d_lat = math.radians(lat2 - lat1) - d_lon = math.radians(lon2 - lon1) - a = ( - math.sin(d_lat / 2) ** 2 - + math.cos(math.radians(lat1)) - * math.cos(math.radians(lat2)) - * math.sin(d_lon / 2) ** 2 - ) - c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) - return r * c - - -Query._distance_km = _distance_km diff --git a/manage.py b/manage.py deleted file mode 100644 index 883307e..0000000 --- a/manage.py +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env python -"""Django's command-line utility for administrative tasks.""" -import os -import sys - -if __name__ == '__main__': - """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'geo.settings') - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) diff --git a/nixpacks.toml b/nixpacks.toml deleted file mode 100644 index b2a8f09..0000000 --- a/nixpacks.toml +++ /dev/null @@ -1,18 +0,0 @@ -providers = ["python"] - -[build] - -[phases.install] -cmds = [ - "python -m venv --copies /opt/venv", - ". /opt/venv/bin/activate", - "pip install poetry==$NIXPACKS_POETRY_VERSION", - "poetry install --no-interaction --no-ansi" -] - -[start] -cmd = "poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn geo.wsgi:application --bind 0.0.0.0:${PORT:-8000}" - -[variables] -# Set Poetry version to match local environment -NIXPACKS_POETRY_VERSION = "2.2.1" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fb66e65 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3732 @@ +{ + "name": "geo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "geo", + "version": "1.0.0", + "dependencies": { + "@apollo/server": "^4.11.3", + "@sentry/node": "^9.5.0", + "arangojs": "^9.2.0", + "cors": "^2.8.5", + "express": "^5.0.1", + "h3-js": "^4.2.1" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/node": "^22.13.0", + "tsx": "^4.19.3", + "typescript": "^5.7.3" + } + }, + "node_modules/@apollo/cache-control-types": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", + "integrity": "sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g==", + "license": "MIT", + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/protobufjs": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.7.tgz", + "integrity": "sha512-Lahx5zntHPZia35myYDBRuF58tlwPskwHc5CWBZC/4bMKB6siTBWwtMrkqXcsNwQiFSzSx5hKdRPUmemrEp3Gg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.0", + "long": "^4.0.0" + }, + "bin": { + "apollo-pbjs": "bin/pbjs", + "apollo-pbts": "bin/pbts" + } + }, + "node_modules/@apollo/server": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@apollo/server/-/server-4.13.0.tgz", + "integrity": "sha512-t4GzaRiYIcPwYy40db6QjZzgvTr9ztDKBddykUXmBb2SVjswMKXbkaJ5nPeHqmT3awr9PAaZdCZdZhRj55I/8A==", + "deprecated": "Apollo Server v4 is end-of-life since January 26, 2026. As long as you are already using a non-EOL version of Node.js, upgrading to v5 should take only a few minutes. See https://www.apollographql.com/docs/apollo-server/previous-versions for details.", + "license": "MIT", + "dependencies": { + "@apollo/cache-control-types": "^1.0.3", + "@apollo/server-gateway-interface": "^1.1.1", + "@apollo/usage-reporting-protobuf": "^4.1.1", + "@apollo/utils.createhash": "^2.0.2", + "@apollo/utils.fetcher": "^2.0.0", + "@apollo/utils.isnodelike": "^2.0.0", + "@apollo/utils.keyvaluecache": "^2.1.0", + "@apollo/utils.logger": "^2.0.0", + "@apollo/utils.usagereporting": "^2.1.0", + "@apollo/utils.withrequired": "^2.0.0", + "@graphql-tools/schema": "^9.0.0", + "@types/express": "^4.17.13", + "@types/express-serve-static-core": "^4.17.30", + "@types/node-fetch": "^2.6.1", + "async-retry": "^1.2.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "express": "^4.21.1", + "loglevel": "^1.6.8", + "lru-cache": "^7.10.1", + "negotiator": "^0.6.3", + "node-abort-controller": "^3.1.1", + "node-fetch": "^2.6.7", + "uuid": "^9.0.0", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=14.16.0" + }, + "peerDependencies": { + "graphql": "^16.6.0" + } + }, + "node_modules/@apollo/server-gateway-interface": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@apollo/server-gateway-interface/-/server-gateway-interface-1.1.1.tgz", + "integrity": "sha512-pGwCl/po6+rxRmDMFgozKQo2pbsSwE91TpsDBAOgf74CRDPXHHtM88wbwjab0wMMZh95QfR45GGyDIdhY24bkQ==", + "deprecated": "@apollo/server-gateway-interface v1 is part of Apollo Server v4, which is deprecated and will transition to end-of-life on January 26, 2026. As long as you are already using a non-EOL version of Node.js, upgrading to v2 should take only a few minutes. See https://www.apollographql.com/docs/apollo-server/previous-versions for details.", + "license": "MIT", + "dependencies": { + "@apollo/usage-reporting-protobuf": "^4.1.1", + "@apollo/utils.fetcher": "^2.0.0", + "@apollo/utils.keyvaluecache": "^2.1.0", + "@apollo/utils.logger": "^2.0.0" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/server/node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@apollo/server/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@apollo/server/node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@apollo/server/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@apollo/server/node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/@apollo/server/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@apollo/server/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/@apollo/server/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@apollo/server/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/@apollo/server/node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@apollo/server/node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@apollo/server/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@apollo/server/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@apollo/server/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@apollo/server/node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@apollo/server/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@apollo/server/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@apollo/server/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@apollo/server/node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/@apollo/server/node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@apollo/server/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@apollo/server/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@apollo/server/node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@apollo/server/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@apollo/usage-reporting-protobuf": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@apollo/usage-reporting-protobuf/-/usage-reporting-protobuf-4.1.1.tgz", + "integrity": "sha512-u40dIUePHaSKVshcedO7Wp+mPiZsaU6xjv9J+VyxpoU/zL6Jle+9zWeG98tr/+SZ0nZ4OXhrbb8SNr0rAPpIDA==", + "license": "MIT", + "dependencies": { + "@apollo/protobufjs": "1.2.7" + } + }, + "node_modules/@apollo/utils.createhash": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@apollo/utils.createhash/-/utils.createhash-2.0.2.tgz", + "integrity": "sha512-UkS3xqnVFLZ3JFpEmU/2cM2iKJotQXMoSTgxXsfQgXLC5gR1WaepoXagmYnPSA7Q/2cmnyTYK5OgAgoC4RULPg==", + "license": "MIT", + "dependencies": { + "@apollo/utils.isnodelike": "^2.0.1", + "sha.js": "^2.4.11" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@apollo/utils.dropunuseddefinitions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.dropunuseddefinitions/-/utils.dropunuseddefinitions-2.0.1.tgz", + "integrity": "sha512-EsPIBqsSt2BwDsv8Wu76LK5R1KtsVkNoO4b0M5aK0hx+dGg9xJXuqlr7Fo34Dl+y83jmzn+UvEW+t1/GP2melA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.fetcher": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.fetcher/-/utils.fetcher-2.0.1.tgz", + "integrity": "sha512-jvvon885hEyWXd4H6zpWeN3tl88QcWnHp5gWF5OPF34uhvoR+DFqcNxs9vrRaBBSY3qda3Qe0bdud7tz2zGx1A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@apollo/utils.isnodelike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.isnodelike/-/utils.isnodelike-2.0.1.tgz", + "integrity": "sha512-w41XyepR+jBEuVpoRM715N2ZD0xMD413UiJx8w5xnAZD2ZkSJnMJBoIzauK83kJpSgNuR6ywbV29jG9NmxjK0Q==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@apollo/utils.keyvaluecache": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-2.1.1.tgz", + "integrity": "sha512-qVo5PvUUMD8oB9oYvq4ViCjYAMWnZ5zZwEjNF37L2m1u528x5mueMlU+Cr1UinupCgdB78g+egA1G98rbJ03Vw==", + "license": "MIT", + "dependencies": { + "@apollo/utils.logger": "^2.0.1", + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@apollo/utils.logger": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-2.0.1.tgz", + "integrity": "sha512-YuplwLHaHf1oviidB7MxnCXAdHp3IqYV8n0momZ3JfLniae92eYqMIx+j5qJFX6WKJPs6q7bczmV4lXIsTu5Pg==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@apollo/utils.printwithreducedwhitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.printwithreducedwhitespace/-/utils.printwithreducedwhitespace-2.0.1.tgz", + "integrity": "sha512-9M4LUXV/fQBh8vZWlLvb/HyyhjJ77/I5ZKu+NBWV/BmYGyRmoEP9EVAy7LCVoY3t8BDcyCAGfxJaLFCSuQkPUg==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.removealiases": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.removealiases/-/utils.removealiases-2.0.1.tgz", + "integrity": "sha512-0joRc2HBO4u594Op1nev+mUF6yRnxoUH64xw8x3bX7n8QBDYdeYgY4tF0vJReTy+zdn2xv6fMsquATSgC722FA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.sortast": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.sortast/-/utils.sortast-2.0.1.tgz", + "integrity": "sha512-eciIavsWpJ09za1pn37wpsCGrQNXUhM0TktnZmHwO+Zy9O4fu/WdB4+5BvVhFiZYOXvfjzJUcc+hsIV8RUOtMw==", + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.stripsensitiveliterals": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.stripsensitiveliterals/-/utils.stripsensitiveliterals-2.0.1.tgz", + "integrity": "sha512-QJs7HtzXS/JIPMKWimFnUMK7VjkGQTzqD9bKD1h3iuPAqLsxd0mUNVbkYOPTsDhUKgcvUOfOqOJWYohAKMvcSA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.usagereporting": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.usagereporting/-/utils.usagereporting-2.1.0.tgz", + "integrity": "sha512-LPSlBrn+S17oBy5eWkrRSGb98sWmnEzo3DPTZgp8IQc8sJe0prDgDuppGq4NeQlpoqEHz0hQeYHAOA0Z3aQsxQ==", + "license": "MIT", + "dependencies": { + "@apollo/usage-reporting-protobuf": "^4.1.0", + "@apollo/utils.dropunuseddefinitions": "^2.0.1", + "@apollo/utils.printwithreducedwhitespace": "^2.0.1", + "@apollo/utils.removealiases": "2.0.1", + "@apollo/utils.sortast": "^2.0.1", + "@apollo/utils.stripsensitiveliterals": "^2.0.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.withrequired": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.withrequired/-/utils.withrequired-2.0.1.tgz", + "integrity": "sha512-YBDiuAX9i1lLc6GeTy1m7DGLFn/gMnvXqlalOIMjM7DeOgIacEjjfwPqb0M1CQ2v11HhR15d1NmxJoRCfrNqcA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@graphql-tools/merge": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.4.2.tgz", + "integrity": "sha512-XbrHAaj8yDuINph+sAfuq3QCZ/tKblrTLOpirK0+CAgNlZUCHs0Fa+xtMUURgwCVThLle1AF7svJCxFizygLsw==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^9.2.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema": { + "version": "9.0.19", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-9.0.19.tgz", + "integrity": "sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w==", + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^8.4.1", + "@graphql-tools/utils": "^9.2.1", + "tslib": "^2.4.0", + "value-or-promise": "^1.0.12" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/utils": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", + "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", + "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", + "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", + "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.57.2", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", + "shimmer": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.46.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.46.1.tgz", + "integrity": "sha512-AyXVnlCf/xV3K/rNumzKxZqsULyITJH6OVLiW6730JPRqWA7Zc9bvYoVNpN6iOpTU8CasH34SU/ksVJmObFibQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.43.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.43.1.tgz", + "integrity": "sha512-ht7YGWQuV5BopMcw5Q2hXn3I8eG8TH0J/kc/GMcW4CuNTgiP6wCu44BOnucJWL3CmFWaRHI//vWyAhaC8BwePw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/connect": "3.4.38" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.16.1.tgz", + "integrity": "sha512-K/qU4CjnzOpNkkKO4DfCLSQshejRNAJtd4esgigo/50nxCB6XCyi1dhAblUHM9jG5dRm8eu0FB+t87nIo99LYQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.57.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.47.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.47.1.tgz", + "integrity": "sha512-QNXPTWteDclR2B4pDFpz0TNghgB33UMjUt14B+BZPmtH1MwUFAfLHBaP5If0Z5NZC+jaH8oF2glgYjrmhZWmSw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.19.1.tgz", + "integrity": "sha512-6g0FhB3B9UobAR60BGTcXg4IHZ6aaYJzp0Ki5FhnxyAPt8Ns+9SSvgcrnsN2eGmk3RWG5vYycUGOEApycQL24A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.57.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.43.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.43.1.tgz", + "integrity": "sha512-M6qGYsp1cURtvVLGDrPPZemMFEbuMmCXgQYTReC/IbimV5sGrLBjB+/hANUpRZjX67nGLdKSVLZuQQAiNz+sww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.57.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.47.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.47.1.tgz", + "integrity": "sha512-EGQRWMGqwiuVma8ZLAZnExQ7sBvbOx0N/AE/nlafISPs8S+QtXX+Viy6dcQwVWwYHQPAcuY3bFt3xgoAwb4ZNQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.57.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.45.2.tgz", + "integrity": "sha512-7Ehow/7Wp3aoyCrZwQpU7a2CnoMq0XhIcioFuKjBb0PLYfBfmTsFTUyatlHu0fRxhwcRsSQRTvEhmZu8CppBpQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.57.2.tgz", + "integrity": "sha512-1Uz5iJ9ZAlFOiPuwYg29Bf7bJJc/GeoeJIFKJYQf67nTVKFe8RHbEtxgkOmK4UGZNHKXcpW4P8cWBYzBn1USpg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/instrumentation": "0.57.2", + "@opentelemetry/semantic-conventions": "1.28.0", + "forwarded-parse": "2.1.2", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation-ioredis": { + "version": "0.47.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.47.1.tgz", + "integrity": "sha512-OtFGSN+kgk/aoKgdkKQnBsQFDiG8WdCxu+UrHr0bXScdAmtSzLSraLo7wFIb25RVHfRWvzI5kZomqJYEg/l1iA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/redis-common": "^0.36.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.7.1.tgz", + "integrity": "sha512-OtjaKs8H7oysfErajdYr1yuWSjMAectT7Dwr+axIoZqT9lmEOkD/H/3rgAs8h/NIuEi2imSXD+vL4MZtOuJfqQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-knex": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.44.1.tgz", + "integrity": "sha512-U4dQxkNhvPexffjEmGwCq68FuftFK15JgUF05y/HlK3M6W/G2iEaACIfXdSnwVNe9Qh0sPfw8LbOPxrWzGWGMQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.47.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.47.1.tgz", + "integrity": "sha512-l/c+Z9F86cOiPJUllUCt09v+kICKvT+Vg1vOAJHtHPsJIzurGayucfCMq2acd/A/yxeNWunl9d9eqZ0G+XiI6A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.44.1.tgz", + "integrity": "sha512-5MPkYCvG2yw7WONEjYj5lr5JFehTobW7wX+ZUFy81oF2lr9IPfZk9qO+FTaM0bGEiymwfLwKe6jE15nHn1nmHg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.57.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.52.0.tgz", + "integrity": "sha512-1xmAqOtRUQGR7QfJFfGV/M2kC7wmI2WgZdpru8hJl3S0r4hW0n3OQpEHlSGXJAaNFyvT+ilnwkT+g5L4ljHR6g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.46.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.46.1.tgz", + "integrity": "sha512-3kINtW1LUTPkiXFRSSBmva1SXzS/72we/jL22N+BnF3DFcoewkdkHPYOIdAAk9gSicJ4d5Ojtt1/HeibEc5OQg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.45.1.tgz", + "integrity": "sha512-TKp4hQ8iKQsY7vnp/j0yJJ4ZsP109Ht6l4RHTj0lNEG1TfgTrIH5vJMbgmoYXWzNHAqBH2e7fncN12p3BP8LFg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/mysql": "2.15.26" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.45.2.tgz", + "integrity": "sha512-h6Ad60FjCYdJZ5DTz1Lk2VmQsShiViKe0G7sYikb0GHI0NVvApp2XQNRHNjEMz87roFttGPLHOYVPlfy+yVIhQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@opentelemetry/sql-common": "^0.40.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.51.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.51.1.tgz", + "integrity": "sha512-QxgjSrxyWZc7Vk+qGSfsejPVFL1AgAJdSBMYZdDUbwg730D09ub3PXScB9d04vIqPriZ+0dqzjmQx0yWKiCi2Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.26.0", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@opentelemetry/sql-common": "^0.40.1", + "@types/pg": "8.6.1", + "@types/pg-pool": "2.0.6" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-redis-4": { + "version": "0.46.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.46.1.tgz", + "integrity": "sha512-UMqleEoabYMsWoTkqyt9WAzXwZ4BlFZHO40wr3d5ZvtjKCHlD4YXLm+6OLCeIi/HkX7EXvQaz8gtAwkwwSEvcQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/redis-common": "^0.36.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-tedious": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.18.1.tgz", + "integrity": "sha512-5Cuy/nj0HBaH+ZJ4leuD7RjgvA844aY2WW+B5uLcWtxGjRZl3MNLuxnNg5DYWZNPO+NafSSnra0q49KWAHsKBg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/tedious": "^4.0.14" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.10.1.tgz", + "integrity": "sha512-rkOGikPEyRpMCmNu9AQuV5dtRlDmJp2dK5sw8roVshAGoB6hH/3QjDtRhdwd75SsJwgynWUNRUYe0wAkTo16tQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.57.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, + "node_modules/@opentelemetry/redis-common": { + "version": "0.36.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.36.2.tgz", + "integrity": "sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", + "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz", + "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.40.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.40.1.tgz", + "integrity": "sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.1.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, + "node_modules/@prisma/instrumentation": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.11.1.tgz", + "integrity": "sha512-mrZOev24EDhnefmnZX7WVVT7v+r9LttPRqf54ONvj6re4XMF7wFTpK2tLJi4XHB7fFp/6xhYbgRel8YV7gQiyA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.8" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@sentry/core": { + "version": "9.47.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.47.1.tgz", + "integrity": "sha512-KX62+qIt4xgy8eHKHiikfhz2p5fOciXd0Cl+dNzhgPFq8klq4MGMNaf148GB3M/vBqP4nw/eFvRMAayFCgdRQw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "9.47.1", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-9.47.1.tgz", + "integrity": "sha512-CDbkasBz3fnWRKSFs6mmaRepM2pa+tbZkrqhPWifFfIkJDidtVW40p6OnquTvPXyPAszCnDZRnZT14xyvNmKPQ==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1", + "@opentelemetry/core": "^1.30.1", + "@opentelemetry/instrumentation": "^0.57.2", + "@opentelemetry/instrumentation-amqplib": "^0.46.1", + "@opentelemetry/instrumentation-connect": "0.43.1", + "@opentelemetry/instrumentation-dataloader": "0.16.1", + "@opentelemetry/instrumentation-express": "0.47.1", + "@opentelemetry/instrumentation-fs": "0.19.1", + "@opentelemetry/instrumentation-generic-pool": "0.43.1", + "@opentelemetry/instrumentation-graphql": "0.47.1", + "@opentelemetry/instrumentation-hapi": "0.45.2", + "@opentelemetry/instrumentation-http": "0.57.2", + "@opentelemetry/instrumentation-ioredis": "0.47.1", + "@opentelemetry/instrumentation-kafkajs": "0.7.1", + "@opentelemetry/instrumentation-knex": "0.44.1", + "@opentelemetry/instrumentation-koa": "0.47.1", + "@opentelemetry/instrumentation-lru-memoizer": "0.44.1", + "@opentelemetry/instrumentation-mongodb": "0.52.0", + "@opentelemetry/instrumentation-mongoose": "0.46.1", + "@opentelemetry/instrumentation-mysql": "0.45.1", + "@opentelemetry/instrumentation-mysql2": "0.45.2", + "@opentelemetry/instrumentation-pg": "0.51.1", + "@opentelemetry/instrumentation-redis-4": "0.46.1", + "@opentelemetry/instrumentation-tedious": "0.18.1", + "@opentelemetry/instrumentation-undici": "0.10.1", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-trace-base": "^1.30.1", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@prisma/instrumentation": "6.11.1", + "@sentry/core": "9.47.1", + "@sentry/node-core": "9.47.1", + "@sentry/opentelemetry": "9.47.1", + "import-in-the-middle": "^1.14.2", + "minimatch": "^9.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "9.47.1", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-9.47.1.tgz", + "integrity": "sha512-7TEOiCGkyShJ8CKtsri9lbgMCbB+qNts2Xq37itiMPN2m+lIukK3OX//L8DC5nfKYZlgikrefS63/vJtm669hQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "9.47.1", + "@sentry/opentelemetry": "9.47.1", + "import-in-the-middle": "^1.14.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", + "@opentelemetry/core": "^1.30.1 || ^2.0.0", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/resources": "^1.30.1 || ^2.0.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", + "@opentelemetry/semantic-conventions": "^1.34.0" + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "9.47.1", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-9.47.1.tgz", + "integrity": "sha512-STtFpjF7lwzeoedDJV+5XA6P89BfmFwFftmHSGSe3UTI8z8IoiR5yB6X2vCjSPvXlfeOs13qCNNCEZyznxM8Xw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "9.47.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", + "@opentelemetry/core": "^1.30.1 || ^2.0.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", + "@opentelemetry/semantic-conventions": "^1.34.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/express/node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/mysql": { + "version": "2.15.26", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz", + "integrity": "sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/pg": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz", + "integrity": "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/pg-pool": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.6.tgz", + "integrity": "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==", + "license": "MIT", + "dependencies": { + "@types/pg": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/shimmer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", + "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==", + "license": "MIT" + }, + "node_modules/@types/tedious": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", + "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/arangojs": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/arangojs/-/arangojs-9.3.0.tgz", + "integrity": "sha512-+z/TxumH8ywsXAN0oyQAxMnBMtDbd4tjFhGXzygAHD8+YDzEC705STLBh38KGbwxWeekalu2XEHUtrJ/NONgTQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^20.11.26" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/arangojs/node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", + "license": "MIT" + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphql": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz", + "integrity": "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/h3-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz", + "integrity": "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==", + "license": "Apache-2.0", + "engines": { + "node": ">=4", + "npm": ">=3", + "yarn": ">=1.3.0" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/import-in-the-middle": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz", + "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "license": "MIT" + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-in-the-middle": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", + "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", + "license": "BSD-2-Clause" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/value-or-promise": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/value-or-promise/-/value-or-promise-1.0.12.tgz", + "integrity": "sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..78271b8 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "geo", + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx --watch src/index.ts" + }, + "dependencies": { + "@apollo/server": "^4.11.3", + "@sentry/node": "^9.5.0", + "arangojs": "^9.2.0", + "cors": "^2.8.5", + "express": "^5.0.1", + "h3-js": "^4.2.1" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/node": "^22.13.0", + "tsx": "^4.19.3", + "typescript": "^5.7.3" + } +} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index e0d06a5..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,31 +0,0 @@ -[project] -name = "geo" -version = "0.1.0" -description = "Geo service - logistics graph and routing" -authors = [ - {name = "Ruslan Bakiev",email = "572431+veikab@users.noreply.github.com"} -] -requires-python = "^3.11" -dependencies = [ - "django (>=5.2.8,<6.0)", - "graphene-django (>=3.2.3,<4.0.0)", - "django-cors-headers (>=4.9.0,<5.0.0)", - "python-arango (>=8.0.0,<9.0.0)", - "python-dotenv (>=1.2.1,<2.0.0)", - "infisicalsdk (>=1.0.12,<2.0.0)", - "gunicorn (>=23.0.0,<24.0.0)", - "whitenoise (>=6.7.0,<7.0.0)", - "sentry-sdk (>=2.47.0,<3.0.0)", - "h3 (>=4.0.0,<5.0.0)" -] - -[tool.poetry] -package-mode = false - -[tool.poetry.group.dev.dependencies] -pytest = "^8.0.0" -requests = "^2.32.0" - -[build-system] -requires = ["poetry-core>=2.0.0,<3.0.0"] -build-backend = "poetry.core.masonry.api" diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index e10dbdf..0000000 --- a/pytest.ini +++ /dev/null @@ -1,12 +0,0 @@ -[pytest] -testpaths = tests -python_files = test_*.py -python_classes = Test* -python_functions = test_* -addopts = - -v - --tb=short - --strict-markers -markers = - slow: marks tests as slow (deselect with '-m "not slow"') - integration: marks tests as integration tests diff --git a/run_tests.sh b/run_tests.sh deleted file mode 100755 index 676eb60..0000000 --- a/run_tests.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash -# Run geo service GraphQL endpoint tests - -set -e - -cd "$(dirname "$0")" - -echo "🧪 Running Geo Service GraphQL Tests" -echo "====================================" -echo "" - -# Check if TEST_GEO_URL is set, otherwise use production -if [ -z "$TEST_GEO_URL" ]; then - export TEST_GEO_URL="https://geo.optovia.ru/graphql/public/" - echo "📍 Testing against: $TEST_GEO_URL (production)" -else - echo "📍 Testing against: $TEST_GEO_URL" -fi - -echo "" - -# Install dependencies if needed -if ! poetry run python -c "import pytest" 2>/dev/null; then - echo "📦 Installing dependencies..." - poetry install --with dev - echo "" -fi - -# Run tests -echo "🚀 Running tests..." -echo "" - -poetry run pytest tests/test_graphql_endpoints.py -v -s "$@" - -echo "" -echo "✅ Test run complete" diff --git a/src/cluster.ts b/src/cluster.ts new file mode 100644 index 0000000..8ee188c --- /dev/null +++ b/src/cluster.ts @@ -0,0 +1,192 @@ +import { latLngToCell, cellToLatLng } from 'h3-js' +import { getDb } from './db.js' + +const ZOOM_TO_RES: Record = { + 0: 0, 1: 0, 2: 1, 3: 1, 4: 2, 5: 2, + 6: 3, 7: 3, 8: 4, 9: 4, 10: 5, 11: 5, + 12: 6, 13: 7, 14: 8, 15: 9, 16: 10, +} + +interface CachedNode { + _key: string + name?: string + latitude?: number + longitude?: number + country?: string + country_code?: string + node_type?: string + transport_types?: string[] +} + +const nodesCache = new Map() + +function fetchNodes(transportType?: string | null, nodeType?: string | null): CachedNode[] { + const cacheKey = `nodes:${transportType || 'all'}:${nodeType || 'logistics'}` + if (nodesCache.has(cacheKey)) return nodesCache.get(cacheKey)! + + const db = getDb() + let aql: string + + if (nodeType === 'offer') { + aql = ` + FOR node IN nodes + FILTER node.node_type == 'offer' + FILTER node.latitude != null AND node.longitude != null + RETURN node + ` + } else if (nodeType === 'supplier') { + aql = ` + FOR offer IN nodes + FILTER offer.node_type == 'offer' + FILTER offer.supplier_uuid != null + LET supplier = DOCUMENT(CONCAT('nodes/', offer.supplier_uuid)) + FILTER supplier != null + FILTER supplier.latitude != null AND supplier.longitude != null + COLLECT sup_uuid = offer.supplier_uuid INTO offers + LET sup = DOCUMENT(CONCAT('nodes/', sup_uuid)) + RETURN { + _key: sup_uuid, + name: sup.name, + latitude: sup.latitude, + longitude: sup.longitude, + country: sup.country, + country_code: sup.country_code, + node_type: 'supplier', + offers_count: LENGTH(offers) + } + ` + } else { + aql = ` + FOR node IN nodes + FILTER node.node_type == 'logistics' OR node.node_type == null + FILTER node.latitude != null AND node.longitude != null + RETURN node + ` + } + + // arangojs query returns a cursor — we need async. Use a sync cache pattern with pre-fetching. + // Since this is called from resolvers which are async, we'll use a different approach. + // Store a promise instead. + throw new Error('Use fetchNodesAsync instead') +} + +export async function fetchNodesAsync(transportType?: string | null, nodeType?: string | null): Promise { + const cacheKey = `nodes:${transportType || 'all'}:${nodeType || 'logistics'}` + if (nodesCache.has(cacheKey)) return nodesCache.get(cacheKey)! + + const db = getDb() + let aql: string + + if (nodeType === 'offer') { + aql = ` + FOR node IN nodes + FILTER node.node_type == 'offer' + FILTER node.latitude != null AND node.longitude != null + RETURN node + ` + } else if (nodeType === 'supplier') { + aql = ` + FOR offer IN nodes + FILTER offer.node_type == 'offer' + FILTER offer.supplier_uuid != null + LET supplier = DOCUMENT(CONCAT('nodes/', offer.supplier_uuid)) + FILTER supplier != null + FILTER supplier.latitude != null AND supplier.longitude != null + COLLECT sup_uuid = offer.supplier_uuid INTO offers + LET sup = DOCUMENT(CONCAT('nodes/', sup_uuid)) + RETURN { + _key: sup_uuid, + name: sup.name, + latitude: sup.latitude, + longitude: sup.longitude, + country: sup.country, + country_code: sup.country_code, + node_type: 'supplier', + offers_count: LENGTH(offers) + } + ` + } else { + aql = ` + FOR node IN nodes + FILTER node.node_type == 'logistics' OR node.node_type == null + FILTER node.latitude != null AND node.longitude != null + RETURN node + ` + } + + const cursor = await db.query(aql) + let allNodes: CachedNode[] = await cursor.all() + + if (transportType && (!nodeType || nodeType === 'logistics')) { + allNodes = allNodes.filter(n => (n.transport_types || []).includes(transportType)) + } + + nodesCache.set(cacheKey, allNodes) + console.log(`Cached ${allNodes.length} nodes for ${cacheKey}`) + return allNodes +} + +export interface ClusterPoint { + id: string + latitude: number + longitude: number + count: number + expansion_zoom: number | null + name: string | null +} + +export async function getClusteredNodes( + west: number, south: number, east: number, north: number, + zoom: number, transportType?: string | null, nodeType?: string | null, +): Promise { + const resolution = ZOOM_TO_RES[Math.floor(zoom)] ?? 5 + const nodes = await fetchNodesAsync(transportType, nodeType) + + if (!nodes.length) return [] + + const cells = new Map() + + for (const node of nodes) { + const lat = node.latitude + const lng = node.longitude + if (lat == null || lng == null) continue + if (lat < south || lat > north || lng < west || lng > east) continue + + const cell = latLngToCell(lat, lng, resolution) + if (!cells.has(cell)) cells.set(cell, []) + cells.get(cell)!.push(node) + } + + const results: ClusterPoint[] = [] + + for (const [cell, nodesInCell] of cells) { + if (nodesInCell.length === 1) { + const node = nodesInCell[0] + results.push({ + id: node._key, + latitude: node.latitude!, + longitude: node.longitude!, + count: 1, + expansion_zoom: null, + name: node.name || null, + }) + } else { + const [lat, lng] = cellToLatLng(cell) + results.push({ + id: `cluster-${cell}`, + latitude: lat, + longitude: lng, + count: nodesInCell.length, + expansion_zoom: Math.min(zoom + 2, 16), + name: null, + }) + } + } + + return results +} + +export function invalidateCache(): void { + nodesCache.clear() + console.log('Cluster cache invalidated') +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..67b78c9 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,27 @@ +import { Database } from 'arangojs' + +const ARANGODB_URL = process.env.ARANGODB_INTERNAL_URL || 'http://localhost:8529' +const ARANGODB_DATABASE = process.env.ARANGODB_DATABASE || 'optovia_maps' +const ARANGODB_PASSWORD = process.env.ARANGODB_PASSWORD || '' + +let _db: Database | null = null + +export function getDb(): Database { + if (!_db) { + const url = ARANGODB_URL.startsWith('http') ? ARANGODB_URL : `http://${ARANGODB_URL}` + _db = new Database({ url, databaseName: ARANGODB_DATABASE, auth: { username: 'root', password: ARANGODB_PASSWORD } }) + console.log(`Connected to ArangoDB: ${url}/${ARANGODB_DATABASE}`) + } + return _db +} + +export async function ensureGraph(): Promise { + const db = getDb() + const graphs = await db.listGraphs() + if (graphs.some(g => g.name === 'optovia_graph')) return + + console.log('Creating graph: optovia_graph') + await db.createGraph('optovia_graph', [ + { collection: 'edges', from: ['nodes'], to: ['nodes'] }, + ]) +} diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..5bbb118 --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,90 @@ +/** Haversine distance in km. */ +export function distanceKm(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371 + const dLat = (lat2 - lat1) * Math.PI / 180 + const dLon = (lon2 - lon1) * Math.PI / 180 + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * + Math.sin(dLon / 2) ** 2 + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ArangoDoc = Record + +export interface RouteStage { + from_uuid: string | null + from_name: string | null + from_lat: number | null + from_lon: number | null + to_uuid: string | null + to_name: string | null + to_lat: number | null + to_lon: number | null + distance_km: number + travel_time_seconds: number + transport_type: string | null +} + +export interface RoutePath { + total_distance_km: number + total_time_seconds: number + stages: RouteStage[] +} + +function buildStage(fromDoc: ArangoDoc | undefined, toDoc: ArangoDoc | undefined, transportType: string, edges: ArangoDoc[]): RouteStage { + const distance = edges.reduce((s, e) => s + (e.distance_km || 0), 0) + const time = edges.reduce((s, e) => s + (e.travel_time_seconds || 0), 0) + return { + from_uuid: fromDoc?._key ?? null, + from_name: fromDoc?.name ?? null, + from_lat: fromDoc?.latitude ?? null, + from_lon: fromDoc?.longitude ?? null, + to_uuid: toDoc?._key ?? null, + to_name: toDoc?.name ?? null, + to_lat: toDoc?.latitude ?? null, + to_lon: toDoc?.longitude ?? null, + distance_km: distance, + travel_time_seconds: time, + transport_type: transportType, + } +} + +export function buildRouteFromEdges(pathEdges: [string, string, ArangoDoc][], nodeDocs: Map): RoutePath | null { + if (!pathEdges.length) return null + + // Filter offer edges — not transport stages + const filtered = pathEdges.filter(([, , e]) => e.transport_type !== 'offer') + if (!filtered.length) return null + + const stages: RouteStage[] = [] + let currentEdges: ArangoDoc[] = [] + let currentType: string | null = null + let segmentStart: string | null = null + + for (const [fromKey, , edge] of filtered) { + const edgeType = edge.transport_type as string + if (currentType === null) { + currentType = edgeType + currentEdges = [edge] + segmentStart = fromKey + } else if (edgeType === currentType) { + currentEdges.push(edge) + } else { + stages.push(buildStage(nodeDocs.get(segmentStart!), nodeDocs.get(fromKey), currentType, currentEdges)) + currentType = edgeType + currentEdges = [edge] + segmentStart = fromKey + } + } + + const lastTo = filtered[filtered.length - 1][1] + stages.push(buildStage(nodeDocs.get(segmentStart!), nodeDocs.get(lastTo), currentType!, currentEdges)) + + return { + total_distance_km: stages.reduce((s, st) => s + (st.distance_km || 0), 0), + total_time_seconds: stages.reduce((s, st) => s + (st.travel_time_seconds || 0), 0), + stages, + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a9f2ec8 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,33 @@ +import express from 'express' +import cors from 'cors' +import { ApolloServer } from '@apollo/server' +import { expressMiddleware } from '@apollo/server/express4' +import * as Sentry from '@sentry/node' +import { typeDefs, resolvers } from './schema.js' + +const PORT = parseInt(process.env.PORT || '8000', 10) +const SENTRY_DSN = process.env.SENTRY_DSN || '' + +if (SENTRY_DSN) { + Sentry.init({ + dsn: SENTRY_DSN, + tracesSampleRate: 0.01, + release: process.env.RELEASE_VERSION || '1.0.0', + environment: process.env.ENVIRONMENT || 'production', + }) +} + +const app = express() +app.use(cors({ origin: ['https://optovia.ru'], credentials: true })) + +const server = new ApolloServer({ typeDefs, resolvers, introspection: true }) +await server.start() + +app.use('/graphql/public', express.json(), expressMiddleware(server) as unknown as express.RequestHandler) + +app.get('/health', (_, res) => { res.json({ status: 'ok' }) }) + +app.listen(PORT, '0.0.0.0', () => { + console.log(`Geo server ready on port ${PORT}`) + console.log(` /graphql/public - public (no auth)`) +}) diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..5f719b3 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,1027 @@ +import { getDb, ensureGraph } from './db.js' +import { getClusteredNodes } from './cluster.js' +import { distanceKm, buildRouteFromEdges, type ArangoDoc, type RoutePath } from './helpers.js' + +const GRAPHHOPPER_URL = process.env.GRAPHHOPPER_EXTERNAL_URL || 'https://graphhopper.optovia.ru' +const OPENRAILROUTING_URL = process.env.OPENRAILROUTING_EXTERNAL_URL || 'https://openrailrouting.optovia.ru' +const MAX_EXPANSIONS = 20000 + +export const typeDefs = `#graphql + type Edge { + to_uuid: String + to_name: String + to_latitude: Float + to_longitude: Float + distance_km: Float + travel_time_seconds: Int + transport_type: String + } + + type Node { + uuid: String + name: String + latitude: Float + longitude: Float + country: String + country_code: String + synced_at: String + transport_types: [String] + edges: [Edge] + distance_km: Float + } + + type NodeConnections { + hub: Node + rail_node: Node + auto_edges: [Edge] + rail_edges: [Edge] + } + + type Route { + distance_km: Float + geometry: JSON + } + + type RouteStage { + from_uuid: String + from_name: String + from_lat: Float + from_lon: Float + to_uuid: String + to_name: String + to_lat: Float + to_lon: Float + distance_km: Float + travel_time_seconds: Int + transport_type: String + } + + type RoutePath { + total_distance_km: Float + total_time_seconds: Int + stages: [RouteStage] + } + + type ProductRouteOption { + source_uuid: String + source_name: String + source_lat: Float + source_lon: Float + distance_km: Float + routes: [RoutePath] + } + + type ClusterPoint { + id: String + latitude: Float + longitude: Float + count: Int + expansion_zoom: Int + name: String + } + + type Product { + uuid: String + name: String + offers_count: Int + } + + type Supplier { + uuid: String + name: String + latitude: Float + longitude: Float + distance_km: Float + } + + type OfferNode { + uuid: String + product_uuid: String + product_name: String + supplier_uuid: String + supplier_name: String + latitude: Float + longitude: Float + country: String + country_code: String + price_per_unit: String + currency: String + quantity: String + unit: String + distance_km: Float + } + + type OfferWithRoute { + uuid: String + product_uuid: String + product_name: String + supplier_uuid: String + supplier_name: String + latitude: Float + longitude: Float + country: String + country_code: String + price_per_unit: String + currency: String + quantity: String + unit: String + distance_km: Float + routes: [RoutePath] + } + + scalar JSON + + type Query { + node(uuid: String!): Node + nodes(limit: Int, offset: Int, transport_type: String, country: String, search: String, west: Float, south: Float, east: Float, north: Float): [Node!]! + nodes_count(transport_type: String, country: String, west: Float, south: Float, east: Float, north: Float): Int! + hub_countries: [String!]! + nearest_nodes(lat: Float!, lon: Float!, limit: Int): [Node!]! + node_connections(uuid: String!, limit_auto: Int, limit_rail: Int): NodeConnections + auto_route(from_lat: Float!, from_lon: Float!, to_lat: Float!, to_lon: Float!): Route + rail_route(from_lat: Float!, from_lon: Float!, to_lat: Float!, to_lon: Float!): Route + clustered_nodes(west: Float!, south: Float!, east: Float!, north: Float!, zoom: Int!, transport_type: String, node_type: String): [ClusterPoint!]! + products: [Product!]! + offers_by_product(product_uuid: String!): [OfferNode!]! + hubs_near_offer(offer_uuid: String!, limit: Int): [Node!]! + suppliers: [Supplier!]! + products_by_supplier(supplier_uuid: String!): [Product!]! + offers_by_supplier_product(supplier_uuid: String!, product_uuid: String!): [OfferNode!]! + products_near_hub(hub_uuid: String!, radius_km: Float): [Product!]! + suppliers_for_product(product_uuid: String!): [Supplier!]! + hubs_for_product(product_uuid: String!, radius_km: Float): [Node!]! + offers_by_hub(hub_uuid: String!, product_uuid: String!, limit: Int): [ProductRouteOption!]! + offer_to_hub(offer_uuid: String!, hub_uuid: String!): ProductRouteOption + nearest_hubs(lat: Float!, lon: Float!, radius: Float, product_uuid: String, limit: Int): [Node!]! + nearest_offers(lat: Float!, lon: Float!, radius: Float, product_uuid: String, hub_uuid: String, limit: Int): [OfferWithRoute!]! + nearest_suppliers(lat: Float!, lon: Float!, radius: Float, product_uuid: String, limit: Int): [Supplier!]! + route_to_coordinate(offer_uuid: String!, lat: Float!, lon: Float!): ProductRouteOption + hubs_list(limit: Int, offset: Int, country: String, transport_type: String, west: Float, south: Float, east: Float, north: Float): [Node!]! + suppliers_list(limit: Int, offset: Int, country: String, west: Float, south: Float, east: Float, north: Float): [Supplier!]! + products_list(limit: Int, offset: Int, west: Float, south: Float, east: Float, north: Float): [Product!]! + } +` + +function mapNode(doc: ArangoDoc, includeEdges = false) { + return { + uuid: doc._key, + name: doc.name ?? null, + latitude: doc.latitude ?? null, + longitude: doc.longitude ?? null, + country: doc.country ?? null, + country_code: doc.country_code ?? null, + synced_at: doc.synced_at ?? null, + transport_types: doc.transport_types || [], + edges: includeEdges ? (doc.edges || []) : [], + distance_km: doc.distance_km ?? null, + } +} + +function mapOffer(doc: ArangoDoc) { + return { + uuid: doc._key, + product_uuid: doc.product_uuid ?? null, + product_name: doc.product_name ?? null, + supplier_uuid: doc.supplier_uuid ?? null, + supplier_name: doc.supplier_name ?? null, + latitude: doc.latitude ?? null, + longitude: doc.longitude ?? null, + country: doc.country ?? null, + country_code: doc.country_code ?? null, + price_per_unit: doc.price_per_unit ?? null, + currency: doc.currency ?? null, + quantity: doc.quantity ?? null, + unit: doc.unit ?? null, + distance_km: doc.distance_km ?? null, + } +} + +function boundsFilter(args: { west?: number | null; south?: number | null; east?: number | null; north?: number | null }, varName = 'node') { + if (args.west == null || args.south == null || args.east == null || args.north == null) return { filter: '', vars: {} } + return { + filter: ` + FILTER ${varName}.latitude != null AND ${varName}.longitude != null + FILTER ${varName}.latitude >= @south AND ${varName}.latitude <= @north + FILTER ${varName}.longitude >= @west AND ${varName}.longitude <= @east + `, + vars: { west: args.west, south: args.south, east: args.east, north: args.north }, + } +} + +// Phase-based routing helpers +type Phase = 'end_auto' | 'end_auto_done' | 'rail' | 'start_auto_done' | 'offer' + +function allowedNextPhase(current: Phase, transportType: string): Phase | null { + if (current === 'end_auto') { + if (transportType === 'offer') return 'offer' + if (transportType === 'auto') return 'end_auto_done' + if (transportType === 'rail') return 'rail' + return null + } + if (current === 'end_auto_done') { + if (transportType === 'offer') return 'offer' + if (transportType === 'rail') return 'rail' + return null + } + if (current === 'rail') { + if (transportType === 'offer') return 'offer' + if (transportType === 'rail') return 'rail' + if (transportType === 'auto') return 'start_auto_done' + return null + } + if (current === 'start_auto_done') { + if (transportType === 'offer') return 'offer' + return null + } + return null +} + +function phaseTypes(phase: Phase): string[] { + if (phase === 'end_auto') return ['auto', 'rail', 'offer'] + if (phase === 'end_auto_done') return ['rail', 'offer'] + if (phase === 'rail') return ['rail', 'auto', 'offer'] + if (phase === 'start_auto_done') return ['offer'] + return ['offer'] +} + +async function fetchNeighbors(nodeKey: string, phase: Phase): Promise { + const db = getDb() + const types = phaseTypes(phase) + const cursor = await db.query(` + FOR edge IN edges + FILTER edge.transport_type IN @types + FILTER edge._from == @node_id OR edge._to == @node_id + LET neighbor_id = edge._from == @node_id ? edge._to : edge._from + LET neighbor = DOCUMENT(neighbor_id) + FILTER neighbor != null + RETURN { + neighbor_key: neighbor._key, + neighbor_doc: neighbor, + from_id: edge._from, + to_id: edge._to, + transport_type: edge.transport_type, + distance_km: edge.distance_km, + travel_time_seconds: edge.travel_time_seconds + } + `, { node_id: `nodes/${nodeKey}`, types }) + return cursor.all() +} + +async function resolveOfferToHubInternal(offerUuid: string, hubUuid: string): Promise { + const db = getDb() + await ensureGraph() + + const nodesCol = db.collection('nodes') + const [offer, hub] = await Promise.all([nodesCol.document(offerUuid).catch(() => null), nodesCol.document(hubUuid).catch(() => null)]) + if (!offer || !hub) return null + + const hubLat = hub.latitude + const hubLon = hub.longitude + const offerLat = offer.latitude + const offerLon = offer.longitude + + // Priority queue: [cost, seq, nodeKey, phase] + const queue: [number, number, string, Phase][] = [[0, 0, hubUuid, 'end_auto']] + let counter = 0 + const visited = new Map() + const predecessors = new Map() // stateKey -> [prevStateKey, edge] + const nodeDocs = new Map([[hubUuid, hub], [offerUuid, offer]]) + let expansions = 0 + + while (queue.length > 0 && expansions < MAX_EXPANSIONS) { + queue.sort((a, b) => a[0] - b[0]) + const [cost, , nodeKey, phase] = queue.shift()! + + const stateKey = `${nodeKey}:${phase}` + if (visited.has(stateKey) && cost > visited.get(stateKey)!) continue + + if (nodeKey === offerUuid) { + const pathEdges: [string, string, ArangoDoc][] = [] + let curState = stateKey + let curKey = nodeKey + while (predecessors.has(curState)) { + const [prevState, edgeInfo] = predecessors.get(curState)! + const prevKey = prevState.split(':')[0] + pathEdges.push([curKey, prevKey, edgeInfo]) + curState = prevState + curKey = prevKey + } + + const route = buildRouteFromEdges(pathEdges, nodeDocs) + let dm: number | null = null + if (offerLat != null && offerLon != null && hubLat != null && hubLon != null) { + dm = distanceKm(offerLat, offerLon, hubLat, hubLon) + } + + return { + source_uuid: offerUuid, + source_name: offer.name || offer.product_name, + source_lat: offerLat, + source_lon: offerLon, + distance_km: dm, + routes: route ? [route] : [], + } + } + + const neighbors = await fetchNeighbors(nodeKey, phase) + expansions++ + + for (const neighbor of neighbors) { + const transportType = neighbor.transport_type as string + const nextPhase = allowedNextPhase(phase, transportType) + if (!nextPhase) continue + + const neighborKey = neighbor.neighbor_key as string + nodeDocs.set(neighborKey, neighbor.neighbor_doc) + + const travelTime = neighbor.travel_time_seconds as number | null + const edgeDist = neighbor.distance_km as number | null + const stepCost = travelTime ?? (edgeDist || 0) + const newCost = cost + stepCost + + const nStateKey = `${neighborKey}:${nextPhase}` + if (visited.has(nStateKey) && newCost >= visited.get(nStateKey)!) continue + + visited.set(nStateKey, newCost) + counter++ + queue.push([newCost, counter, neighborKey, nextPhase]) + predecessors.set(nStateKey, [stateKey, neighbor]) + } + } + + return null +} + +export const resolvers = { + JSON: { + __serialize: (value: unknown) => value, + __parseValue: (value: unknown) => value, + __parseLiteral: (ast: { value: string }) => ast.value, + }, + + Product: { + offers_count: async (parent: { uuid: string }) => { + const db = getDb() + try { + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'offer' + FILTER node.product_uuid == @product_uuid + COLLECT WITH COUNT INTO length + RETURN length + `, { product_uuid: parent.uuid }) + const result = await cursor.next() + return result ?? 0 + } catch { return 0 } + }, + }, + + Query: { + node: async (_: unknown, args: { uuid: string }) => { + const db = getDb() + const nodesCol = db.collection('nodes') + const node = await nodesCol.document(args.uuid).catch(() => null) + if (!node) return null + + const cursor = await db.query(` + FOR edge IN edges + FILTER edge._from == @from_id + LET to_node = DOCUMENT(edge._to) + RETURN { + to_uuid: to_node._key, + to_name: to_node.name, + to_latitude: to_node.latitude, + to_longitude: to_node.longitude, + distance_km: edge.distance_km, + travel_time_seconds: edge.travel_time_seconds, + transport_type: edge.transport_type + } + `, { from_id: `nodes/${args.uuid}` }) + const edges = await cursor.all() + + return { ...mapNode(node, true), edges } + }, + + nodes: async (_: unknown, args: { limit?: number; offset?: number; transport_type?: string; country?: string; search?: string; west?: number; south?: number; east?: number; north?: number }) => { + const db = getDb() + const bounds = boundsFilter(args) + + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'logistics' OR node.node_type == null + LET types = node.transport_types != null ? node.transport_types : [] + FILTER @transport_type == null OR @transport_type IN types + FILTER @country == null OR node.country == @country + FILTER @search == null OR CONTAINS(LOWER(node.name), LOWER(@search)) OR CONTAINS(LOWER(node.country), LOWER(@search)) + ${bounds.filter} + SORT node.name ASC + LIMIT @offset, @limit + RETURN node + `, { + transport_type: args.transport_type ?? null, + country: args.country ?? null, + search: args.search ?? null, + offset: args.offset ?? 0, + limit: args.limit ?? 1000000, + ...bounds.vars, + }) + const nodes = await cursor.all() + return nodes.map((n: ArangoDoc) => mapNode(n)) + }, + + nodes_count: async (_: unknown, args: { transport_type?: string; country?: string; west?: number; south?: number; east?: number; north?: number }) => { + const db = getDb() + const bounds = boundsFilter(args) + + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'logistics' OR node.node_type == null + LET types = node.transport_types != null ? node.transport_types : [] + FILTER @transport_type == null OR @transport_type IN types + FILTER @country == null OR node.country == @country + ${bounds.filter} + COLLECT WITH COUNT INTO length + RETURN length + `, { transport_type: args.transport_type ?? null, country: args.country ?? null, ...bounds.vars }) + return (await cursor.next()) ?? 0 + }, + + hub_countries: async () => { + const db = getDb() + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'logistics' OR node.node_type == null + FILTER node.country != null + COLLECT country = node.country + SORT country ASC + RETURN country + `) + return cursor.all() + }, + + nearest_nodes: async (_: unknown, args: { lat: number; lon: number; limit?: number }) => { + const db = getDb() + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'logistics' OR node.node_type == null + FILTER node.latitude != null AND node.longitude != null + LET dist = DISTANCE(node.latitude, node.longitude, @lat, @lon) / 1000 + SORT dist ASC + LIMIT @limit + RETURN MERGE(node, {distance_km: dist}) + `, { lat: args.lat, lon: args.lon, limit: args.limit ?? 5 }) + const nodes = await cursor.all() + return nodes.map((n: ArangoDoc) => mapNode(n)) + }, + + node_connections: async (_: unknown, args: { uuid: string; limit_auto?: number; limit_rail?: number }) => { + const db = getDb() + const nodesCol = db.collection('nodes') + const hub = await nodesCol.document(args.uuid).catch(() => null) + if (!hub) return null + + const cursor = await db.query(` + LET auto_edges = ( + FOR edge IN edges + FILTER edge._from == @from_id AND edge.transport_type == "auto" + LET to_node = DOCUMENT(edge._to) + FILTER to_node != null + SORT edge.distance_km ASC + LIMIT @limit_auto + RETURN { + to_uuid: to_node._key, to_name: to_node.name, + to_latitude: to_node.latitude, to_longitude: to_node.longitude, + distance_km: edge.distance_km, travel_time_seconds: edge.travel_time_seconds, + transport_type: edge.transport_type + } + ) + LET hub_has_rail = @hub_has_rail + LET rail_node = hub_has_rail ? DOCUMENT(@from_id) : FIRST( + FOR node IN nodes + FILTER node.latitude != null AND node.longitude != null + FILTER 'rail' IN node.transport_types + SORT DISTANCE(@hub_lat, @hub_lon, node.latitude, node.longitude) + LIMIT 1 + RETURN node + ) + LET rail_edges = rail_node == null ? [] : ( + FOR edge IN edges + FILTER edge._from == CONCAT("nodes/", rail_node._key) AND edge.transport_type == "rail" + LET to_node = DOCUMENT(edge._to) + FILTER to_node != null + SORT edge.distance_km ASC + LIMIT @limit_rail + RETURN { + to_uuid: to_node._key, to_name: to_node.name, + to_latitude: to_node.latitude, to_longitude: to_node.longitude, + distance_km: edge.distance_km, travel_time_seconds: edge.travel_time_seconds, + transport_type: edge.transport_type + } + ) + RETURN { hub: DOCUMENT(@from_id), rail_node, auto_edges, rail_edges } + `, { + from_id: `nodes/${args.uuid}`, + hub_lat: hub.latitude, + hub_lon: hub.longitude, + hub_has_rail: (hub.transport_types || []).includes('rail'), + limit_auto: args.limit_auto ?? 12, + limit_rail: args.limit_rail ?? 12, + }) + + const result = await cursor.next() + if (!result) return null + + return { + hub: result.hub ? mapNode(result.hub) : null, + rail_node: result.rail_node ? mapNode(result.rail_node) : null, + auto_edges: result.auto_edges || [], + rail_edges: result.rail_edges || [], + } + }, + + auto_route: async (_: unknown, args: { from_lat: number; from_lon: number; to_lat: number; to_lon: number }) => { + const url = new URL('/route', GRAPHHOPPER_URL) + url.searchParams.append('point', `${args.from_lat},${args.from_lon}`) + url.searchParams.append('point', `${args.to_lat},${args.to_lon}`) + url.searchParams.append('profile', 'car') + url.searchParams.append('instructions', 'false') + url.searchParams.append('calc_points', 'true') + url.searchParams.append('points_encoded', 'false') + + try { + const res = await fetch(url.toString(), { signal: AbortSignal.timeout(30000) }) + const data = await res.json() as ArangoDoc + if (data.paths?.length > 0) { + const path = data.paths[0] + return { distance_km: Math.round((path.distance || 0) / 10) / 100, geometry: path.points?.coordinates || [] } + } + } catch (e) { console.error('GraphHopper request failed:', e) } + return null + }, + + rail_route: async (_: unknown, args: { from_lat: number; from_lon: number; to_lat: number; to_lon: number }) => { + const url = new URL('/route', OPENRAILROUTING_URL) + url.searchParams.append('point', `${args.from_lat},${args.from_lon}`) + url.searchParams.append('point', `${args.to_lat},${args.to_lon}`) + url.searchParams.append('profile', 'all_tracks') + url.searchParams.append('calc_points', 'true') + url.searchParams.append('points_encoded', 'false') + + try { + const res = await fetch(url.toString(), { signal: AbortSignal.timeout(60000) }) + const data = await res.json() as ArangoDoc + if (data.paths?.length > 0) { + const path = data.paths[0] + return { distance_km: Math.round((path.distance || 0) / 10) / 100, geometry: path.points?.coordinates || [] } + } + } catch (e) { console.error('OpenRailRouting request failed:', e) } + return null + }, + + clustered_nodes: async (_: unknown, args: { west: number; south: number; east: number; north: number; zoom: number; transport_type?: string; node_type?: string }) => { + return getClusteredNodes(args.west, args.south, args.east, args.north, args.zoom, args.transport_type, args.node_type) + }, + + products: async () => { + const db = getDb() + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'offer' + FILTER node.product_uuid != null + COLLECT product_uuid = node.product_uuid INTO offers + LET first_offer = FIRST(offers).node + RETURN { uuid: product_uuid, name: first_offer.product_name } + `) + return cursor.all() + }, + + offers_by_product: async (_: unknown, args: { product_uuid: string }) => { + const db = getDb() + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'offer' + FILTER node.product_uuid == @product_uuid + RETURN node + `, { product_uuid: args.product_uuid }) + const nodes = await cursor.all() + return nodes.map(mapOffer) + }, + + hubs_near_offer: async (_: unknown, args: { offer_uuid: string; limit?: number }) => { + const db = getDb() + const nodesCol = db.collection('nodes') + const offer = await nodesCol.document(args.offer_uuid).catch(() => null) + if (!offer || offer.latitude == null || offer.longitude == null) return [] + + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'logistics' OR node.node_type == null + FILTER node.product_uuid == null + FILTER node.latitude != null AND node.longitude != null + LET dist = DISTANCE(node.latitude, node.longitude, @lat, @lon) / 1000 + SORT dist ASC + LIMIT @limit + RETURN MERGE(node, {distance_km: dist}) + `, { lat: offer.latitude, lon: offer.longitude, limit: args.limit ?? 12 }) + const nodes = await cursor.all() + return nodes.map((n: ArangoDoc) => mapNode(n)) + }, + + suppliers: async () => { + const db = getDb() + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'offer' + FILTER node.supplier_uuid != null + COLLECT supplier_uuid = node.supplier_uuid + RETURN { uuid: supplier_uuid } + `) + return cursor.all() + }, + + products_by_supplier: async (_: unknown, args: { supplier_uuid: string }) => { + const db = getDb() + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'offer' + FILTER node.supplier_uuid == @supplier_uuid + FILTER node.product_uuid != null + COLLECT product_uuid = node.product_uuid INTO offers + LET first_offer = FIRST(offers).node + RETURN { uuid: product_uuid, name: first_offer.product_name } + `, { supplier_uuid: args.supplier_uuid }) + return cursor.all() + }, + + offers_by_supplier_product: async (_: unknown, args: { supplier_uuid: string; product_uuid: string }) => { + const db = getDb() + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'offer' + FILTER node.supplier_uuid == @supplier_uuid + FILTER node.product_uuid == @product_uuid + RETURN node + `, { supplier_uuid: args.supplier_uuid, product_uuid: args.product_uuid }) + const nodes = await cursor.all() + return nodes.map(mapOffer) + }, + + products_near_hub: async (_: unknown, args: { hub_uuid: string; radius_km?: number }) => { + const db = getDb() + const nodesCol = db.collection('nodes') + const hub = await nodesCol.document(args.hub_uuid).catch(() => null) + if (!hub || hub.latitude == null || hub.longitude == null) return [] + + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'offer' + FILTER node.product_uuid != null + FILTER node.latitude != null AND node.longitude != null + LET dist = DISTANCE(node.latitude, node.longitude, @lat, @lon) / 1000 + FILTER dist <= @radius_km + COLLECT product_uuid = node.product_uuid INTO offers + LET first_offer = FIRST(offers).node + RETURN { uuid: product_uuid, name: first_offer.product_name } + `, { lat: hub.latitude, lon: hub.longitude, radius_km: args.radius_km ?? 500 }) + return cursor.all() + }, + + suppliers_for_product: async (_: unknown, args: { product_uuid: string }) => { + const db = getDb() + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'offer' + FILTER node.product_uuid == @product_uuid + FILTER node.supplier_uuid != null + COLLECT supplier_uuid = node.supplier_uuid + RETURN { uuid: supplier_uuid } + `, { product_uuid: args.product_uuid }) + return cursor.all() + }, + + hubs_for_product: async (_: unknown, args: { product_uuid: string; radius_km?: number }) => { + const db = getDb() + const cursor = await db.query(` + FOR offer IN nodes + FILTER offer.node_type == 'offer' + FILTER offer.product_uuid == @product_uuid + FILTER offer.latitude != null AND offer.longitude != null + FOR hub IN nodes + FILTER hub.node_type == 'logistics' OR hub.node_type == null + FILTER hub.latitude != null AND hub.longitude != null + LET dist = DISTANCE(offer.latitude, offer.longitude, hub.latitude, hub.longitude) / 1000 + FILTER dist <= @radius_km + COLLECT hub_uuid = hub._key, hub_name = hub.name, + hub_lat = hub.latitude, hub_lon = hub.longitude, + hub_country = hub.country, hub_country_code = hub.country_code, + hub_transport = hub.transport_types + RETURN { + uuid: hub_uuid, name: hub_name, latitude: hub_lat, longitude: hub_lon, + country: hub_country, country_code: hub_country_code, transport_types: hub_transport + } + `, { product_uuid: args.product_uuid, radius_km: args.radius_km ?? 500 }) + const hubs = await cursor.all() + return hubs.map((h: ArangoDoc) => ({ ...mapNode(h), uuid: h.uuid })) + }, + + offers_by_hub: async (_: unknown, args: { hub_uuid: string; product_uuid: string; limit?: number }) => { + const db = getDb() + await ensureGraph() + const nodesCol = db.collection('nodes') + + const hub = await nodesCol.document(args.hub_uuid).catch(() => null) + if (!hub || hub.latitude == null || hub.longitude == null) return [] + + const hubLat = hub.latitude as number + const hubLon = hub.longitude as number + const limit = args.limit ?? 10 + + // Priority queue: [cost, seq, nodeKey, phase] + const queue: [number, number, string, Phase][] = [[0, 0, args.hub_uuid, 'end_auto']] + let counter = 0 + const visited = new Map() + const predecessors = new Map() + const nodeDocs = new Map([[args.hub_uuid, hub]]) + const foundRoutes: ArangoDoc[] = [] + let expansions = 0 + + while (queue.length > 0 && foundRoutes.length < limit && expansions < MAX_EXPANSIONS) { + queue.sort((a, b) => a[0] - b[0]) + const [cost, , nodeKey, phase] = queue.shift()! + const stateKey = `${nodeKey}:${phase}` + + if (visited.has(stateKey) && cost > visited.get(stateKey)!) continue + + const nodeDoc = nodeDocs.get(nodeKey) + if (nodeDoc && nodeDoc.product_uuid === args.product_uuid) { + const pathEdges: [string, string, ArangoDoc][] = [] + let curState = stateKey + let curKey = nodeKey + while (predecessors.has(curState)) { + const [prevState, edgeInfo] = predecessors.get(curState)! + const prevKey = prevState.split(':')[0] + pathEdges.push([curKey, prevKey, edgeInfo]) + curState = prevState + curKey = prevKey + } + + const route = buildRouteFromEdges(pathEdges, nodeDocs) + let dm: number | null = null + const srcLat = nodeDoc.latitude as number | null + const srcLon = nodeDoc.longitude as number | null + if (srcLat != null && srcLon != null) dm = distanceKm(srcLat, srcLon, hubLat, hubLon) + + foundRoutes.push({ + source_uuid: nodeKey, + source_name: nodeDoc.name || nodeDoc.product_name, + source_lat: srcLat, + source_lon: srcLon, + distance_km: dm, + routes: route ? [route] : [], + }) + continue + } + + const neighbors = await fetchNeighbors(nodeKey, phase) + expansions++ + + for (const neighbor of neighbors) { + const transportType = neighbor.transport_type as string + const nextPhase = allowedNextPhase(phase, transportType) + if (!nextPhase) continue + + const neighborKey = neighbor.neighbor_key as string + nodeDocs.set(neighborKey, neighbor.neighbor_doc) + + const travelTime = neighbor.travel_time_seconds as number | null + const edgeDist = neighbor.distance_km as number | null + const stepCost = travelTime ?? (edgeDist || 0) + const newCost = cost + stepCost + const nStateKey = `${neighborKey}:${nextPhase}` + + if (visited.has(nStateKey) && newCost >= visited.get(nStateKey)!) continue + visited.set(nStateKey, newCost) + counter++ + queue.push([newCost, counter, neighborKey, nextPhase]) + predecessors.set(nStateKey, [stateKey, neighbor]) + } + } + + return foundRoutes + }, + + offer_to_hub: async (_: unknown, args: { offer_uuid: string; hub_uuid: string }) => { + return resolveOfferToHubInternal(args.offer_uuid, args.hub_uuid) + }, + + nearest_hubs: async (_: unknown, args: { lat: number; lon: number; radius?: number; product_uuid?: string; limit?: number }) => { + const db = getDb() + const radius = args.radius ?? 1000 + const limit = args.limit ?? 12 + + let aql: string + let bindVars: Record + + if (args.product_uuid) { + aql = ` + FOR offer IN nodes + FILTER offer.node_type == 'offer' + FILTER offer.product_uuid == @product_uuid + FILTER offer.latitude != null AND offer.longitude != null + LET dist_to_offer = DISTANCE(offer.latitude, offer.longitude, @lat, @lon) / 1000 + FILTER dist_to_offer <= @radius + FOR hub IN nodes + FILTER hub.node_type == 'logistics' OR hub.node_type == null + FILTER hub.product_uuid == null + FILTER hub.latitude != null AND hub.longitude != null + LET dist_to_hub = DISTANCE(hub.latitude, hub.longitude, @lat, @lon) / 1000 + FILTER dist_to_hub <= @radius + COLLECT hub_uuid = hub._key INTO hub_group + LET first_hub = FIRST(hub_group)[0].hub + LET hub_dist = DISTANCE(first_hub.latitude, first_hub.longitude, @lat, @lon) / 1000 + SORT hub_dist ASC + LIMIT @limit + RETURN MERGE(first_hub, {_key: hub_uuid, distance_km: hub_dist}) + ` + bindVars = { lat: args.lat, lon: args.lon, radius, product_uuid: args.product_uuid, limit } + } else { + aql = ` + FOR hub IN nodes + FILTER hub.node_type == 'logistics' OR hub.node_type == null + FILTER hub.product_uuid == null + FILTER hub.latitude != null AND hub.longitude != null + LET dist = DISTANCE(hub.latitude, hub.longitude, @lat, @lon) / 1000 + FILTER dist <= @radius + SORT dist ASC + LIMIT @limit + RETURN MERGE(hub, {distance_km: dist}) + ` + bindVars = { lat: args.lat, lon: args.lon, radius, limit } + } + + const cursor = await db.query(aql, bindVars) + const hubs = await cursor.all() + return hubs.map((n: ArangoDoc) => mapNode(n)) + }, + + nearest_offers: async (_: unknown, args: { lat: number; lon: number; radius?: number; product_uuid?: string; hub_uuid?: string; limit?: number }) => { + const db = getDb() + await ensureGraph() + const radius = args.radius ?? 500 + const limit = args.limit ?? 50 + + let aql = ` + FOR offer IN nodes + FILTER offer.node_type == 'offer' + FILTER offer.product_uuid != null + FILTER offer.latitude != null AND offer.longitude != null + ` + if (args.product_uuid) aql += ` FILTER offer.product_uuid == @product_uuid\n` + aql += ` + LET dist = DISTANCE(offer.latitude, offer.longitude, @lat, @lon) / 1000 + FILTER dist <= @radius + SORT dist ASC + LIMIT @limit + RETURN MERGE(offer, {distance_km: dist}) + ` + + const bindVars: Record = { lat: args.lat, lon: args.lon, radius, limit } + if (args.product_uuid) bindVars.product_uuid = args.product_uuid + + const cursor = await db.query(aql, bindVars) + const offerNodes = await cursor.all() + + const offers = [] + for (const node of offerNodes) { + let routes: RoutePath[] = [] + if (args.hub_uuid) { + const routeResult = await resolveOfferToHubInternal(node._key, args.hub_uuid) + if (routeResult?.routes) routes = routeResult.routes as RoutePath[] + } + offers.push({ ...mapOffer(node), routes }) + } + return offers + }, + + nearest_suppliers: async (_: unknown, args: { lat: number; lon: number; radius?: number; product_uuid?: string; limit?: number }) => { + const db = getDb() + const radius = args.radius ?? 1000 + const limit = args.limit ?? 12 + + let aql = ` + FOR offer IN nodes + FILTER offer.node_type == 'offer' + FILTER offer.supplier_uuid != null + FILTER offer.latitude != null AND offer.longitude != null + ` + if (args.product_uuid) aql += ` FILTER offer.product_uuid == @product_uuid\n` + aql += ` + LET dist = DISTANCE(offer.latitude, offer.longitude, @lat, @lon) / 1000 + FILTER dist <= @radius + COLLECT supplier_uuid = offer.supplier_uuid INTO offers + LET first_offer = FIRST(offers).offer + LET supplier_dist = DISTANCE(first_offer.latitude, first_offer.longitude, @lat, @lon) / 1000 + LET supplier_node = FIRST( + FOR s IN nodes + FILTER s._key == supplier_uuid + FILTER s.node_type == 'supplier' + RETURN s + ) + SORT supplier_dist ASC + LIMIT @limit + RETURN { + uuid: supplier_uuid, + name: supplier_node != null ? supplier_node.name : first_offer.supplier_name, + latitude: supplier_node != null ? supplier_node.latitude : first_offer.latitude, + longitude: supplier_node != null ? supplier_node.longitude : first_offer.longitude, + distance_km: supplier_dist + } + ` + + const bindVars: Record = { lat: args.lat, lon: args.lon, radius, limit } + if (args.product_uuid) bindVars.product_uuid = args.product_uuid + + const cursor = await db.query(aql, bindVars) + return cursor.all() + }, + + route_to_coordinate: async (_: unknown, args: { offer_uuid: string; lat: number; lon: number }) => { + const db = getDb() + const cursor = await db.query(` + FOR hub IN nodes + FILTER hub.node_type == 'logistics' OR hub.node_type == null + FILTER hub.product_uuid == null + FILTER hub.latitude != null AND hub.longitude != null + LET dist = DISTANCE(hub.latitude, hub.longitude, @lat, @lon) / 1000 + SORT dist ASC + LIMIT 1 + RETURN hub + `, { lat: args.lat, lon: args.lon }) + const hubs = await cursor.all() + if (!hubs.length) return null + + return resolveOfferToHubInternal(args.offer_uuid, hubs[0]._key) + }, + + hubs_list: async (_: unknown, args: { limit?: number; offset?: number; country?: string; transport_type?: string; west?: number; south?: number; east?: number; north?: number }) => { + const db = getDb() + const bounds = boundsFilter(args) + + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'logistics' OR node.node_type == null + FILTER node.product_uuid == null + LET types = node.transport_types != null ? node.transport_types : [] + FILTER @transport_type == null OR @transport_type IN types + FILTER @country == null OR node.country == @country + ${bounds.filter} + SORT node.name ASC + LIMIT @offset, @limit + RETURN node + `, { transport_type: args.transport_type ?? null, country: args.country ?? null, offset: args.offset ?? 0, limit: args.limit ?? 50, ...bounds.vars }) + const nodes = await cursor.all() + return nodes.map((n: ArangoDoc) => mapNode(n)) + }, + + suppliers_list: async (_: unknown, args: { limit?: number; offset?: number; country?: string; west?: number; south?: number; east?: number; north?: number }) => { + const db = getDb() + const bounds = boundsFilter(args) + + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'supplier' + FILTER @country == null OR node.country == @country + ${bounds.filter} + SORT node.name ASC + LIMIT @offset, @limit + RETURN node + `, { country: args.country ?? null, offset: args.offset ?? 0, limit: args.limit ?? 50, ...bounds.vars }) + const nodes = await cursor.all() + return nodes.map((n: ArangoDoc) => ({ + uuid: n._key, + name: n.name ?? null, + latitude: n.latitude ?? null, + longitude: n.longitude ?? null, + distance_km: null, + })) + }, + + products_list: async (_: unknown, args: { limit?: number; offset?: number; west?: number; south?: number; east?: number; north?: number }) => { + const db = getDb() + const bounds = boundsFilter(args) + + const cursor = await db.query(` + FOR node IN nodes + FILTER node.node_type == 'offer' + FILTER node.product_uuid != null + ${bounds.filter} + COLLECT product_uuid = node.product_uuid INTO offers + LET first_offer = FIRST(offers).node + SORT first_offer.product_name ASC + LIMIT @offset, @limit + RETURN { uuid: product_uuid, name: first_offer.product_name } + `, { offset: args.offset ?? 0, limit: args.limit ?? 50, ...bounds.vars }) + return cursor.all() + }, + }, +} diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index b1183a4..0000000 --- a/tests/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# Geo Service Tests - -Comprehensive test suite for all GraphQL endpoints in the geo service. - -## Test Coverage - -### Basic Endpoints (4 tests) -- `test_products_query` - List all unique products -- `test_nodes_query_basic` - List hubs/nodes without filters -- `test_nodes_query_with_filters` - Filter nodes by transport type and country -- `test_nodes_query_with_bounds` - Filter nodes by geographic bounds -- `test_clustered_nodes_query` - Map clustering for visualization - -### Nearest Endpoints (6 tests) -- `test_nearest_hubs` - Find hubs near coordinates -- `test_nearest_hubs_with_product_filter` - Find hubs with specific product -- `test_nearest_offers` - Find offers near coordinates -- `test_nearest_offers_with_product_filter` - Find offers for specific product -- `test_nearest_suppliers` - Find suppliers near coordinates -- `test_nearest_suppliers_with_product_filter` - Find suppliers with product - -### Routing Endpoints (3 tests) -- `test_route_to_coordinate` - Multi-hop route from offer to destination -- `test_auto_route` - Road route between coordinates (requires OSRM) -- `test_rail_route` - Rail route between coordinates - -### Edge Cases (3 tests) -- `test_nearest_with_zero_radius` - Very small search radius -- `test_invalid_coordinates` - Invalid lat/lon values -- `test_nonexistent_uuid` - Non-existent offer UUID - -**Total: 16 tests covering 8 main endpoints** - -## Running Tests - -### Local Testing (against production) - -```bash -cd backends/geo -poetry install -poetry run pytest tests/test_graphql_endpoints.py -v -``` - -### Testing against different endpoint - -```bash -export TEST_GEO_URL=https://geo-staging.example.com/graphql/public/ -poetry run pytest tests/test_graphql_endpoints.py -v -``` - -### Run specific test class - -```bash -poetry run pytest tests/test_graphql_endpoints.py::TestNearestEndpoints -v -``` - -### Run single test - -```bash -poetry run pytest tests/test_graphql_endpoints.py::TestNearestEndpoints::test_nearest_offers -v -``` - -### Show print output - -```bash -poetry run pytest tests/test_graphql_endpoints.py -v -s -``` - -## CI Integration - -Tests should be run on each deployment: - -```yaml -# .gitea/workflows/test.yml -- name: Run geo endpoint tests - run: | - cd backends/geo - poetry install - export TEST_GEO_URL=https://geo.optovia.ru/graphql/public/ - poetry run pytest tests/test_graphql_endpoints.py -v -``` - -## Test Data Requirements - -Tests use real data from the production/staging database. Required data: -- At least one product in `products` collection -- At least one hub node with coordinates -- At least one offer with coordinates -- Graph edges for routing tests - -## Expected Test Results - -All tests should pass on production environment. Some tests may be skipped if: -- No products exist: `test_nearest_hubs_with_product_filter`, `test_nearest_offers_with_product_filter` -- No offers exist: `test_route_to_coordinate` -- OSRM not configured: `test_auto_route`, `test_rail_route` (warnings, not failures) - -## Troubleshooting - -### All nearest* tests return 0 results - -Check that nodes collection has documents with: -- Valid `latitude` and `longitude` fields (not null) -- Correct `node_type` field (`'hub'`, `'offer'`, `'supplier'`) - -Query ArangoDB directly: - -```javascript -// Count offers with coordinates -db._query(` - FOR node IN nodes - FILTER node.node_type == 'offer' - FILTER node.latitude != null AND node.longitude != null - RETURN node -`).toArray().length -``` - -### Test failures with 400 errors - -Check GraphQL schema matches test queries. GraphQL validation errors indicate: -- Missing required arguments -- Wrong argument types -- Invalid field names - -### Connection errors - -Verify: -- TEST_GEO_URL points to correct endpoint -- Endpoint is accessible (not behind VPN/firewall) -- GraphQL endpoint is `/graphql/public/` not `/graphql/` diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 0782730..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Geo service tests diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 4d9e8a9..0000000 Binary files a/tests/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/tests/__pycache__/test_graphql_endpoints.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_graphql_endpoints.cpython-313-pytest-9.0.2.pyc deleted file mode 100644 index 05afb75..0000000 Binary files a/tests/__pycache__/test_graphql_endpoints.cpython-313-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/test_graphql_endpoints.py b/tests/test_graphql_endpoints.py deleted file mode 100644 index 71d1990..0000000 --- a/tests/test_graphql_endpoints.py +++ /dev/null @@ -1,844 +0,0 @@ -""" -Comprehensive tests for all Geo GraphQL endpoints. -Tests use real API calls to production/staging GraphQL endpoint. -""" - -import os -import json -import requests -import pytest - -# GraphQL endpoint - override with TEST_GEO_URL env var -GEO_URL = os.getenv('TEST_GEO_URL', 'https://geo.optovia.ru/graphql/public/') - - -class TestBasicEndpoints: - """Test basic list/query endpoints.""" - - def test_products_query(self): - """Test products query - should return list of unique products.""" - query = """ - query GetProducts { - products { - uuid - name - offersCount - } - } - """ - response = requests.post(GEO_URL, json={'query': query}) - assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}" - - data = response.json() - assert 'errors' not in data, f"GraphQL errors: {data.get('errors')}" - assert 'data' in data - assert 'products' in data['data'] - - products = data['data']['products'] - assert isinstance(products, list), "products should be a list" - if len(products) > 0: - product = products[0] - assert 'uuid' in product - assert 'name' in product - assert 'offersCount' in product - assert isinstance(product['offersCount'], int) - - print(f"✓ products query: {len(products)} products found") - - def test_nodes_query_basic(self): - """Test nodes query without filters.""" - query = """ - query GetNodes($limit: Int, $offset: Int) { - nodes(limit: $limit, offset: $offset) { - uuid - name - latitude - longitude - country - transportTypes - } - nodesCount - } - """ - variables = {'limit': 10, 'offset': 0} - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}" - - data = response.json() - assert 'errors' not in data, f"GraphQL errors: {data.get('errors')}" - - nodes = data['data']['nodes'] - count = data['data']['nodesCount'] - - assert isinstance(nodes, list) - assert isinstance(count, int) - assert count > 0, "Should have at least some nodes in database" - assert len(nodes) <= 10, "Should respect limit" - - if len(nodes) > 0: - node = nodes[0] - assert 'uuid' in node - assert 'name' in node - # Coordinates might be null for some nodes - assert 'latitude' in node - assert 'longitude' in node - - print(f"✓ nodes query: {len(nodes)}/{count} nodes found") - - def test_nodes_query_with_filters(self): - """Test nodes query with transport type and country filters.""" - query = """ - query GetNodes($transportType: String, $country: String, $limit: Int) { - nodes(transportType: $transportType, country: $country, limit: $limit) { - uuid - name - country - transportTypes - } - nodesCount(transportType: $transportType, country: $country) - } - """ - variables = {'transportType': 'sea', 'limit': 5} - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200 - - data = response.json() - assert 'errors' not in data, f"GraphQL errors: {data.get('errors')}" - - nodes = data['data']['nodes'] - # All nodes should have 'sea' in their transportTypes - for node in nodes: - if node.get('transportTypes'): - assert 'sea' in node['transportTypes'], f"Node {node['uuid']} missing 'sea' transport type" - - print(f"✓ nodes query with filters: {len(nodes)} sea nodes found") - - def test_nodes_query_with_bounds(self): - """Test nodes query with geographic bounds.""" - query = """ - query GetNodes($west: Float, $south: Float, $east: Float, $north: Float, $limit: Int) { - nodes(west: $west, south: $south, east: $east, north: $north, limit: $limit) { - uuid - name - latitude - longitude - } - } - """ - # Bounds for central Europe - variables = { - 'west': 5.0, - 'south': 45.0, - 'east': 15.0, - 'north': 55.0, - 'limit': 20 - } - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200 - - data = response.json() - assert 'errors' not in data, f"GraphQL errors: {data.get('errors')}" - - nodes = data['data']['nodes'] - # Verify all nodes are within bounds - for node in nodes: - if node.get('latitude') and node.get('longitude'): - lat = float(node['latitude']) - lon = float(node['longitude']) - assert variables['south'] <= lat <= variables['north'], \ - f"Node {node['uuid']} latitude {lat} outside bounds" - assert variables['west'] <= lon <= variables['east'], \ - f"Node {node['uuid']} longitude {lon} outside bounds" - - print(f"✓ nodes with bounds: {len(nodes)} nodes in central Europe") - - def test_clustered_nodes_query(self): - """Test clusteredNodes query for map clustering.""" - query = """ - query GetClusteredNodes($west: Float!, $south: Float!, $east: Float!, $north: Float!, $zoom: Int!) { - clusteredNodes(west: $west, south: $south, east: $east, north: $north, zoom: $zoom) { - id - latitude - longitude - count - expansionZoom - name - } - } - """ - # World view - variables = { - 'west': -180.0, - 'south': -90.0, - 'east': 180.0, - 'north': 90.0, - 'zoom': 2 - } - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200 - - data = response.json() - assert 'errors' not in data, f"GraphQL errors: {data.get('errors')}" - - clusters = data['data']['clusteredNodes'] - assert isinstance(clusters, list) - assert len(clusters) > 0, "Should have clusters at zoom level 2" - - for cluster in clusters: - assert 'id' in cluster - assert 'latitude' in cluster - assert 'longitude' in cluster - assert 'count' in cluster - assert cluster['count'] >= 1 - - print(f"✓ clusteredNodes: {len(clusters)} clusters/points at zoom 2") - - -class TestNearestEndpoints: - """Test new coordinate-based 'nearest' endpoints.""" - - def test_nearest_hubs(self): - """Test nearestHubs query - find hubs near coordinates.""" - query = """ - query NearestHubs($lat: Float!, $lon: Float!, $radius: Float, $limit: Int) { - nearestHubs(lat: $lat, lon: $lon, radius: $radius, limit: $limit) { - uuid - name - latitude - longitude - country - transportTypes - distanceKm - } - } - """ - # Rotterdam coordinates (major European port) - variables = { - 'lat': 51.9244, - 'lon': 4.4777, - 'radius': 200, - 'limit': 5 - } - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200, f"Status: {response.status_code}, Body: {response.text}" - - data = response.json() - assert 'errors' not in data, f"GraphQL errors: {data.get('errors')}" - - hubs = data['data']['nearestHubs'] - assert isinstance(hubs, list) - - # Should find some hubs in 200km radius of Rotterdam - if len(hubs) > 0: - hub = hubs[0] - assert 'uuid' in hub - assert 'name' in hub - assert 'distanceKm' in hub - assert hub['distanceKm'] <= 200, f"Hub {hub['uuid']} distance {hub['distanceKm']} exceeds radius" - - # Verify hubs are sorted by distance - distances = [h['distanceKm'] for h in hubs] - assert distances == sorted(distances), "Hubs should be sorted by distance" - - print(f"✓ nearestHubs: {len(hubs)} hubs near Rotterdam") - - def test_nearest_hubs_with_product_filter(self): - """Test nearestHubs with product filter.""" - # First get a product UUID - products_query = "query { products { uuid } }" - prod_response = requests.post(GEO_URL, json={'query': products_query}) - products = prod_response.json()['data']['products'] - - if not products: - pytest.skip("No products in database") - - product_uuid = products[0]['uuid'] - - query = """ - query NearestHubs($lat: Float!, $lon: Float!, $radius: Float, $productUuid: String) { - nearestHubs(lat: $lat, lon: $lon, radius: $radius, productUuid: $productUuid) { - uuid - name - distanceKm - } - } - """ - variables = { - 'lat': 50.0, - 'lon': 10.0, - 'radius': 1000, - 'productUuid': product_uuid - } - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200 - - data = response.json() - assert 'errors' not in data, f"GraphQL errors: {data.get('errors')}" - - hubs = data['data']['nearestHubs'] - print(f"✓ nearestHubs with product filter: {len(hubs)} hubs for product {product_uuid[:8]}") - - def test_nearest_offers(self): - """Test nearestOffers query - find offers near coordinates.""" - query = """ - query NearestOffers($lat: Float!, $lon: Float!, $radius: Float, $limit: Int) { - nearestOffers(lat: $lat, lon: $lon, radius: $radius, limit: $limit) { - uuid - productUuid - productName - latitude - longitude - pricePerUnit - currency - distanceKm - } - } - """ - # Central Europe - variables = { - 'lat': 50.0, - 'lon': 10.0, - 'radius': 500, - 'limit': 10 - } - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200, f"Status: {response.status_code}, Body: {response.text}" - - data = response.json() - assert 'errors' not in data, f"GraphQL errors: {data.get('errors')}" - - offers = data['data']['nearestOffers'] - assert isinstance(offers, list) - - if len(offers) > 0: - offer = offers[0] - assert 'uuid' in offer - assert 'productUuid' in offer - assert 'distanceKm' in offer - assert offer['distanceKm'] <= 500 - - # Verify offers are sorted by distance - distances = [o['distanceKm'] for o in offers] - assert distances == sorted(distances), "Offers should be sorted by distance" - - print(f"✓ nearestOffers: {len(offers)} offers in Central Europe") - - def test_nearest_offers_with_product_filter(self): - """Test nearestOffers with product UUID filter.""" - # First get a product UUID - products_query = "query { products { uuid name } }" - prod_response = requests.post(GEO_URL, json={'query': products_query}) - products = prod_response.json()['data']['products'] - - if not products: - pytest.skip("No products in database") - - product_uuid = products[0]['uuid'] - product_name = products[0]['name'] - - query = """ - query NearestOffers($lat: Float!, $lon: Float!, $radius: Float, $productUuid: String) { - nearestOffers(lat: $lat, lon: $lon, radius: $radius, productUuid: $productUuid) { - uuid - productUuid - productName - distanceKm - } - } - """ - # Global search with large radius - variables = { - 'lat': 0.0, - 'lon': 0.0, - 'radius': 20000, - 'productUuid': product_uuid - } - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200 - - data = response.json() - assert 'errors' not in data, f"GraphQL errors: {data.get('errors')}" - - offers = data['data']['nearestOffers'] - # All offers should be for the requested product - for offer in offers: - assert offer['productUuid'] == product_uuid, \ - f"Offer {offer['uuid']} has wrong product UUID" - - print(f"✓ nearestOffers with product: {len(offers)} offers for '{product_name}'") - - def test_nearest_suppliers(self): - """Test nearestSuppliers query - find suppliers near coordinates.""" - query = """ - query NearestSuppliers($lat: Float!, $lon: Float!, $radius: Float, $limit: Int) { - nearestSuppliers(lat: $lat, lon: $lon, radius: $radius, limit: $limit) { - uuid - name - latitude - longitude - distanceKm - } - } - """ - variables = { - 'lat': 52.52, # Berlin - 'lon': 13.405, - 'radius': 300, - 'limit': 10 - } - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200 - - data = response.json() - assert 'errors' not in data, f"GraphQL errors: {data.get('errors')}" - - suppliers = data['data']['nearestSuppliers'] - assert isinstance(suppliers, list) - - if len(suppliers) > 0: - supplier = suppliers[0] - assert 'uuid' in supplier - assert 'name' in supplier - assert 'distanceKm' in supplier - assert supplier['distanceKm'] <= 300 - - # Verify sorted by distance - distances = [s['distanceKm'] for s in suppliers] - assert distances == sorted(distances) - - print(f"✓ nearestSuppliers: {len(suppliers)} suppliers near Berlin") - - def test_nearest_suppliers_with_product_filter(self): - """Test nearestSuppliers with product filter.""" - # Get a product UUID - products_query = "query { products { uuid } }" - prod_response = requests.post(GEO_URL, json={'query': products_query}) - products = prod_response.json()['data']['products'] - - if not products: - pytest.skip("No products in database") - - product_uuid = products[0]['uuid'] - - query = """ - query NearestSuppliers($lat: Float!, $lon: Float!, $radius: Float, $productUuid: String) { - nearestSuppliers(lat: $lat, lon: $lon, radius: $radius, productUuid: $productUuid) { - uuid - name - distanceKm - } - } - """ - variables = { - 'lat': 50.0, - 'lon': 10.0, - 'radius': 1000, - 'productUuid': product_uuid - } - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200 - - data = response.json() - assert 'errors' not in data, f"GraphQL errors: {data.get('errors')}" - - suppliers = data['data']['nearestSuppliers'] - print(f"✓ nearestSuppliers with product: {len(suppliers)} suppliers for product {product_uuid[:8]}") - - def test_nearest_offers_with_hub_uuid(self): - """Test nearestOffers with hubUuid - should return offers with calculated routes. - - This tests the fix for the bug where resolve_offer_to_hub was called incorrectly - (self was None in graphene resolvers). - """ - # First, get a hub UUID from the database - hubs_query = """ - query { - nearestHubs(lat: 0, lon: 0, radius: 20000, limit: 5) { - uuid - name - latitude - longitude - } - } - """ - hubs_response = requests.post(GEO_URL, json={'query': hubs_query}) - hubs_data = hubs_response.json() - - if not hubs_data.get('data', {}).get('nearestHubs'): - pytest.skip("No hubs found in database") - - hub = hubs_data['data']['nearestHubs'][0] - hub_uuid = hub['uuid'] - hub_lat = hub['latitude'] - hub_lon = hub['longitude'] - - # Now test nearestOffers with this hub UUID - query = """ - query NearestOffers($lat: Float!, $lon: Float!, $radius: Float, $hubUuid: String, $limit: Int) { - nearestOffers(lat: $lat, lon: $lon, radius: $radius, hubUuid: $hubUuid, limit: $limit) { - uuid - productUuid - productName - supplierUuid - supplierName - latitude - longitude - pricePerUnit - currency - distanceKm - routes { - totalDistanceKm - totalTimeSeconds - stages { - fromUuid - fromName - fromLat - fromLon - toUuid - toName - toLat - toLon - distanceKm - travelTimeSeconds - transportType - } - } - } - } - """ - # Search around the hub location with large radius - variables = { - 'lat': float(hub_lat) if hub_lat else 0.0, - 'lon': float(hub_lon) if hub_lon else 0.0, - 'radius': 5000, # 5000km radius to find offers - 'hubUuid': hub_uuid, - 'limit': 10 - } - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200, f"Status: {response.status_code}, Body: {response.text}" - - data = response.json() - assert 'errors' not in data, f"GraphQL errors: {data.get('errors')}" - - offers = data['data']['nearestOffers'] - assert isinstance(offers, list), "nearestOffers should return a list" - - # The key assertion: with hubUuid, we should get offers with routes calculated - # (This was the bug - resolve_offer_to_hub was failing silently) - print(f"✓ nearestOffers with hubUuid: {len(offers)} offers for hub '{hub['name']}'") - - if len(offers) > 0: - # Check first offer structure - offer = offers[0] - assert 'uuid' in offer - assert 'productUuid' in offer - assert 'routes' in offer, "Offer should have routes field when hubUuid is provided" - - # If routes exist, verify structure - if offer['routes'] and len(offer['routes']) > 0: - route = offer['routes'][0] - assert 'totalDistanceKm' in route - assert 'totalTimeSeconds' in route - assert 'stages' in route - - if route['stages'] and len(route['stages']) > 0: - stage = route['stages'][0] - assert 'fromUuid' in stage - assert 'toUuid' in stage - assert 'transportType' in stage - assert 'distanceKm' in stage - print(f" Route has {len(route['stages'])} stages, total {route['totalDistanceKm']:.1f}km") - - def test_nearest_offers_with_hub_and_product(self): - """Test nearestOffers with both hubUuid and productUuid filters.""" - # Get a product and hub - products_query = "query { products { uuid name } }" - prod_response = requests.post(GEO_URL, json={'query': products_query}) - products = prod_response.json().get('data', {}).get('products', []) - - hubs_query = """ - query { - nearestHubs(lat: 0, lon: 0, radius: 20000, limit: 1) { - uuid - name - latitude - longitude - } - } - """ - hubs_response = requests.post(GEO_URL, json={'query': hubs_query}) - hubs = hubs_response.json().get('data', {}).get('nearestHubs', []) - - if not products or not hubs: - pytest.skip("No products or hubs in database") - - product = products[0] - hub = hubs[0] - - query = """ - query NearestOffers($lat: Float!, $lon: Float!, $radius: Float, $productUuid: String, $hubUuid: String, $limit: Int) { - nearestOffers(lat: $lat, lon: $lon, radius: $radius, productUuid: $productUuid, hubUuid: $hubUuid, limit: $limit) { - uuid - productUuid - productName - routes { - totalDistanceKm - stages { - transportType - } - } - } - } - """ - variables = { - 'lat': float(hub['latitude']) if hub['latitude'] else 0.0, - 'lon': float(hub['longitude']) if hub['longitude'] else 0.0, - 'radius': 10000, - 'productUuid': product['uuid'], - 'hubUuid': hub['uuid'], - 'limit': 5 # Limit to avoid timeout when calculating routes - } - - # Use longer timeout as route calculation takes time - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}, timeout=120) - assert response.status_code == 200 - - data = response.json() - assert 'errors' not in data, f"GraphQL errors: {data.get('errors')}" - - offers = data['data']['nearestOffers'] - - # All offers should be for the requested product - for offer in offers: - assert offer['productUuid'] == product['uuid'], \ - f"Offer has wrong productUuid: {offer['productUuid']} != {product['uuid']}" - - print(f"✓ nearestOffers with hub+product: {len(offers)} offers for '{product['name']}' via hub '{hub['name']}'") - - -class TestRoutingEndpoints: - """Test routing and pathfinding endpoints.""" - - def test_route_to_coordinate(self): - """Test routeToCoordinate query - find route from offer to coordinates.""" - # First, get an offer UUID with coordinates - offers_query = """ - query { - nearestOffers(lat: 50.0, lon: 10.0, radius: 1000, limit: 1) { - uuid - latitude - longitude - } - } - """ - offers_response = requests.post(GEO_URL, json={'query': offers_query}) - offers_data = offers_response.json() - - if not offers_data.get('data', {}).get('nearestOffers'): - pytest.skip("No offers found for routing test") - - offer = offers_data['data']['nearestOffers'][0] - offer_uuid = offer['uuid'] - - query = """ - query RouteToCoordinate($offerUuid: String!, $lat: Float!, $lon: Float!) { - routeToCoordinate(offerUuid: $offerUuid, lat: $lat, lon: $lon) { - offerUuid - distanceKm - routes { - totalDistanceKm - totalTimeSeconds - stages { - fromUuid - fromName - fromLat - fromLon - toUuid - toName - toLat - toLon - distanceKm - travelTimeSeconds - transportType - } - } - } - } - """ - # Route to Amsterdam - variables = { - 'offerUuid': offer_uuid, - 'lat': 52.3676, - 'lon': 4.9041 - } - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200, f"Status: {response.status_code}, Body: {response.text}" - - data = response.json() - assert 'errors' not in data, f"GraphQL errors: {data.get('errors')}" - - route_data = data['data']['routeToCoordinate'] - assert route_data is not None - assert route_data['offerUuid'] == offer_uuid - - if route_data.get('routes'): - route = route_data['routes'][0] - assert 'totalDistanceKm' in route - assert 'totalTimeSeconds' in route - assert 'stages' in route - assert len(route['stages']) > 0 - - # Verify each stage has required fields - for stage in route['stages']: - assert 'fromUuid' in stage - assert 'toUuid' in stage - assert 'distanceKm' in stage - assert 'transportType' in stage - - print(f"✓ routeToCoordinate: {len(route['stages'])} stages, {route['totalDistanceKm']:.1f}km") - else: - print(f"✓ routeToCoordinate: no routes found (offer may be isolated)") - - def test_auto_route(self): - """Test autoRoute query - calculate road route between coordinates.""" - query = """ - query AutoRoute($fromLat: Float!, $fromLon: Float!, $toLat: Float!, $toLon: Float!) { - autoRoute(fromLat: $fromLat, fromLon: $fromLon, toLat: $toLat, toLon: $toLon) { - distanceKm - geometry - } - } - """ - # Route from Amsterdam to Rotterdam - variables = { - 'fromLat': 52.3676, - 'fromLon': 4.9041, - 'toLat': 51.9244, - 'toLon': 4.4777 - } - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200 - - data = response.json() - # Auto route might not be available (requires OSRM service) - if 'errors' in data: - print("⚠ autoRoute: service not available (expected if OSRM not configured)") - else: - route = data['data']['autoRoute'] - if route: - assert 'distanceKm' in route - assert route['distanceKm'] > 0 - print(f"✓ autoRoute: {route['distanceKm']:.1f}km Amsterdam → Rotterdam") - else: - print("⚠ autoRoute: returned null") - - def test_rail_route(self): - """Test railRoute query - calculate rail route between coordinates.""" - query = """ - query RailRoute($fromLat: Float!, $fromLon: Float!, $toLat: Float!, $toLon: Float!) { - railRoute(fromLat: $fromLat, fromLon: $fromLon, toLat: $toLat, toLon: $toLon) { - distanceKm - geometry - } - } - """ - variables = { - 'fromLat': 52.3676, - 'fromLon': 4.9041, - 'toLat': 51.9244, - 'toLon': 4.4777 - } - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200 - - data = response.json() - # Rail route might not be available - if 'errors' in data: - print("⚠ railRoute: service not available") - else: - route = data['data']['railRoute'] - if route: - assert 'distanceKm' in route - print(f"✓ railRoute: {route['distanceKm']:.1f}km") - else: - print("⚠ railRoute: returned null") - - -class TestEdgeCases: - """Test edge cases and error handling.""" - - def test_nearest_with_zero_radius(self): - """Test nearest queries with very small radius.""" - query = """ - query NearestHubs($lat: Float!, $lon: Float!, $radius: Float) { - nearestHubs(lat: $lat, lon: $lon, radius: $radius) { - uuid - } - } - """ - variables = {'lat': 50.0, 'lon': 10.0, 'radius': 0.001} - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - assert response.status_code == 200 - - data = response.json() - assert 'errors' not in data - # Should return empty list or very few results - hubs = data['data']['nearestHubs'] - assert isinstance(hubs, list) - print(f"✓ nearestHubs with tiny radius: {len(hubs)} hubs") - - def test_invalid_coordinates(self): - """Test behavior with invalid latitude/longitude values.""" - query = """ - query NearestHubs($lat: Float!, $lon: Float!) { - nearestHubs(lat: $lat, lon: $lon) { - uuid - } - } - """ - # Latitude > 90 is invalid - variables = {'lat': 100.0, 'lon': 10.0} - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - # Should either return error or empty results - assert response.status_code in [200, 400] - print("✓ invalid coordinates handled") - - def test_nonexistent_uuid(self): - """Test route query with non-existent offer UUID.""" - query = """ - query RouteToCoordinate($offerUuid: String!, $lat: Float!, $lon: Float!) { - routeToCoordinate(offerUuid: $offerUuid, lat: $lat, lon: $lon) { - offerUuid - } - } - """ - variables = { - 'offerUuid': 'nonexistent-uuid-12345', - 'lat': 50.0, - 'lon': 10.0 - } - - response = requests.post(GEO_URL, json={'query': query, 'variables': variables}) - # Should handle gracefully (null result or error) - assert response.status_code in [200, 400] - print("✓ nonexistent UUID handled") - - -if __name__ == '__main__': - pytest.main([__file__, '-v', '-s']) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..459e4f3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}