Split exchange into quotes service
All checks were successful
Build Docker Image / build (push) Successful in 2m0s

This commit is contained in:
Ruslan Bakiev
2026-05-31 16:14:18 +05:00
parent 5eae00961c
commit a499e03401
16 changed files with 2048 additions and 2656 deletions

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# Optovia Exchange
GraphQL service for the marketplace layer: suppliers, products, and quotes.
This service does not own logistics hubs, route graph data, route pricing, or shipment calculations. Those belong to `backends/logistics`.

3469
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,22 +9,23 @@
"start": "prisma migrate deploy && node dist/index.js"
},
"dependencies": {
"@apollo/server": "^4.11.3",
"@prisma/client": "^6.5.0",
"@temporalio/client": "^1.11.7",
"cors": "^2.8.5",
"express": "^4.21.2",
"graphql": "^16.10.0",
"graphql-tag": "^2.12.6",
"jose": "^6.0.11",
"@sentry/node": "^9.5.0"
"@apollo/server": "^5.5.1",
"@as-integrations/express5": "^1.1.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@sentry/node": "^10.55.0",
"cors": "^2.8.6",
"express": "^5.2.1",
"graphql": "^16.14.0",
"pg": "^8.21.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.13.0",
"prisma": "^6.5.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.9.1",
"@types/pg": "^8.20.0",
"prisma": "^7.8.0",
"tsx": "^4.22.3",
"typescript": "^6.0.3"
}
}

12
prisma.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import 'dotenv/config'
import { defineConfig, env } from 'prisma/config'
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
url: env('EXCHANGE_DATABASE_URL'),
},
})

View File

