Migrate orders backend from Django to Express + Apollo Server
All checks were successful
Build Docker Image / build (push) Successful in 1m37s

Replace Python/Django/Graphene stack with TypeScript/Express/Apollo Server.
Same 3 GraphQL endpoints (public/user/team), same JWT auth via Logto JWKS,
same Odoo proxy logic. No database needed (pure proxy).
This commit is contained in:
Ruslan Bakiev
2026-03-09 09:08:57 +07:00
parent 03dd05129e
commit c0321660b9
37 changed files with 3917 additions and 2278 deletions

78
src/auth.ts Normal file
View File

@@ -0,0 +1,78 @@
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_ORDERS_AUDIENCE = process.env.LOGTO_ORDERS_AUDIENCE || 'https://orders.optovia.ru'
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL))
export interface AuthContext {
userId?: string
teamUuid?: string
scopes: string[]
}
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: scopesFromPayload(payload),
}
}
export async function teamContext(req: Request): Promise<AuthContext> {
const token = getBearerToken(req)
const { payload } = await jwtVerify(token, jwks, {
issuer: LOGTO_ISSUER,
audience: LOGTO_ORDERS_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 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' },
})
}
}

86
src/index.ts Normal file
View File

@@ -0,0 +1,86 @@
import express from 'express'
import cors from 'cors'
import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import * as Sentry from '@sentry/node'
import { publicTypeDefs, publicResolvers } from './schemas/public.js'
import { userTypeDefs, userResolvers } from './schemas/user.js'
import { teamTypeDefs, teamResolvers } from './schemas/team.js'
import { publicContext, userContext, teamContext, type AuthContext } from './auth.js'
const PORT = parseInt(process.env.PORT || '8000', 10)
const SENTRY_DSN = process.env.SENTRY_DSN || ''
if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
tracesSampleRate: 0.01,
release: process.env.RELEASE_VERSION || '1.0.0',
environment: process.env.ENVIRONMENT || 'production',
})
}
const app = express()
app.use(cors({ origin: ['https://optovia.ru'], credentials: true }))
const 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,
})
await Promise.all([publicServer.start(), userServer.start(), teamServer.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 }) => {
try {
return await userContext(req as unknown as import('express').Request)
} catch {
return { scopes: [] }
}
},
}) 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.get('/health', (_, res) => {
res.json({ status: 'ok' })
})
app.listen(PORT, '0.0.0.0', () => {
console.log(`Orders 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`)
})

11
src/schemas/public.ts Normal file
View File

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

299
src/schemas/team.ts Normal file
View File

