Split exchange into quotes service
All checks were successful
Build Docker Image / build (push) Successful in 2m0s
All checks were successful
Build Docker Image / build (push) Successful in 2m0s
This commit is contained in:
5
README.md
Normal file
5
README.md
Normal 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
3469
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -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
12
prisma.config.ts
Normal 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'),
|
||||
},
|
||||
})
|
||||
98
prisma/migrations/1_split_exchange_quotes/migration.sql
Normal file
98
prisma/migrations/1_split_exchange_quotes/migration.sql
Normal 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;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
73
src/auth.ts
73
src/auth.ts
@@ -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' },
|
||||
})
|
||||
}
|
||||
}
|
||||
10
src/db.ts
10
src/db.ts
@@ -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 })
|
||||
|
||||
48
src/index.ts
48
src/index.ts
@@ -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
283
src/schema.ts
Normal 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),
|
||||
},
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export const userTypeDefs = `#graphql
|
||||
type Query {
|
||||
health: String!
|
||||
}
|
||||
`
|
||||
|
||||
export const userResolvers = {
|
||||
Query: {
|
||||
health: () => 'ok',
|
||||
},
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user