diff --git a/src/schema.ts b/src/schema.ts index c772b39..7c81210 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -4,8 +4,22 @@ import { distanceKm, buildRouteFromEdges, type ArangoDoc, type RoutePath } from 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 EXCHANGE_GRAPHQL_URL = process.env.EXCHANGE_GRAPHQL_URL || 'https://exchange.optovia.ru/graphql' const MAX_EXPANSIONS = 20000 +type ProductCatalogItem = { + uuid: string + name: string | null + imageUrl: string | null +} + +type ProductCatalogPayload = { + data?: { + products?: ProductCatalogItem[] + } + errors?: { message?: string }[] +} + export const typeDefs = `#graphql type Edge { toUuid: String @@ -185,12 +199,29 @@ function mapNode(doc: ArangoDoc, includeEdges = false) { } } -function mapOffer(doc: ArangoDoc) { +function catalogProduct(productUuid: unknown, catalog: Map) { + const uuid = typeof productUuid === 'string' ? productUuid : '' + return uuid ? catalog.get(uuid) : undefined +} + +function productView(doc: ArangoDoc, catalog: Map) { + const productUuid = doc.product_uuid ?? doc.uuid ?? null + const product = catalogProduct(productUuid, catalog) + return { + uuid: productUuid, + name: product?.name ?? doc.product_name ?? doc.name ?? null, + imageUrl: product?.imageUrl ?? null, + offersCount: doc.offers_count ?? doc.offersCount ?? null, + } +} + +function mapOffer(doc: ArangoDoc, catalog = new Map()) { + const product = catalogProduct(doc.product_uuid, catalog) return { uuid: doc._key, productUuid: doc.product_uuid ?? null, - productName: doc.product_name ?? null, - productImageUrl: doc.product_image_url ?? null, + productName: product?.name ?? doc.product_name ?? null, + productImageUrl: product?.imageUrl ?? null, supplierUuid: doc.supplier_uuid ?? null, supplierName: doc.supplier_name ?? null, supplierLogoUrl: doc.supplier_logo_url ?? null, @@ -206,6 +237,36 @@ function mapOffer(doc: ArangoDoc) { } } +async function loadProductCatalog() { + const res = await fetch(EXCHANGE_GRAPHQL_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GeoProductCatalog { + products(limit: 1000) { + uuid + name + imageUrl + } + } + `, + }), + }) + if (!res.ok) throw new Error(`Exchange product catalog request failed: ${res.status}`) + + const payload = await res.json() as ProductCatalogPayload + if (payload.errors?.length) { + throw new Error(payload.errors.map((error) => error.message ?? 'Exchange GraphQL error').join('\n')) + } + + return new Map( + (payload.data?.products ?? []) + .filter((product) => product.uuid) + .map((product) => [product.uuid, product]), + ) +} + function mapEdge(doc: ArangoDoc) { return { toUuid: doc.to_uuid ?? doc._key ?? null, @@ -614,19 +675,22 @@ export const resolvers = { products: async () => { const db = getDb() + const productCatalog = await loadProductCatalog() 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, imageUrl: first_offer.product_image_url } + RETURN { uuid: product_uuid, product_uuid, product_name: first_offer.product_name, offers_count: LENGTH(offers) } `) - return cursor.all() + const products = await cursor.all() + return products.map((product: ArangoDoc) => productView(product, productCatalog)) }, offersByProduct: async (_: unknown, args: { productUuid: string }) => { const db = getDb() + const productCatalog = await loadProductCatalog() const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'offer' @@ -634,7 +698,7 @@ export const resolvers = { RETURN node `, { product_uuid: args.productUuid }) const nodes = await cursor.all() - return nodes.map(mapOffer) + return nodes.map((node: ArangoDoc) => mapOffer(node, productCatalog)) }, hubsNearOffer: async (_: unknown, args: { offerUuid: string; limit?: number }) => { @@ -680,6 +744,7 @@ export const resolvers = { productsBySupplier: async (_: unknown, args: { supplierUuid: string }) => { const db = getDb() + const productCatalog = await loadProductCatalog() const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'offer' @@ -687,13 +752,15 @@ export const resolvers = { 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, imageUrl: first_offer.product_image_url } + RETURN { uuid: product_uuid, product_uuid, product_name: first_offer.product_name, offers_count: LENGTH(offers) } `, { supplier_uuid: args.supplierUuid }) - return cursor.all() + const products = await cursor.all() + return products.map((product: ArangoDoc) => productView(product, productCatalog)) }, offersBySupplierProduct: async (_: unknown, args: { supplierUuid: string; productUuid: string }) => { const db = getDb() + const productCatalog = await loadProductCatalog() const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'offer' @@ -702,11 +769,12 @@ export const resolvers = { RETURN node `, { supplier_uuid: args.supplierUuid, product_uuid: args.productUuid }) const nodes = await cursor.all() - return nodes.map(mapOffer) + return nodes.map((node: ArangoDoc) => mapOffer(node, productCatalog)) }, productsNearHub: async (_: unknown, args: { hubUuid: string; radiusKm?: number }) => { const db = getDb() + const productCatalog = await loadProductCatalog() const nodesCol = db.collection('nodes') const hub = await nodesCol.document(args.hubUuid).catch(() => null) if (!hub || hub.latitude == null || hub.longitude == null) return [] @@ -720,9 +788,10 @@ export const resolvers = { 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, imageUrl: first_offer.product_image_url } + RETURN { uuid: product_uuid, product_uuid, product_name: first_offer.product_name, offers_count: LENGTH(offers) } `, { lat: hub.latitude, lon: hub.longitude, radius_km: args.radiusKm ?? 500 }) - return cursor.all() + const products = await cursor.all() + return products.map((product: ArangoDoc) => productView(product, productCatalog)) }, suppliersForProduct: async (_: unknown, args: { productUuid: string }) => { @@ -914,6 +983,7 @@ export const resolvers = { nearestOffers: async (_: unknown, args: { lat: number; lon: number; radius?: number; productUuid?: string; hubUuid?: string; limit?: number }) => { const db = getDb() + const productCatalog = await loadProductCatalog() await ensureGraph() const radius = args.radius ?? 500 const limit = args.limit ?? 50 @@ -946,7 +1016,7 @@ export const resolvers = { const routeResult = await resolveOfferToHubInternal(node._key, args.hubUuid) if (routeResult?.routes) routes = routeResult.routes as RoutePath[] } - offers.push({ ...mapOffer(node), routes }) + offers.push({ ...mapOffer(node, productCatalog), routes }) } return offers }, @@ -1062,6 +1132,7 @@ export const resolvers = { productsList: async (_: unknown, args: { limit?: number; offset?: number; west?: number; south?: number; east?: number; north?: number }) => { const db = getDb() + const productCatalog = await loadProductCatalog() const bounds = boundsFilter(args) const cursor = await db.query(` @@ -1073,9 +1144,10 @@ export const resolvers = { LET first_offer = FIRST(offers).node SORT first_offer.product_name ASC LIMIT @offset, @limit - RETURN { uuid: product_uuid, name: first_offer.product_name, imageUrl: first_offer.product_image_url } + RETURN { uuid: product_uuid, product_uuid, product_name: first_offer.product_name, offers_count: LENGTH(offers) } `, { offset: args.offset ?? 0, limit: args.limit ?? 50, ...bounds.vars }) - return cursor.all() + const products = await cursor.all() + return products.map((product: ArangoDoc) => productView(product, productCatalog)) }, }, }