@@ -0,0 +1,299 @@
import { GraphQLError } from 'graphql'
import { requireScopes, type AuthContext } from '../auth.js'
const ODOO_INTERNAL_URL = process.env.ODOO_INTERNAL_URL || 'odoo:8069'
export const teamTypeDefs = `#graphql
type Company {
uuid: String
name: String
taxId: String
country: String
countryCode: String
active: Boolean
}
type Trip {
uuid: String
name: String
sequence: Int
company: Company
plannedLoadingDate: String
actualLoadingDate: String
realLoadingDate: String
plannedUnloadingDate: String
actualUnloadingDate: String
plannedWeight: Float
weightAtLoading: Float
weightAtUnloading: Float
}
type Stage {
uuid: String
name: String
sequence: Int
stageType: String
transportType: String
sourceLocationName: String
sourceLatitude: Float
sourceLongitude: Float
destinationLocationName: String
destinationLatitude: Float
destinationLongitude: Float
locationName: String
locationLatitude: Float
locationLongitude: Float
selectedCompany: Company
trips: [Trip]
}
type OrderLine {
uuid: String
productUuid: String
productName: String
quantity: Float
unit: String
priceUnit: Float
subtotal: Float
currency: String
notes: String
}
type Order {
uuid: String
name: String
teamUuid: String
userId: String
status: String
totalAmount: Float
currency: String
sourceLocationUuid: String
sourceLocationName: String
sourceLatitude: Float
sourceLongitude: Float
destinationLocationUuid: String
destinationLocationName: String
createdAt: String
updatedAt: String
notes: String
orderLines: [OrderLine]
stages: [Stage]
}
type Query {
getTeamOrders: [Order]
getOrder(orderUuid: String!): Order
}
`
interface OdooCompany {
uuid?: string
name?: string
tax_id?: string
country?: string
country_code?: string
active?: boolean
}
interface OdooTrip {
uuid?: string
name?: string
sequence?: number
company?: OdooCompany
planned_loading_date?: string
actual_loading_date?: string
real_loading_date?: string
planned_unloading_date?: string
actual_unloading_date?: string
planned_weight?: number
weight_at_loading?: number
weight_at_unloading?: number
}
interface OdooStage {
uuid?: string
name?: string
sequence?: number
stage_type?: string
transport_type?: string
source_location_name?: string
source_latitude?: number
source_longitude?: number
destination_location_name?: string
destination_latitude?: number
destination_longitude?: number
location_name?: string
location_latitude?: number
location_longitude?: number
selected_company?: OdooCompany
trips?: OdooTrip[]
}
interface OdooOrderLine {
uuid?: string
product_uuid?: string
product_name?: string
quantity?: number
unit?: string
price_unit?: number
subtotal?: number
currency?: string
notes?: string
}
interface OdooOrder {
uuid?: string
name?: string
team_uuid?: string
user_id?: string
status?: string
total_amount?: number
currency?: string
source_location_uuid?: string
source_location_name?: string
source_latitude?: number
source_longitude?: number
destination_location_uuid?: string
destination_location_name?: string
created_at?: string
updated_at?: string
notes?: string
order_lines?: OdooOrderLine[]
stages?: OdooStage[]
}
function mapCompany(c?: OdooCompany) {
if (!c) return null
return {
uuid: c.uuid ?? '',
name: c.name ?? '',
taxId: c.tax_id ?? '',
country: c.country ?? '',
countryCode: c.country_code ?? '',
active: c.active ?? true,
}
}
function mapTrip(t: OdooTrip) {
return {
uuid: t.uuid,
name: t.name,
sequence: t.sequence,
company: mapCompany(t.company),
plannedLoadingDate: t.planned_loading_date,
actualLoadingDate: t.actual_loading_date,
realLoadingDate: t.real_loading_date,
plannedUnloadingDate: t.planned_unloading_date,
actualUnloadingDate: t.actual_unloading_date,
plannedWeight: t.planned_weight,
weightAtLoading: t.weight_at_loading,
weightAtUnloading: t.weight_at_unloading,
}
}
function mapStage(s: OdooStage) {
return {
uuid: s.uuid,
name: s.name,
sequence: s.sequence,
stageType: s.stage_type,
transportType: s.transport_type,
sourceLocationName: s.source_location_name,
sourceLatitude: s.source_latitude,
sourceLongitude: s.source_longitude,
destinationLocationName: s.destination_location_name,
destinationLatitude: s.destination_latitude,
destinationLongitude: s.destination_longitude,
locationName: s.location_name,
locationLatitude: s.location_latitude,
locationLongitude: s.location_longitude,
selectedCompany: mapCompany(s.selected_company),
trips: (s.trips ?? []).map(mapTrip),
}
}
function mapOrder(o: OdooOrder) {
const stages = o.stages ?? []
const firstStage = stages[0]
return {
uuid: o.uuid,
name: o.name,
teamUuid: o.team_uuid,
userId: o.user_id,
status: 'active',
totalAmount: o.total_amount,
currency: o.currency,
sourceLocationUuid: o.source_location_uuid,
sourceLocationName: o.source_location_name,
sourceLatitude: o.source_latitude || firstStage?.source_latitude,
sourceLongitude: o.source_longitude || firstStage?.source_longitude,
destinationLocationUuid: o.destination_location_uuid,
destinationLocationName: o.destination_location_name,
createdAt: o.created_at,
updatedAt: o.updated_at,
notes: o.notes ?? '',
orderLines: (o.order_lines ?? []).map(l => ({
uuid: l.uuid,
productUuid: l.product_uuid,
productName: l.product_name,
quantity: l.quantity,
unit: l.unit,
priceUnit: l.price_unit,
subtotal: l.subtotal,
currency: l.currency,
notes: l.notes ?? '',
})),
stages: stages.map(mapStage),
}
}
async function fetchOdoo(path: string): Promise<Response> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10000)
try {
return await fetch(`http://${ODOO_INTERNAL_URL}${path}`, { signal: controller.signal })
} finally {
clearTimeout(timeout)
}
}
export const teamResolvers = {
Query: {
getTeamOrders: async (_: unknown, __: unknown, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.userId) throw new GraphQLError('User not authenticated')
if (!ctx.teamUuid) return []
try {
const res = await fetchOdoo(`/fastapi/orders/orders/team/${ctx.teamUuid}`)
if (!res.ok) return []
const data = (await res.json()) as OdooOrder[]
return data.map(mapOrder)
} catch (e) {
console.error('Error calling Odoo:', e)
return []
}
},
getOrder: async (_: unknown, args: { orderUuid: string }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.userId || !ctx.teamUuid) {
throw new GraphQLError('User not authenticated')
}
try {
const res = await fetchOdoo(`/fastapi/orders/orders/${args.orderUuid}`)
if (!res.ok) return null
const data = (await res.json()) as OdooOrder
if (data.team_uuid !== ctx.teamUuid) {
throw new GraphQLError('Access denied: order belongs to different team')
}
return mapOrder(data)
} catch (e) {
if (e instanceof GraphQLError) throw e
console.error('Error calling Odoo:', e)
return null
}
},
},
}

11
src/schemas/user.ts Normal file
View File

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