@@ -0,0 +1,98 @@
-- DropTable
DROP TABLE "offers";
-- DropTable
DROP TABLE "calculations";
-- DropTable
DROP TABLE "suppliers";
-- CreateTable
CREATE TABLE "exchange_quotes" (
"id" SERIAL NOT NULL,
"uuid" TEXT NOT NULL,
"supplier_id" INTEGER NOT NULL,
"product_id" INTEGER NOT NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'active',
"quantity" DECIMAL(12,2) NOT NULL,
"unit" VARCHAR(20) NOT NULL DEFAULT 'ton',
"price_per_unit" DECIMAL(12,2) NOT NULL,
"currency" VARCHAR(10) NOT NULL DEFAULT 'USD',
"incoterms_code" VARCHAR(20),
"origin_point_uuid" VARCHAR(100),
"origin_name" VARCHAR(255),
"valid_until" DATE,
"notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "exchange_quotes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "exchange_suppliers" (
"id" SERIAL NOT NULL,
"uuid" TEXT NOT NULL,
"team_uuid" VARCHAR(100),
"name" VARCHAR(255) NOT NULL,
"description" TEXT,
"country" VARCHAR(100) NOT NULL DEFAULT '',
"country_code" VARCHAR(10) NOT NULL DEFAULT '',
"logo_url" VARCHAR(500),
"is_verified" BOOLEAN NOT NULL DEFAULT false,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "exchange_suppliers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "exchange_products" (
"id" SERIAL NOT NULL,
"uuid" TEXT NOT NULL,
"sku" VARCHAR(100),
"name" VARCHAR(255) NOT NULL,
"category_name" VARCHAR(255) NOT NULL DEFAULT '',
"unit" VARCHAR(20) NOT NULL DEFAULT 'ton',
"description" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "exchange_products_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "exchange_quotes_uuid_key" ON "exchange_quotes"("uuid");
-- CreateIndex
CREATE INDEX "exchange_quotes_status_product_id_created_at_idx" ON "exchange_quotes"("status", "product_id", "created_at");
-- CreateIndex
CREATE INDEX "exchange_quotes_supplier_id_created_at_idx" ON "exchange_quotes"("supplier_id", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "exchange_suppliers_uuid_key" ON "exchange_suppliers"("uuid");
-- CreateIndex
CREATE UNIQUE INDEX "exchange_suppliers_team_uuid_key" ON "exchange_suppliers"("team_uuid");
-- CreateIndex
CREATE INDEX "exchange_suppliers_country_code_is_active_idx" ON "exchange_suppliers"("country_code", "is_active");
-- CreateIndex
CREATE UNIQUE INDEX "exchange_products_uuid_key" ON "exchange_products"("uuid");
-- CreateIndex
CREATE UNIQUE INDEX "exchange_products_sku_key" ON "exchange_products"("sku");
-- CreateIndex
CREATE INDEX "exchange_products_category_name_is_active_idx" ON "exchange_products"("category_name", "is_active");
-- AddForeignKey
ALTER TABLE "exchange_quotes" ADD CONSTRAINT "exchange_quotes_supplier_id_fkey" FOREIGN KEY ("supplier_id") REFERENCES "exchange_suppliers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "exchange_quotes" ADD CONSTRAINT "exchange_quotes_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "exchange_products"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -4,68 +4,67 @@ generator client {
datasource db {
provider = "postgresql"
url = env("EXCHANGE_DATABASE_URL")
}
model Offer {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
teamUuid String @map("team_uuid") @db.VarChar(100)
status String @default("active") @db.VarChar(20)
workflowStatus String @default("pending") @map("workflow_status") @db.VarChar(20)
workflowError String? @map("workflow_error")
locationUuid String? @map("location_uuid") @db.VarChar(100)
locationName String @default("") @map("location_name") @db.VarChar(255)
locationCountry String @default("") @map("location_country") @db.VarChar(100)
locationCountryCode String @default("") @map("location_country_code") @db.VarChar(10)
locationLatitude Float? @map("location_latitude")
locationLongitude Float? @map("location_longitude")
productUuid String @map("product_uuid") @db.VarChar(100)
productName String @map("product_name") @db.VarChar(255)
categoryName String @default("") @map("category_name") @db.VarChar(255)
quantity Decimal @db.Decimal(12, 2)
unit String @default("ton") @db.VarChar(20)
pricePerUnit Decimal @map("price_per_unit") @db.Decimal(12, 2)
currency String @default("USD") @db.VarChar(10)
terminusSchemaId String? @map("terminus_schema_id") @db.VarChar(255)
terminusDocumentId String? @map("terminus_document_id") @db.VarChar(255)
description String?
validUntil DateTime? @map("valid_until") @db.Date
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
model Quote {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
supplierId Int @map("supplier_id")
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
productId Int @map("product_id")
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
status String @default("active") @db.VarChar(20)
quantity Decimal @db.Decimal(12, 2)
unit String @default("ton") @db.VarChar(20)
pricePerUnit Decimal @map("price_per_unit") @db.Decimal(12, 2)
currency String @default("USD") @db.VarChar(10)
incotermsCode String? @map("incoterms_code") @db.VarChar(20)
originPointUuid String? @map("origin_point_uuid") @db.VarChar(100)
originName String? @map("origin_name") @db.VarChar(255)
validUntil DateTime? @map("valid_until") @db.Date
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("offers")
@@index([status, productId, createdAt])
@@index([supplierId, createdAt])
@@map("exchange_quotes")
}
model Request {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
productUuid String @map("product_uuid") @db.VarChar(100)
quantity Decimal @db.Decimal(12, 2)
sourceLocationUuid String @map("source_location_uuid") @db.VarChar(100)
userId String @map("user_id") @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
model Supplier {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
teamUuid String? @unique @map("team_uuid") @db.VarChar(100)
name String @db.VarChar(255)
description String?
country String @default("") @db.VarChar(100)
countryCode String @default("") @map("country_code") @db.VarChar(10)
logoUrl String? @map("logo_url") @db.VarChar(500)
isVerified Boolean @default(false) @map("is_verified")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("calculations")
quotes Quote[]
@@index([countryCode, isActive])
@@map("exchange_suppliers")
}
model SupplierProfile {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
teamUuid String @unique @map("team_uuid") @db.VarChar(100)
kycProfileUuid String? @map("kyc_profile_uuid") @db.VarChar(100)
name String @db.VarChar(255)
description String?
country String @default("") @db.VarChar(100)
countryCode String @default("") @map("country_code") @db.VarChar(10)
logoUrl String? @map("logo_url") @db.VarChar(500)
latitude Float?
longitude Float?
isVerified Boolean @default(false) @map("is_verified")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
model Product {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
sku String? @unique @db.VarChar(100)
name String @db.VarChar(255)
categoryName String @default("") @map("category_name") @db.VarChar(255)
unit String @default("ton") @db.VarChar(20)
description String?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("suppliers")
quotes Quote[]
@@index([categoryName, isActive])
@@map("exchange_products")
}

View File

@@ -1,73 +0,0 @@
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
import { GraphQLError } from 'graphql'
import type { Request } from 'express'
const LOGTO_JWKS_URL = process.env.LOGTO_JWKS_URL || 'https://auth.optovia.ru/oidc/jwks'
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || 'https://auth.optovia.ru/oidc'
const LOGTO_EXCHANGE_AUDIENCE = process.env.LOGTO_EXCHANGE_AUDIENCE || 'https://exchange.optovia.ru'
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL))
export interface AuthContext {
userId?: string
teamUuid?: string
scopes: string[]
isM2M?: boolean
}
function getBearerToken(req: Request): string {
const auth = req.headers.authorization || ''
if (!auth.startsWith('Bearer ')) {
throw new GraphQLError('Missing Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
}
const token = auth.slice(7)
if (!token || token === 'undefined') {
throw new GraphQLError('Empty Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
}
return token
}
function scopesFromPayload(payload: JWTPayload): string[] {
const scope = payload.scope
if (!scope) return []
if (typeof scope === 'string') return scope.split(' ')
if (Array.isArray(scope)) return scope as string[]
return []
}
export async function publicContext(): Promise<AuthContext> {
return { scopes: [] }
}
export async function userContext(req: Request): Promise<AuthContext> {
const token = getBearerToken(req)
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER })
return { userId: payload.sub, scopes: [] }
}
export async function teamContext(req: Request): Promise<AuthContext> {
const token = getBearerToken(req)
const { payload } = await jwtVerify(token, jwks, {
issuer: LOGTO_ISSUER,
audience: LOGTO_EXCHANGE_AUDIENCE,
})
const teamUuid = (payload as Record<string, unknown>).team_uuid as string | undefined
const scopes = scopesFromPayload(payload)
if (!teamUuid || !scopes.includes('teams:member')) {
throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
}
return { userId: payload.sub, teamUuid, scopes }
}
export async function m2mContext(): Promise<AuthContext> {
return { scopes: [], isM2M: true }
}
export function requireScopes(ctx: AuthContext, ...required: string[]): void {
const missing = required.filter(s => !ctx.scopes.includes(s))
if (missing.length > 0) {
throw new GraphQLError(`Missing required scopes: ${missing.join(', ')}`, {
extensions: { code: 'FORBIDDEN' },
})
}
}

View File

@@ -1,3 +1,11 @@
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
export const prisma = new PrismaClient()
const connectionString = process.env.EXCHANGE_DATABASE_URL
if (!connectionString) {
throw new Error('EXCHANGE_DATABASE_URL is required')
}
const adapter = new PrismaPg({ connectionString })
export const prisma = new PrismaClient({ adapter })

View File

@@ -1,16 +1,16 @@
import express from 'express'
import cors from 'cors'
import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import { expressMiddleware } from '@as-integrations/express5'
import * as Sentry from '@sentry/node'
import { publicTypeDefs, publicResolvers } from './schemas/public.js'
import { userTypeDefs, userResolvers } from './schemas/user.js'
import { teamTypeDefs, teamResolvers } from './schemas/team.js'
import { m2mTypeDefs, m2mResolvers } from './schemas/m2m.js'
import { publicContext, userContext, teamContext, m2mContext, type AuthContext } from './auth.js'
import { resolvers, typeDefs } from './schema.js'
const PORT = parseInt(process.env.PORT || '8000', 10)
const PORT = Number.parseInt(process.env.PORT || '8000', 10)
const SENTRY_DSN = process.env.SENTRY_DSN || ''
const corsOrigins = (process.env.CORS_ORIGINS || 'https://optovia.ru,https://app.optovia.ru')
.split(',')
.map(origin => origin.trim())
.filter(origin => origin.length > 0)
if (SENTRY_DSN) {
Sentry.init({
@@ -22,37 +22,15 @@ if (SENTRY_DSN) {
}
const app = express()
app.use(cors({ origin: ['https://optovia.ru'], credentials: true }))
app.use(cors({ origin: corsOrigins, credentials: true }))
const publicServer = new ApolloServer<AuthContext>({ typeDefs: publicTypeDefs, resolvers: publicResolvers, introspection: true })
const userServer = new ApolloServer<AuthContext>({ typeDefs: userTypeDefs, resolvers: userResolvers, introspection: true })
const teamServer = new ApolloServer<AuthContext>({ typeDefs: teamTypeDefs, resolvers: teamResolvers, introspection: true })
const m2mServer = new ApolloServer<AuthContext>({ typeDefs: m2mTypeDefs, resolvers: m2mResolvers, introspection: true })
const server = new ApolloServer({ typeDefs, resolvers, introspection: true })
await server.start()
await Promise.all([publicServer.start(), userServer.start(), teamServer.start(), m2mServer.start()])
app.use('/graphql/public', express.json(), expressMiddleware(publicServer, {
context: async () => publicContext(),
}) as unknown as express.RequestHandler)
app.use('/graphql/user', express.json(), expressMiddleware(userServer, {
context: async ({ req }) => userContext(req as unknown as import('express').Request),
}) as unknown as express.RequestHandler)
app.use('/graphql/team', express.json(), expressMiddleware(teamServer, {
context: async ({ req }) => teamContext(req as unknown as import('express').Request),
}) as unknown as express.RequestHandler)
app.use('/graphql/m2m', express.json(), expressMiddleware(m2mServer, {
context: async () => m2mContext(),
}) as unknown as express.RequestHandler)
app.get('/health', (_, res) => { res.json({ status: 'ok' }) })
app.use('/graphql', express.json(), expressMiddleware(server))
app.get('/health', (_, res) => { res.json({ status: 'ok', service: 'exchange' }) })
app.listen(PORT, '0.0.0.0', () => {
console.log(`Exchange server ready on port ${PORT}`)
console.log(` /graphql/public - public`)
console.log(` /graphql/user - id token auth`)
console.log(` /graphql/team - team access token auth`)
console.log(` /graphql/m2m - internal services (no auth)`)
console.log(' /graphql - suppliers, products, quotes')
})

283
src/schema.ts Normal file
View File

@@ -0,0 +1,283 @@
import { prisma } from './db.js'
type SupplierInput = {
teamUuid?: string | null
name: string
description?: string | null
country?: string | null
countryCode?: string | null
logoUrl?: string | null
isVerified?: boolean | null
isActive?: boolean | null
}
type ProductInput = {
sku?: string | null
name: string
categoryName?: string | null
unit?: string | null
description?: string | null
isActive?: boolean | null
}
type QuoteInput = {
supplierUuid: string
productUuid: string
status?: string | null
quantity: number
unit?: string | null
pricePerUnit: number
currency?: string | null
incotermsCode?: string | null
originPointUuid?: string | null
originName?: string | null
validUntil?: string | null
notes?: string | null
}
export const typeDefs = `#graphql
type Supplier {
uuid: ID!
teamUuid: String
name: String!
description: String
country: String!
countryCode: String!
logoUrl: String
isVerified: Boolean!
isActive: Boolean!
createdAt: String!
updatedAt: String!
}
type Product {
uuid: ID!
sku: String
name: String!
categoryName: String!
unit: String!
description: String
isActive: Boolean!
createdAt: String!
updatedAt: String!
}
type Quote {
uuid: ID!
supplier: Supplier!
product: Product!
status: String!
quantity: Float!
unit: String!
pricePerUnit: Float!
currency: String!
incotermsCode: String
originPointUuid: String
originName: String
validUntil: String
notes: String
createdAt: String!
updatedAt: String!
}
input SupplierInput {
teamUuid: String
name: String!
description: String
country: String
countryCode: String
logoUrl: String
isVerified: Boolean
isActive: Boolean
}
input ProductInput {
sku: String
name: String!
categoryName: String
unit: String
description: String
isActive: Boolean
}
input QuoteInput {
supplierUuid: ID!
productUuid: ID!
status: String
quantity: Float!
unit: String
pricePerUnit: Float!
currency: String
incotermsCode: String
originPointUuid: String
originName: String
validUntil: String
notes: String
}
type Query {
suppliers(countryCode: String, isVerified: Boolean, limit: Int, offset: Int): [Supplier!]!
supplier(uuid: ID!): Supplier
products(categoryName: String, search: String, limit: Int, offset: Int): [Product!]!
product(uuid: ID!): Product
quotes(productUuid: ID, supplierUuid: ID, status: String, limit: Int, offset: Int): [Quote!]!
quote(uuid: ID!): Quote
}
type Mutation {
createSupplier(input: SupplierInput!): Supplier!
updateSupplier(uuid: ID!, input: SupplierInput!): Supplier!
createProduct(input: ProductInput!): Product!
updateProduct(uuid: ID!, input: ProductInput!): Product!
createQuote(input: QuoteInput!): Quote!
updateQuote(uuid: ID!, input: QuoteInput!): Quote!
deleteQuote(uuid: ID!): Boolean!
}
`
function mapDate(value: Date) {
return value.toISOString()
}
function supplierData(input: SupplierInput) {
return {
teamUuid: input.teamUuid ?? null,
name: input.name,
description: input.description ?? null,
country: input.country ?? '',
countryCode: input.countryCode ?? '',
logoUrl: input.logoUrl ?? null,
isVerified: input.isVerified ?? false,
isActive: input.isActive ?? true,
}
}
function productData(input: ProductInput) {
return {
sku: input.sku ?? null,
name: input.name,
categoryName: input.categoryName ?? '',
unit: input.unit ?? 'ton',
description: input.description ?? null,
isActive: input.isActive ?? true,
}
}
async function quoteData(input: QuoteInput) {
const supplier = await prisma.supplier.findUnique({ where: { uuid: input.supplierUuid } })
if (supplier === null) throw new Error(`Supplier ${input.supplierUuid} does not exist`)
const product = await prisma.product.findUnique({ where: { uuid: input.productUuid } })
if (product === null) throw new Error(`Product ${input.productUuid} does not exist`)
return {
supplierId: supplier.id,
productId: product.id,
status: input.status ?? 'active',
quantity: input.quantity,
unit: input.unit ?? product.unit,
pricePerUnit: input.pricePerUnit,
currency: input.currency ?? 'USD',
incotermsCode: input.incotermsCode ?? null,
originPointUuid: input.originPointUuid ?? null,
originName: input.originName ?? null,
validUntil: input.validUntil ? new Date(input.validUntil) : null,
notes: input.notes ?? null,
}
}
export const resolvers = {
Query: {
suppliers: (_: unknown, args: { countryCode?: string; isVerified?: boolean; limit?: number; offset?: number }) =>
prisma.supplier.findMany({
where: {
isActive: true,
...(args.countryCode ? { countryCode: args.countryCode } : {}),
...(args.isVerified !== undefined ? { isVerified: args.isVerified } : {}),
},
take: args.limit ?? 50,
skip: args.offset ?? 0,
orderBy: { createdAt: 'desc' },
}),
supplier: (_: unknown, args: { uuid: string }) =>
prisma.supplier.findUnique({ where: { uuid: args.uuid } }),
products: (_: unknown, args: { categoryName?: string; search?: string; limit?: number; offset?: number }) =>
prisma.product.findMany({
where: {
isActive: true,
...(args.categoryName ? { categoryName: args.categoryName } : {}),
...(args.search ? { name: { contains: args.search, mode: 'insensitive' } } : {}),
},
take: args.limit ?? 50,
skip: args.offset ?? 0,
orderBy: { name: 'asc' },
}),
product: (_: unknown, args: { uuid: string }) =>
prisma.product.findUnique({ where: { uuid: args.uuid } }),
quotes: async (_: unknown, args: { productUuid?: string; supplierUuid?: string; status?: string; limit?: number; offset?: number }) => {
const product = args.productUuid ? await prisma.product.findUnique({ where: { uuid: args.productUuid } }) : null
const supplier = args.supplierUuid ? await prisma.supplier.findUnique({ where: { uuid: args.supplierUuid } }) : null
return prisma.quote.findMany({
where: {
status: args.status ?? 'active',
...(product ? { productId: product.id } : {}),
...(supplier ? { supplierId: supplier.id } : {}),
},
include: { supplier: true, product: true },
take: args.limit ?? 50,
skip: args.offset ?? 0,
orderBy: { createdAt: 'desc' },
})
},
quote: (_: unknown, args: { uuid: string }) =>
prisma.quote.findUnique({ where: { uuid: args.uuid }, include: { supplier: true, product: true } }),
},
Mutation: {
createSupplier: (_: unknown, args: { input: SupplierInput }) =>
prisma.supplier.create({ data: supplierData(args.input) }),
updateSupplier: (_: unknown, args: { uuid: string; input: SupplierInput }) =>
prisma.supplier.update({ where: { uuid: args.uuid }, data: supplierData(args.input) }),
createProduct: (_: unknown, args: { input: ProductInput }) =>
prisma.product.create({ data: productData(args.input) }),
updateProduct: (_: unknown, args: { uuid: string; input: ProductInput }) =>
prisma.product.update({ where: { uuid: args.uuid }, data: productData(args.input) }),
createQuote: async (_: unknown, args: { input: QuoteInput }) =>
prisma.quote.create({ data: await quoteData(args.input), include: { supplier: true, product: true } }),
updateQuote: async (_: unknown, args: { uuid: string; input: QuoteInput }) =>
prisma.quote.update({ where: { uuid: args.uuid }, data: await quoteData(args.input), include: { supplier: true, product: true } }),
deleteQuote: async (_: unknown, args: { uuid: string }) => {
await prisma.quote.delete({ where: { uuid: args.uuid } })
return true
},
},
Supplier: {
createdAt: (parent: { createdAt: Date }) => mapDate(parent.createdAt),
updatedAt: (parent: { updatedAt: Date }) => mapDate(parent.updatedAt),
},
Product: {
createdAt: (parent: { createdAt: Date }) => mapDate(parent.createdAt),
updatedAt: (parent: { updatedAt: Date }) => mapDate(parent.updatedAt),
},
Quote: {
quantity: (parent: { quantity: unknown }) => Number(parent.quantity),
pricePerUnit: (parent: { pricePerUnit: unknown }) => Number(parent.pricePerUnit),
validUntil: (parent: { validUntil: Date | null }) => parent.validUntil?.toISOString() ?? null,
createdAt: (parent: { createdAt: Date }) => mapDate(parent.createdAt),
updatedAt: (parent: { updatedAt: Date }) => mapDate(parent.updatedAt),
},
}

View File

@@ -1,127 +0,0 @@
import { prisma } from '../db.js'
export const m2mTypeDefs = `#graphql
type Offer {
uuid: String!
teamUuid: String!
status: String!
workflowStatus: String!
productUuid: String!
productName: String!
categoryName: String
locationName: String
quantity: Float!
unit: String!
pricePerUnit: Float!
currency: String!
createdAt: String!
}
input CreateOfferFromWorkflowInput {
offerUuid: String!
teamUuid: String!
productUuid: String!
productName: String!
categoryName: String
locationUuid: String
locationName: String
locationCountry: String
locationCountryCode: String
locationLatitude: Float
locationLongitude: Float
quantity: Float!
unit: String
pricePerUnit: Float!
currency: String
terminusSchemaId: String
terminusDocumentId: String
description: String
validUntil: String
}
input UpdateOfferWorkflowStatusInput {
offerUuid: String!
status: String!
errorMessage: String
}
type OfferResult {
success: Boolean!
message: String
offer: Offer
}
type Query {
offer(offerUuid: String!): Offer
}
type Mutation {
createOfferFromWorkflow(input: CreateOfferFromWorkflowInput!): OfferResult
updateOfferWorkflowStatus(input: UpdateOfferWorkflowStatusInput!): OfferResult
}
`
export const m2mResolvers = {
Query: {
offer: async (_: unknown, args: { offerUuid: string }) =>
prisma.offer.findUnique({ where: { uuid: args.offerUuid } }),
},
Mutation: {
createOfferFromWorkflow: async (_: unknown, args: { input: Record<string, unknown> }) => {
const i = args.input
// Idempotent - return existing if already created
const existing = await prisma.offer.findUnique({ where: { uuid: i.offerUuid as string } })
if (existing) {
return { success: true, message: 'Offer already exists', offer: existing }
}
const offer = await prisma.offer.create({
data: {
uuid: i.offerUuid as string,
teamUuid: i.teamUuid as string,
status: 'active',
workflowStatus: 'pending',
productUuid: i.productUuid as string,
productName: i.productName as string,
categoryName: (i.categoryName as string) || '',
locationUuid: i.locationUuid as string | undefined,
locationName: (i.locationName as string) || '',
locationCountry: (i.locationCountry as string) || '',
locationCountryCode: (i.locationCountryCode as string) || '',
locationLatitude: i.locationLatitude as number | undefined,
locationLongitude: i.locationLongitude as number | undefined,
quantity: i.quantity as number,
unit: (i.unit as string) || 'ton',
pricePerUnit: i.pricePerUnit as number,
currency: (i.currency as string) || 'USD',
terminusSchemaId: i.terminusSchemaId as string | undefined,
terminusDocumentId: i.terminusDocumentId as string | undefined,
description: i.description as string | undefined,
validUntil: i.validUntil ? new Date(i.validUntil as string) : undefined,
},
})
return { success: true, message: 'Offer created', offer }
},
updateOfferWorkflowStatus: async (_: unknown, args: { input: { offerUuid: string; status: string; errorMessage?: string } }) => {
const offer = await prisma.offer.update({
where: { uuid: args.input.offerUuid },
data: {
workflowStatus: args.input.status,
workflowError: args.input.errorMessage || null,
},
})
console.log(`Offer ${args.input.offerUuid} workflow status → ${args.input.status}`)
return { success: true, message: 'Status updated', offer }
},
},
Offer: {
quantity: (p: { quantity: unknown }) => Number(p.quantity),
pricePerUnit: (p: { pricePerUnit: unknown }) => Number(p.pricePerUnit),
createdAt: (p: { createdAt: Date }) => p.createdAt.toISOString(),
},
}

View File

@@ -1,163 +0,0 @@
import { prisma } from '../db.js'
import { getProducts } from '../services/odoo.js'
export const publicTypeDefs = `#graphql
type Product {
uuid: String
name: String
categoryId: String
categoryName: String
terminusSchemaId: String
}
type SupplierProfile {
uuid: String!
teamUuid: String!
kycProfileUuid: String
name: String!
description: String
country: String
countryCode: String
logoUrl: String
latitude: Float
longitude: Float
isVerified: Boolean!
isActive: Boolean!
offersCount: Int
}
type Offer {
uuid: String!
teamUuid: String!
status: String!
locationUuid: String
locationName: String
locationCountry: String
locationCountryCode: String
locationLatitude: Float
locationLongitude: Float
productUuid: String!
productName: String!
categoryName: String
quantity: Float!
unit: String!
pricePerUnit: Float!
currency: String!
description: String
validUntil: String
createdAt: String!
updatedAt: String!
}
type Query {
getProducts: [Product]
getAvailableProducts: [Product]
getSupplierProfiles(country: String, isVerified: Boolean, limit: Int, offset: Int): [SupplierProfile]
getSupplierProfilesCount(country: String, isVerified: Boolean): Int
getSupplierProfile(uuid: String!): SupplierProfile
getSupplierProfileByTeam(teamUuid: String!): SupplierProfile
getOffers(status: String, productUuid: String, locationUuid: String, categoryName: String, teamUuid: String, limit: Int, offset: Int): [Offer]
getOffersCount(status: String, productUuid: String, locationUuid: String, categoryName: String, teamUuid: String): Int
getOffer(uuid: String!): Offer
}
`
export const publicResolvers = {
Query: {
getProducts: async () => {
const products = await getProducts()
return products.map(p => ({
uuid: p.uuid,
name: p.name,
categoryId: p.category_id,
categoryName: p.category_name,
terminusSchemaId: p.terminus_schema_id,
}))
},
getAvailableProducts: async () => {
const products = await getProducts()
const activeOfferProductUuids = await prisma.offer.findMany({
where: { status: 'active' },
select: { productUuid: true },
distinct: ['productUuid'],
})
const activeSet = new Set(activeOfferProductUuids.map(o => o.productUuid))
return products
.filter(p => activeSet.has(p.uuid))
.map(p => ({
uuid: p.uuid,
name: p.name,
categoryId: p.category_id,
categoryName: p.category_name,
terminusSchemaId: p.terminus_schema_id,
}))
},
getSupplierProfiles: async (_: unknown, args: { country?: string; isVerified?: boolean; limit?: number; offset?: number }) => {
const where: Record<string, unknown> = { isActive: true }
if (args.country) where.country = args.country
if (args.isVerified !== undefined) where.isVerified = args.isVerified
return prisma.supplierProfile.findMany({
where,
take: args.limit ?? 50,
skip: args.offset ?? 0,
orderBy: { createdAt: 'desc' },
})
},
getSupplierProfilesCount: async (_: unknown, args: { country?: string; isVerified?: boolean }) => {
const where: Record<string, unknown> = { isActive: true }
if (args.country) where.country = args.country
if (args.isVerified !== undefined) where.isVerified = args.isVerified
return prisma.supplierProfile.count({ where })
},
getSupplierProfile: (_: unknown, args: { uuid: string }) =>
prisma.supplierProfile.findUnique({ where: { uuid: args.uuid } }),
getSupplierProfileByTeam: (_: unknown, args: { teamUuid: string }) =>
prisma.supplierProfile.findUnique({ where: { teamUuid: args.teamUuid } }),
getOffers: async (_: unknown, args: { status?: string; productUuid?: string; locationUuid?: string; categoryName?: string; teamUuid?: string; limit?: number; offset?: number }) => {
const where: Record<string, unknown> = {}
where.status = args.status || 'active'
if (args.productUuid) where.productUuid = args.productUuid
if (args.locationUuid) where.locationUuid = args.locationUuid
if (args.categoryName) where.categoryName = args.categoryName
if (args.teamUuid) where.teamUuid = args.teamUuid
return prisma.offer.findMany({
where,
take: args.limit ?? 50,
skip: args.offset ?? 0,
orderBy: { createdAt: 'desc' },
})
},
getOffersCount: async (_: unknown, args: { status?: string; productUuid?: string; locationUuid?: string; categoryName?: string; teamUuid?: string }) => {
const where: Record<string, unknown> = {}
where.status = args.status || 'active'
if (args.productUuid) where.productUuid = args.productUuid
if (args.locationUuid) where.locationUuid = args.locationUuid
if (args.categoryName) where.categoryName = args.categoryName
if (args.teamUuid) where.teamUuid = args.teamUuid
return prisma.offer.count({ where })
},
getOffer: (_: unknown, args: { uuid: string }) =>
prisma.offer.findUnique({ where: { uuid: args.uuid } }),
},
SupplierProfile: {
offersCount: async (parent: { teamUuid: string }) =>
prisma.offer.count({ where: { teamUuid: parent.teamUuid, status: 'active' } }),
},
Offer: {
quantity: (parent: { quantity: unknown }) => Number(parent.quantity),
pricePerUnit: (parent: { pricePerUnit: unknown }) => Number(parent.pricePerUnit),
createdAt: (parent: { createdAt: Date }) => parent.createdAt.toISOString(),
updatedAt: (parent: { updatedAt: Date }) => parent.updatedAt.toISOString(),
validUntil: (parent: { validUntil: Date | null }) => parent.validUntil?.toISOString() ?? null,
},
}

View File

@@ -1,201 +0,0 @@
import { GraphQLError } from 'graphql'
import { randomUUID } from 'crypto'
import { prisma } from '../db.js'
import { requireScopes, type AuthContext } from '../auth.js'
import { startOfferWorkflow } from '../services/temporal.js'
export const teamTypeDefs = `#graphql
type Request {
uuid: String!
productUuid: String!
quantity: Float!
sourceLocationUuid: String!
userId: String!
createdAt: String!
updatedAt: String!
}
type Offer {
uuid: String!
teamUuid: String!
status: String!
workflowStatus: String!
productUuid: String!
productName: String!
categoryName: String
locationName: String
locationCountry: String
quantity: Float!
unit: String!
pricePerUnit: Float!
currency: String!
description: String
validUntil: String
createdAt: String!
updatedAt: String!
}
input RequestInput {
productUuid: String!
quantity: Float!
sourceLocationUuid: String!
}
input OfferInput {
teamUuid: String!
productUuid: String!
productName: String!
categoryName: String
locationUuid: String
locationName: String!
locationCountry: String!
locationCountryCode: String!
locationLatitude: Float
locationLongitude: Float
quantity: Float!
unit: String
pricePerUnit: Float!
currency: String
description: String
validUntil: String
terminusSchemaId: String
terminusPayload: String
}
type CreateOfferResult {
success: Boolean!
message: String
workflowId: String
offerUuid: String
}
type Query {
getRequests(userId: String): [Request]
getRequest(uuid: String!): Request
getTeamOffers(teamUuid: String!): [Offer]
}
type Mutation {
createRequest(input: RequestInput!): Request
createOffer(input: OfferInput!): CreateOfferResult
updateOffer(uuid: String!, input: OfferInput!): Offer
deleteOffer(uuid: String!): Boolean
}
`
export const teamResolvers = {
Query: {
getRequests: async (_: unknown, args: { userId?: string }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
const where: Record<string, unknown> = {}
if (args.userId) where.userId = args.userId
return prisma.request.findMany({ where, orderBy: { createdAt: 'desc' } })
},
getRequest: async (_: unknown, args: { uuid: string }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
return prisma.request.findUnique({ where: { uuid: args.uuid } })
},
getTeamOffers: async (_: unknown, args: { teamUuid: string }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
return prisma.offer.findMany({
where: { teamUuid: args.teamUuid },
orderBy: { createdAt: 'desc' },
})
},
},
Mutation: {
createRequest: async (_: unknown, args: { input: { productUuid: string; quantity: number; sourceLocationUuid: string } }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.userId) throw new GraphQLError('Not authenticated')
return prisma.request.create({
data: {
productUuid: args.input.productUuid,
quantity: args.input.quantity,
sourceLocationUuid: args.input.sourceLocationUuid,
userId: ctx.userId,
},
})
},
createOffer: async (_: unknown, args: { input: Record<string, unknown> }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
const input = args.input
const offerUuid = randomUUID()
try {
const result = await startOfferWorkflow({
offer_uuid: offerUuid,
team_uuid: input.teamUuid as string,
product_uuid: input.productUuid as string,
product_name: input.productName as string,
category_name: (input.categoryName as string) || '',
location_uuid: input.locationUuid as string | undefined,
location_name: (input.locationName as string) || '',
location_country: (input.locationCountry as string) || '',
location_country_code: (input.locationCountryCode as string) || '',
location_latitude: input.locationLatitude as number | undefined,
location_longitude: input.locationLongitude as number | undefined,
quantity: input.quantity as number,
unit: (input.unit as string) || 'ton',
price_per_unit: input.pricePerUnit as number,
currency: (input.currency as string) || 'USD',
description: input.description as string | undefined,
valid_until: input.validUntil as string | undefined,
terminus_schema_id: input.terminusSchemaId as string | undefined,
terminus_payload: input.terminusPayload as string | undefined,
})
return { success: true, message: 'Workflow started', workflowId: result.workflowId, offerUuid }
} catch (e) {
console.error('Failed to start offer workflow:', e)
return { success: false, message: String(e), workflowId: null, offerUuid: null }
}
},
updateOffer: async (_: unknown, args: { uuid: string; input: Record<string, unknown> }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
const input = args.input
return prisma.offer.update({
where: { uuid: args.uuid },
data: {
productUuid: input.productUuid as string,
productName: input.productName as string,
categoryName: (input.categoryName as string) || undefined,
locationName: (input.locationName as string) || undefined,
locationCountry: (input.locationCountry as string) || undefined,
locationCountryCode: (input.locationCountryCode as string) || undefined,
locationLatitude: input.locationLatitude as number | undefined,
locationLongitude: input.locationLongitude as number | undefined,
quantity: input.quantity as number,
unit: (input.unit as string) || undefined,
pricePerUnit: input.pricePerUnit as number,
currency: (input.currency as string) || undefined,
description: input.description as string | undefined,
},
})
},
deleteOffer: async (_: unknown, args: { uuid: string }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
await prisma.offer.delete({ where: { uuid: args.uuid } })
return true
},
},
Request: {
quantity: (p: { quantity: unknown }) => Number(p.quantity),
createdAt: (p: { createdAt: Date }) => p.createdAt.toISOString(),
updatedAt: (p: { updatedAt: Date }) => p.updatedAt.toISOString(),
},
Offer: {
quantity: (p: { quantity: unknown }) => Number(p.quantity),
pricePerUnit: (p: { pricePerUnit: unknown }) => Number(p.pricePerUnit),
createdAt: (p: { createdAt: Date }) => p.createdAt.toISOString(),
updatedAt: (p: { updatedAt: Date }) => p.updatedAt.toISOString(),
validUntil: (p: { validUntil: Date | null }) => p.validUntil?.toISOString() ?? null,
},
}

View File

@@ -1,11 +0,0 @@
export const userTypeDefs = `#graphql
type Query {
health: String!
}
`
export const userResolvers = {
Query: {
health: () => 'ok',
},
}

View File

@@ -1,22 +0,0 @@
const ODOO_INTERNAL_URL = process.env.ODOO_INTERNAL_URL || 'odoo:8069'
interface Product {
uuid: string
name: string
category_id?: string
category_name?: string
terminus_schema_id?: string
}
export async function getProducts(): Promise<Product[]> {
try {
const res = await fetch(`http://${ODOO_INTERNAL_URL}/fastapi/products/products`, {
signal: AbortSignal.timeout(10000),
})
if (!res.ok) return []
return (await res.json()) as Product[]
} catch (e) {
console.error('Error fetching products from Odoo:', e)
return []
}
}

View File

@@ -1,42 +0,0 @@
import { Client, Connection } from '@temporalio/client'
const TEMPORAL_HOST = process.env.TEMPORAL_INTERNAL_URL || 'temporal:7233'
const TEMPORAL_NAMESPACE = process.env.TEMPORAL_NAMESPACE || 'default'
const TEMPORAL_TASK_QUEUE = process.env.TEMPORAL_TASK_QUEUE || 'platform-worker'
interface OfferWorkflowPayload {
offer_uuid: string
team_uuid: string
product_uuid: string
product_name: string
category_name: string
location_uuid?: string
location_name: string
location_country: string
location_country_code: string
location_latitude?: number
location_longitude?: number
quantity: number
unit: string
price_per_unit: number
currency: string
description?: string
valid_until?: string
terminus_schema_id?: string
terminus_payload?: string
}
export async function startOfferWorkflow(payload: OfferWorkflowPayload) {
const connection = await Connection.connect({ address: TEMPORAL_HOST })
const client = new Client({ connection, namespace: TEMPORAL_NAMESPACE })
const workflowId = `offer-${payload.offer_uuid}`
const handle = await client.workflow.start('create_offer', {
args: [payload],
taskQueue: TEMPORAL_TASK_QUEUE,
workflowId,
})
console.log(`Offer workflow started: ${handle.workflowId}`)
return { workflowId: handle.workflowId, runId: handle.firstExecutionRunId }
}