Migrate teams backend from Django to Express + Apollo Server + Prisma
All checks were successful
Build Docker Image / build (push) Successful in 2m8s

Replace Django/Graphene stack with TypeScript Express server using Apollo Server v4
with 4 GraphQL endpoints (public/user/team/m2m) and Prisma ORM mapped to existing tables.
This commit is contained in:
Ruslan Bakiev
2026-03-09 09:26:41 +07:00
parent e52f5947a2
commit d9f1a066ce
82 changed files with 5164 additions and 3820 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

View File

@@ -1,24 +1,28 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NIXPACKS_POETRY_VERSION=2.2.1
FROM node:22-alpine AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential curl \
&& rm -rf /var/lib/apt/lists/*
COPY package.json ./
RUN npm install
RUN python -m venv --copies /opt/venv
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY prisma ./prisma
RUN npx prisma generate
COPY . .
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
RUN pip install --no-cache-dir poetry==$NIXPACKS_POETRY_VERSION \
&& poetry install --no-interaction --no-ansi
FROM node:22-alpine
ENV PORT=8000
WORKDIR /app
CMD ["sh", "-c", "poetry run python manage.py migrate && poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn teams.wsgi:application --bind 0.0.0.0:${PORT:-8000}"]
COPY package.json ./
RUN npm install --omit=dev
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/dist ./dist
COPY prisma ./prisma
EXPOSE 8000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]

View File

@@ -1,47 +0,0 @@
# Teams Service
Backend сервис для управления командами и участниками в системе Optovia.
## Описание
Сервис для управления командами с интеграцией Logto для аутентификации. Включает управление участниками, приглашениями и KYC статусами команд.
## Основные функции
- Создание и управление командами
- Управление участниками команд (OWNER, ADMIN, MANAGER, MEMBER)
- Система приглашений в команды
- Интеграция с Logto для аутентификации
- KYC статусы команд
- Управление активной командой пользователя
## Модели данных
- **Team** - модель команды с KYC статусами
- **TeamMember** - участники команды с ролями
- **TeamInvitation** - приглашения в команды
- **User** - пользователи с привязкой к Logto
## KYC статусы команд
- `PENDING_KYC` - Требуется KYC
- `KYC_IN_REVIEW` - KYC на рассмотрении
- `KYC_APPROVED` - KYC одобрен
- `KYC_REJECTED` - KYC отклонен
- `SUSPENDED` - Заблокировано
## Технологии
- Django 5.2.8
- GraphQL (Graphene-Django)
- PostgreSQL
- Logto Integration
- Gunicorn
## Развертывание
Проект развертывается через Nixpacks на Dokploy с автоматическими миграциями.
## Автор
Ruslan Bakiev

View File

View File

@@ -1,17 +0,0 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
if __name__ == '__main__':
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'teams.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)

View File

@@ -1,18 +0,0 @@
providers = ["python"]
[build]
[phases.install]
cmds = [
"python -m venv --copies /opt/venv",
". /opt/venv/bin/activate",
"pip install poetry==$NIXPACKS_POETRY_VERSION",
"poetry install --no-interaction --no-ansi"
]
[start]
cmd = "poetry run python manage.py migrate && poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn teams.wsgi:application --bind 0.0.0.0:${PORT:-8000}"
[variables]
# Set Poetry version to match local environment
NIXPACKS_POETRY_VERSION = "2.2.1"

4183
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "teams",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "prisma generate && tsc",
"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"
},
"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"
}
}

1005
poetry.lock generated

File diff suppressed because it is too large Load Diff

132
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,132 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("TEAMS_DATABASE_URL")
}
model Team {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
name String @db.VarChar(200)
teamType String @default("BUYER") @map("team_type") @db.VarChar(20)
logtoOrgId String? @map("logto_org_id") @db.VarChar(100)
ownerId Int? @map("owner_id")
owner User? @relation(fields: [ownerId], references: [id])
selectedLocationType String? @map("selected_location_type") @db.VarChar(20)
selectedLocationUuid String? @map("selected_location_uuid") @db.VarChar(100)
selectedLocationName String? @map("selected_location_name") @db.VarChar(255)
selectedLocationLat Float? @map("selected_location_lat")
selectedLocationLon Float? @map("selected_location_lon")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
members TeamMember[]
invitations TeamInvitation[]
addresses TeamAddress[]
profiles UserProfile[] @relation("ActiveTeam")
@@map("teams_app_team")
}
model User {
id Int @id @default(autoincrement())
password String @default("") @db.VarChar(128)
username String @unique @db.VarChar(150)
firstName String @default("") @map("first_name") @db.VarChar(150)
lastName String @default("") @map("last_name") @db.VarChar(150)
email String @default("") @db.VarChar(254)
isActive Boolean @default(true) @map("is_active")
dateJoined DateTime @default(now()) @map("date_joined")
isSuperuser Boolean @default(false) @map("is_superuser")
isStaff Boolean @default(false) @map("is_staff")
lastLogin DateTime? @map("last_login")
profile UserProfile?
teams Team[]
memberships TeamMember[]
@@map("auth_user")
}
model UserProfile {
id Int @id @default(autoincrement())
userId Int @unique @map("user_id")
user User @relation(fields: [userId], references: [id])
logtoId String @unique @map("logto_id") @db.VarChar(255)
avatarId String? @map("avatar_id") @db.VarChar(255)
phone String @default("") @db.VarChar(50)
activeTeamId Int? @map("active_team_id")
activeTeam Team? @relation("ActiveTeam", fields: [activeTeamId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("teams_app_userprofile")
}
model TeamMember {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
teamId Int @map("team_id")
team Team @relation(fields: [teamId], references: [id])
userId Int? @map("user_id")
user User? @relation(fields: [userId], references: [id])
role String @default("MEMBER") @db.VarChar(20)
joinedAt DateTime @default(now()) @map("joined_at")
@@unique([teamId, userId])
@@map("teams_app_teammember")
}
model TeamInvitation {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
teamId Int @map("team_id")
team Team @relation(fields: [teamId], references: [id])
email String @db.VarChar(254)
role String @default("MEMBER") @db.VarChar(20)
status String @default("PENDING") @db.VarChar(20)
invitedBy String @default("") @map("invited_by") @db.VarChar(255)
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
tokens TeamInvitationToken[]
@@unique([teamId, email])
@@map("teams_app_teaminvitation")
}
model TeamInvitationToken {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
invitationId Int @map("invitation_id")
invitation TeamInvitation @relation(fields: [invitationId], references: [id])
tokenHash String @unique @map("token_hash") @db.VarChar(255)
workflowStatus String @default("pending") @map("workflow_status") @db.VarChar(20)
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("teams_app_teaminvitationtoken")
}
model TeamAddress {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
teamId Int @map("team_id")
team Team @relation(fields: [teamId], references: [id])
name String @db.VarChar(255)
address String
latitude Float?
longitude Float?
countryCode String @default("") @map("country_code") @db.VarChar(10)
isDefault Boolean @default(false) @map("is_default")
status String @default("pending") @db.VarChar(20)
processedAt DateTime? @map("processed_at")
errorMessage String? @map("error_message")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("teams_app_teamaddress")
}

View File

@@ -1,28 +0,0 @@
[project]
name = "teams"
version = "0.1.0"
description = ""
authors = [
{name = "Ruslan Bakiev",email = "572431+veikab@users.noreply.github.com"}
]
readme = "README.md"
requires-python = "^3.11"
dependencies = [
"django (>=5.2.8,<6.0)",
"graphene-django (>=3.2.3,<4.0.0)",
"django-cors-headers (>=4.9.0,<5.0.0)",
"psycopg2-binary (>=2.9.11,<3.0.0)",
"requests (>=2.32.5,<3.0.0)",
"temporalio (>=1.4.0,<2.0.0)",
"python-dotenv (>=1.2.1,<2.0.0)",
"pyjwt (>=2.10.1,<3.0.0)",
"cryptography (>=46.0.3,<47.0.0)",
"infisicalsdk (>=1.0.12,<2.0.0)",
"gunicorn (>=23.0.0,<24.0.0)",
"whitenoise (>=6.7.0,<7.0.0)",
"sentry-sdk (>=2.47.0,<3.0.0)"
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

55
src/auth.ts Normal file
View File

@@ -0,0 +1,55 @@
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_TEAMS_AUDIENCE = process.env.LOGTO_TEAMS_AUDIENCE || 'https://teams.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(' ')
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_TEAMS_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' } })
}

3
src/db.ts Normal file
View File

@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client'
export const prisma = new PrismaClient()

47
src/index.ts Normal file
View File

@@ -0,0 +1,47 @@
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 { m2mTypeDefs, m2mResolvers } from './schemas/m2m.js'
import { publicContext, userContext, teamContext, m2mContext, 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 })
const m2mServer = new ApolloServer<AuthContext>({ typeDefs: m2mTypeDefs, resolvers: m2mResolvers, introspection: true })
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.listen(PORT, '0.0.0.0', () => {
console.log(`Teams 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)`)
})

149
src/schemas/m2m.ts Normal file
View File

@@ -0,0 +1,149 @@
import { prisma } from '../db.js'
export const m2mTypeDefs = `#graphql
type Team {
uuid: String!
name: String!
logtoOrgId: String
createdAt: String
updatedAt: String
}
type TeamInvitation {
uuid: String!
email: String!
role: String!
status: String!
invitedBy: String
expiresAt: String
createdAt: String
}
type TeamInvitationToken {
uuid: String!
workflowStatus: String!
expiresAt: String
createdAt: String
}
input CreateInvitationFromWorkflowInput {
teamUuid: String!
email: String!
role: String
invitedBy: String
expiresAt: String
}
input CreateInvitationTokenInput {
invitationUuid: String!
tokenHash: String!
expiresAt: String
}
input UpdateInvitationTokenStatusInput {
tokenUuid: String!
status: String!
}
type SetLogtoOrgIdResult { team: Team, success: Boolean! }
type CreateTeamResult { success: Boolean!, teamId: String, teamUuid: String, message: String }
type CreateAddressResult { success: Boolean!, addressUuid: String, teamType: String, message: String }
type UpdateAddressStatusResult { success: Boolean! }
type CreateInvitationResult { success: Boolean!, message: String, invitationUuid: String, invitation: TeamInvitation }
type CreateTokenResult { success: Boolean! }
type UpdateTokenStatusResult { success: Boolean! }
type Query {
team(teamId: String!): Team
invitation(invitationUuid: String!): TeamInvitation
}
type Mutation {
setLogtoOrgId(teamId: String!, logtoOrgId: String!): SetLogtoOrgIdResult
createTeamFromWorkflow(teamName: String!, ownerId: String!, teamType: String, countryCode: String): CreateTeamResult
createAddressFromWorkflow(workflowId: String!, teamUuid: String!, name: String!, address: String!, latitude: Float, longitude: Float, countryCode: String, isDefault: Boolean): CreateAddressResult
updateAddressStatus(addressUuid: String!, status: String!, errorMessage: String): UpdateAddressStatusResult
createInvitationFromWorkflow(input: CreateInvitationFromWorkflowInput!): CreateInvitationResult
createInvitationToken(input: CreateInvitationTokenInput!): CreateTokenResult
updateInvitationTokenStatus(input: UpdateInvitationTokenStatusInput!): UpdateTokenStatusResult
}
`
export const m2mResolvers = {
Query: {
team: async (_: unknown, args: { teamId: string }) => {
const team = await prisma.team.findUnique({ where: { uuid: args.teamId } })
if (!team) return null
return { uuid: team.uuid, name: team.name, logtoOrgId: team.logtoOrgId, createdAt: team.createdAt.toISOString(), updatedAt: team.updatedAt.toISOString() }
},
invitation: async (_: unknown, args: { invitationUuid: string }) => {
const inv = await prisma.teamInvitation.findUnique({ where: { uuid: args.invitationUuid } })
if (!inv) return null
return { uuid: inv.uuid, email: inv.email, role: inv.role, status: inv.status, invitedBy: inv.invitedBy, expiresAt: inv.expiresAt?.toISOString() ?? null, createdAt: inv.createdAt.toISOString() }
},
},
Mutation: {
setLogtoOrgId: async (_: unknown, args: { teamId: string; logtoOrgId: string }) => {
const team = await prisma.team.update({ where: { uuid: args.teamId }, data: { logtoOrgId: args.logtoOrgId } })
return { team: { uuid: team.uuid, name: team.name, logtoOrgId: team.logtoOrgId, createdAt: team.createdAt.toISOString(), updatedAt: team.updatedAt.toISOString() }, success: true }
},
createTeamFromWorkflow: async (_: unknown, args: { teamName: string; ownerId: string; teamType?: string; countryCode?: string }) => {
try {
let profile = await prisma.userProfile.findUnique({ where: { logtoId: args.ownerId }, include: { user: true } })
if (!profile) {
const user = await prisma.user.create({ data: { username: args.ownerId } })
profile = await prisma.userProfile.create({ data: { userId: user.id, logtoId: args.ownerId }, include: { user: true } })
}
const team = await prisma.team.create({ data: { name: args.teamName, teamType: args.teamType || 'BUYER', ownerId: profile.userId } })
await prisma.teamMember.create({ data: { teamId: team.id, userId: profile.userId, role: 'OWNER' } })
await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } })
return { success: true, teamId: team.id.toString(), teamUuid: team.uuid, message: 'Team created' }
} catch (e) {
return { success: false, teamId: null, teamUuid: null, message: String(e) }
}
},
createAddressFromWorkflow: async (_: unknown, args: { workflowId: string; teamUuid: string; name: string; address: string; latitude?: number; longitude?: number; countryCode?: string; isDefault?: boolean }) => {
const team = await prisma.team.findUnique({ where: { uuid: args.teamUuid } })
if (!team) return { success: false, addressUuid: null, teamType: null, message: 'Team not found' }
const addr = await prisma.teamAddress.create({
data: { teamId: team.id, name: args.name, address: args.address, latitude: args.latitude, longitude: args.longitude, countryCode: args.countryCode || '', isDefault: args.isDefault || false },
})
return { success: true, addressUuid: addr.uuid, teamType: team.teamType, message: 'Address created' }
},
updateAddressStatus: async (_: unknown, args: { addressUuid: string; status: string; errorMessage?: string }) => {
await prisma.teamAddress.update({
where: { uuid: args.addressUuid },
data: { status: args.status, errorMessage: args.errorMessage || null, processedAt: new Date() },
})
return { success: true }
},
createInvitationFromWorkflow: async (_: unknown, args: { input: { teamUuid: string; email: string; role?: string; invitedBy?: string; expiresAt?: string } }) => {
const team = await prisma.team.findUnique({ where: { uuid: args.input.teamUuid } })
if (!team) return { success: false, message: 'Team not found', invitationUuid: null, invitation: null }
const inv = await prisma.teamInvitation.create({
data: { teamId: team.id, email: args.input.email, role: args.input.role || 'MEMBER', invitedBy: args.input.invitedBy || '', expiresAt: args.input.expiresAt ? new Date(args.input.expiresAt) : null },
})
return { success: true, message: 'Invitation created', invitationUuid: inv.uuid, invitation: { uuid: inv.uuid, email: inv.email, role: inv.role, status: inv.status, invitedBy: inv.invitedBy, expiresAt: inv.expiresAt?.toISOString() ?? null, createdAt: inv.createdAt.toISOString() } }
},
createInvitationToken: async (_: unknown, args: { input: { invitationUuid: string; tokenHash: string; expiresAt?: string } }) => {
const inv = await prisma.teamInvitation.findUnique({ where: { uuid: args.input.invitationUuid } })
if (!inv) return { success: false }
await prisma.teamInvitationToken.create({
data: { invitationId: inv.id, tokenHash: args.input.tokenHash, expiresAt: args.input.expiresAt ? new Date(args.input.expiresAt) : null },
})
return { success: true }
},
updateInvitationTokenStatus: async (_: unknown, args: { input: { tokenUuid: string; status: string } }) => {
await prisma.teamInvitationToken.update({ where: { uuid: args.input.tokenUuid }, data: { workflowStatus: args.input.status } })
return { success: true }
},
},
}

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

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

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

@@ -0,0 +1,286 @@
import { GraphQLError } from 'graphql'
import { prisma } from '../db.js'
import { requireScopes, type AuthContext } from '../auth.js'
import { startAddressWorkflow, startInviteWorkflow } from '../services/temporal.js'
export const teamTypeDefs = `#graphql
type TeamUser {
id: String
firstName: String
lastName: String
phone: String
avatarId: String
createdAt: String
}
type TeamMember {
user: TeamUser
role: String!
joinedAt: String
}
type TeamAddress {
uuid: String!
name: String!
address: String!
latitude: Float
longitude: Float
isDefault: Boolean!
countryCode: String
createdAt: String
processedAt: String
status: String!
}
type SelectedLocation {
type: String
uuid: String
name: String
latitude: Float
longitude: Float
}
type Team {
id: String!
name: String!
ownerId: String
members: [TeamMember]
addresses: [TeamAddress]
selectedLocation: SelectedLocation
}
input InviteMemberInput {
email: String!
role: String
}
input CreateTeamAddressInput {
name: String!
address: String!
latitude: Float
longitude: Float
countryCode: String
isDefault: Boolean
}
input UpdateTeamAddressInput {
uuid: String!
name: String
address: String
latitude: Float
longitude: Float
countryCode: String
isDefault: Boolean
}
input SetSelectedLocationInput {
type: String!
uuid: String!
name: String!
latitude: Float!
longitude: Float!
}
type InviteMemberResult { success: Boolean!, message: String }
type CreateTeamAddressResult { success: Boolean!, message: String, workflowId: String }
type UpdateTeamAddressResult { success: Boolean!, address: TeamAddress }
type DeleteTeamAddressResult { success: Boolean! }
type SetSelectedLocationResult { success: Boolean! }
type Query {
team: Team
getTeam(teamId: String!): Team
teamMembers: [TeamMember]
teamAddresses: [TeamAddress]
}
type Mutation {
inviteMember(input: InviteMemberInput!): InviteMemberResult
createTeamAddress(input: CreateTeamAddressInput!): CreateTeamAddressResult
updateTeamAddress(input: UpdateTeamAddressInput!): UpdateTeamAddressResult
deleteTeamAddress(uuid: String!): DeleteTeamAddressResult
setSelectedLocation(input: SetSelectedLocationInput!): SetSelectedLocationResult
}
`
async function getTeamByUuid(uuid: string) {
return prisma.team.findUnique({
where: { uuid },
include: {
members: { include: { user: { include: { profile: true } } } },
addresses: { orderBy: { createdAt: 'desc' } },
},
})
}
function mapTeam(team: NonNullable<Awaited<ReturnType<typeof getTeamByUuid>>>) {
return {
id: team.uuid,
name: team.name,
ownerId: team.ownerId?.toString() ?? null,
members: team.members.map(m => ({
user: m.user ? {
id: m.user.profile?.logtoId ?? m.user.id.toString(),
firstName: m.user.firstName,
lastName: m.user.lastName,
phone: m.user.profile?.phone ?? '',
avatarId: m.user.profile?.avatarId ?? null,
createdAt: m.user.dateJoined.toISOString(),
} : null,
role: m.role,
joinedAt: m.joinedAt.toISOString(),
})),
addresses: team.addresses.map(a => ({
uuid: a.uuid, name: a.name, address: a.address,
latitude: a.latitude, longitude: a.longitude,
isDefault: a.isDefault, countryCode: a.countryCode,
createdAt: a.createdAt.toISOString(),
processedAt: a.processedAt?.toISOString() ?? null,
status: a.status,
})),
selectedLocation: team.selectedLocationType ? {
type: team.selectedLocationType,
uuid: team.selectedLocationUuid,
name: team.selectedLocationName,
latitude: team.selectedLocationLat,
longitude: team.selectedLocationLon,
} : null,
}
}
export const teamResolvers = {
Query: {
team: async (_: unknown, __: unknown, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid) throw new GraphQLError('Team UUID not found')
const team = await getTeamByUuid(ctx.teamUuid)
return team ? mapTeam(team) : null
},
getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
const team = await getTeamByUuid(args.teamId)
return team ? mapTeam(team) : null
},
teamMembers: async (_: unknown, __: unknown, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid) return []
const team = await prisma.team.findUnique({ where: { uuid: ctx.teamUuid } })
if (!team) return []
const members = await prisma.teamMember.findMany({
where: { teamId: team.id },
include: { user: { include: { profile: true } } },
})
return members.map(m => ({
user: m.user ? {
id: m.user.profile?.logtoId ?? m.user.id.toString(),
firstName: m.user.firstName, lastName: m.user.lastName,
phone: m.user.profile?.phone ?? '', avatarId: m.user.profile?.avatarId ?? null,
createdAt: m.user.dateJoined.toISOString(),
} : null,
role: m.role, joinedAt: m.joinedAt.toISOString(),
}))
},
teamAddresses: async (_: unknown, __: unknown, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid) return []
const team = await prisma.team.findUnique({ where: { uuid: ctx.teamUuid } })
if (!team) return []
const addrs = await prisma.teamAddress.findMany({ where: { teamId: team.id }, orderBy: { createdAt: 'desc' } })
return addrs.map(a => ({
uuid: a.uuid, name: a.name, address: a.address,
latitude: a.latitude, longitude: a.longitude,
isDefault: a.isDefault, countryCode: a.countryCode,
createdAt: a.createdAt.toISOString(),
processedAt: a.processedAt?.toISOString() ?? null,
status: a.status,
}))
},
},
Mutation: {
inviteMember: async (_: unknown, args: { input: { email: string; role?: string } }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid || !ctx.userId) throw new GraphQLError('Not authenticated')
try {
await startInviteWorkflow(ctx.teamUuid, args.input.email, args.input.role || 'MEMBER', ctx.userId)
return { success: true, message: 'Invitation workflow started' }
} catch (e) {
console.error('Failed to start invite workflow:', e)
return { success: false, message: String(e) }
}
},
createTeamAddress: async (_: unknown, args: { input: { name: string; address: string; latitude?: number; longitude?: number; countryCode?: string; isDefault?: boolean } }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid) throw new GraphQLError('Not authenticated')
const team = await prisma.team.findUnique({ where: { uuid: ctx.teamUuid } })
if (!team) throw new GraphQLError('Team not found')
const addr = await prisma.teamAddress.create({
data: {
teamId: team.id, name: args.input.name, address: args.input.address,
latitude: args.input.latitude, longitude: args.input.longitude,
countryCode: args.input.countryCode || '', isDefault: args.input.isDefault || false,
},
})
try {
const wfId = await startAddressWorkflow(
ctx.teamUuid, addr.uuid, addr.name, addr.address,
addr.latitude ?? undefined, addr.longitude ?? undefined,
addr.countryCode || undefined, addr.isDefault,
)
return { success: true, message: 'Address created', workflowId: wfId }
} catch (e) {
console.error('Failed to start address workflow:', e)
return { success: true, message: 'Address created but workflow failed', workflowId: null }
}
},
updateTeamAddress: async (_: unknown, args: { input: { uuid: string; name?: string; address?: string; latitude?: number; longitude?: number; countryCode?: string; isDefault?: boolean } }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
const data: Record<string, unknown> = {}
if (args.input.name !== undefined) data.name = args.input.name
if (args.input.address !== undefined) data.address = args.input.address
if (args.input.latitude !== undefined) data.latitude = args.input.latitude
if (args.input.longitude !== undefined) data.longitude = args.input.longitude
if (args.input.countryCode !== undefined) data.countryCode = args.input.countryCode
if (args.input.isDefault !== undefined) data.isDefault = args.input.isDefault
const addr = await prisma.teamAddress.update({ where: { uuid: args.input.uuid }, data })
return {
success: true,
address: {
uuid: addr.uuid, name: addr.name, address: addr.address,
latitude: addr.latitude, longitude: addr.longitude,
isDefault: addr.isDefault, countryCode: addr.countryCode,
createdAt: addr.createdAt.toISOString(),
processedAt: addr.processedAt?.toISOString() ?? null,
status: addr.status,
},
}
},
deleteTeamAddress: async (_: unknown, args: { uuid: string }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
await prisma.teamAddress.delete({ where: { uuid: args.uuid } })
return { success: true }
},
setSelectedLocation: async (_: unknown, args: { input: { type: string; uuid: string; name: string; latitude: number; longitude: number } }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid) throw new GraphQLError('Not authenticated')
await prisma.team.update({
where: { uuid: ctx.teamUuid },
data: {
selectedLocationType: args.input.type,
selectedLocationUuid: args.input.uuid,
selectedLocationName: args.input.name,
selectedLocationLat: args.input.latitude,
selectedLocationLon: args.input.longitude,
},
})
return { success: true }
},
},
}

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

@@ -0,0 +1,189 @@
import { GraphQLError } from 'graphql'
import { prisma } from '../db.js'
import { startTeamCreated } from '../services/temporal.js'
import type { AuthContext } from '../auth.js'
export const userTypeDefs = `#graphql
type UserTeam {
id: String!
name: String!
teamType: String
logtoOrgId: String
createdAt: String
}
type User {
id: String
firstName: String
lastName: String
phone: String
avatarId: String
activeTeamId: String
activeTeam: UserTeam
teams: [UserTeam]
}
type TeamMemberInfo {
uuid: String!
role: String!
joinedAt: String
}
type TeamInvitationInfo {
uuid: String!
email: String!
role: String!
status: String!
invitedBy: String
expiresAt: String
createdAt: String
}
type TeamWithMembers {
uuid: String!
name: String!
members: [TeamMemberInfo]
invitations: [TeamInvitationInfo]
}
input CreateTeamInput {
name: String!
teamType: String
}
input UpdateUserInput {
firstName: String
lastName: String
phone: String
avatarId: String
}
type CreateTeamResult {
team: UserTeam
success: Boolean!
}
type UpdateUserResult {
user: User
success: Boolean!
}
type SwitchTeamResult {
success: Boolean!
}
type Query {
me: User
getTeam(teamId: String!): TeamWithMembers
}
type Mutation {
createTeam(input: CreateTeamInput!): CreateTeamResult
updateUser(userId: String!, input: UpdateUserInput!): UpdateUserResult
switchTeam(teamId: String!): SwitchTeamResult
}
`
async function getOrCreateProfile(logtoId: string) {
let profile = await prisma.userProfile.findUnique({ where: { logtoId: logtoId }, include: { user: true, activeTeam: true } })
if (!profile) {
const user = await prisma.user.create({ data: { username: logtoId, firstName: '', lastName: '' } })
profile = await prisma.userProfile.create({
data: { userId: user.id, logtoId: logtoId },
include: { user: true, activeTeam: true },
})
}
return profile
}
export const userResolvers = {
Query: {
me: async (_: unknown, __: unknown, ctx: AuthContext) => {
if (!ctx.userId) throw new GraphQLError('Not authenticated')
const profile = await getOrCreateProfile(ctx.userId)
const memberships = await prisma.teamMember.findMany({ where: { userId: profile.userId }, include: { team: true } })
return {
id: ctx.userId,
firstName: profile.user.firstName,
lastName: profile.user.lastName,
phone: profile.phone,
avatarId: profile.avatarId,
activeTeamId: profile.activeTeam?.uuid ?? null,
activeTeam: profile.activeTeam ? { id: profile.activeTeam.uuid, name: profile.activeTeam.name, teamType: profile.activeTeam.teamType, logtoOrgId: profile.activeTeam.logtoOrgId, createdAt: profile.activeTeam.createdAt.toISOString() } : null,
teams: memberships.map(m => ({ id: m.team.uuid, name: m.team.name, teamType: m.team.teamType, logtoOrgId: m.team.logtoOrgId, createdAt: m.team.createdAt.toISOString() })),
}
},
getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
if (!ctx.userId) throw new GraphQLError('Not authenticated')
const team = await prisma.team.findUnique({
where: { uuid: args.teamId },
include: {
members: { include: { user: true } },
invitations: true,
},
})
if (!team) return null
return {
uuid: team.uuid,
name: team.name,
members: team.members.map(m => ({ uuid: m.uuid, role: m.role, joinedAt: m.joinedAt.toISOString() })),
invitations: team.invitations.map(i => ({
uuid: i.uuid, email: i.email, role: i.role, status: i.status,
invitedBy: i.invitedBy, expiresAt: i.expiresAt?.toISOString() ?? null,
createdAt: i.createdAt.toISOString(),
})),
}
},
},
Mutation: {
createTeam: async (_: unknown, args: { input: { name: string; teamType?: string } }, ctx: AuthContext) => {
if (!ctx.userId) throw new GraphQLError('Not authenticated')
const profile = await getOrCreateProfile(ctx.userId)
const team = await prisma.team.create({
data: { name: args.input.name, teamType: args.input.teamType || 'BUYER', ownerId: profile.userId },
})
await prisma.teamMember.create({ data: { teamId: team.id, userId: profile.userId, role: 'OWNER' } })
await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } })
try {
await startTeamCreated(team.uuid, team.name, ctx.userId, team.teamType)
} catch (e) {
console.error('Failed to start team_created workflow:', e)
}
return {
team: { id: team.uuid, name: team.name, teamType: team.teamType, logtoOrgId: team.logtoOrgId, createdAt: team.createdAt.toISOString() },
success: true,
}
},
updateUser: async (_: unknown, args: { userId: string; input: { firstName?: string; lastName?: string; phone?: string; avatarId?: string } }, ctx: AuthContext) => {
if (!ctx.userId) throw new GraphQLError('Not authenticated')
const profile = await getOrCreateProfile(ctx.userId)
const userUpdate: Record<string, unknown> = {}
if (args.input.firstName !== undefined) userUpdate.firstName = args.input.firstName
if (args.input.lastName !== undefined) userUpdate.lastName = args.input.lastName
if (Object.keys(userUpdate).length > 0) {
await prisma.user.update({ where: { id: profile.userId }, data: userUpdate })
}
const profileUpdate: Record<string, unknown> = {}
if (args.input.phone !== undefined) profileUpdate.phone = args.input.phone
if (args.input.avatarId !== undefined) profileUpdate.avatarId = args.input.avatarId
if (Object.keys(profileUpdate).length > 0) {
await prisma.userProfile.update({ where: { id: profile.id }, data: profileUpdate })
}
return { user: { id: ctx.userId }, success: true }
},
switchTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
if (!ctx.userId) throw new GraphQLError('Not authenticated')
const profile = await getOrCreateProfile(ctx.userId)
const team = await prisma.team.findUnique({ where: { uuid: args.teamId } })
if (!team) throw new GraphQLError('Team not found')
await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } })
return { success: true }
},
},
}

46
src/services/temporal.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Client, Connection } from '@temporalio/client'
import { randomUUID } from 'crypto'
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'
async function getClient() {
const connection = await Connection.connect({ address: TEMPORAL_HOST })
return new Client({ connection, namespace: TEMPORAL_NAMESPACE })
}
export async function startTeamCreated(teamUuid: string, teamName: string, ownerId: string, teamType: string) {
const client = await getClient()
const handle = await client.workflow.start('team_created_workflow', {
args: [{ team_uuid: teamUuid, team_name: teamName, owner_id: ownerId, team_type: teamType }],
taskQueue: TEMPORAL_TASK_QUEUE,
workflowId: teamUuid,
})
console.log(`Team created workflow started: ${handle.workflowId}`)
return handle.workflowId
}
export async function startAddressWorkflow(teamUuid: string, addressUuid: string, name: string, address: string, latitude?: number, longitude?: number, countryCode?: string, isDefault?: boolean) {
const client = await getClient()
const workflowId = `address-${randomUUID()}`
const handle = await client.workflow.start('create_address', {
args: [{ team_uuid: teamUuid, address_uuid: addressUuid, name, address, latitude, longitude, country_code: countryCode, is_default: isDefault }],
taskQueue: TEMPORAL_TASK_QUEUE,
workflowId,
})
console.log(`Address workflow started: ${handle.workflowId}`)
return handle.workflowId
}
export async function startInviteWorkflow(teamUuid: string, email: string, role: string, invitedBy: string) {
const client = await getClient()
const workflowId = `invite-${randomUUID()}`
const handle = await client.workflow.start('invite_user', {
args: [{ team_uuid: teamUuid, email, role, invited_by: invitedBy }],
taskQueue: TEMPORAL_TASK_QUEUE,
workflowId,
})
console.log(`Invite workflow started: ${handle.workflowId}`)
return handle.workflowId
}

View File

@@ -1 +0,0 @@
# Django orders service

View File

@@ -1,161 +0,0 @@
import os
from pathlib import Path
from urllib.parse import urlparse
from dotenv import load_dotenv
from infisical_sdk import InfisicalSDKClient
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
load_dotenv()
INFISICAL_API_URL = os.environ["INFISICAL_API_URL"]
INFISICAL_CLIENT_ID = os.environ["INFISICAL_CLIENT_ID"]
INFISICAL_CLIENT_SECRET = os.environ["INFISICAL_CLIENT_SECRET"]
INFISICAL_PROJECT_ID = os.environ["INFISICAL_PROJECT_ID"]
INFISICAL_ENV = os.environ.get("INFISICAL_ENV", "prod")
client = InfisicalSDKClient(host=INFISICAL_API_URL)
client.auth.universal_auth.login(
client_id=INFISICAL_CLIENT_ID,
client_secret=INFISICAL_CLIENT_SECRET,
)
# Fetch secrets from /teams and /shared
for secret_path in ["/teams", "/shared"]:
secrets_response = client.secrets.list_secrets(
environment_slug=INFISICAL_ENV,
secret_path=secret_path,
project_id=INFISICAL_PROJECT_ID,
expand_secret_references=True,
view_secret_value=True,
)
for secret in secrets_response.secrets:
os.environ[secret.secretKey] = secret.secretValue
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'dev-secret-key-change-in-production')
DEBUG = os.getenv('DEBUG', 'False') == 'True'
# Sentry/GlitchTip configuration
SENTRY_DSN = os.getenv('SENTRY_DSN', '')
if SENTRY_DSN:
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=[DjangoIntegration()],
auto_session_tracking=False,
traces_sample_rate=0.01,
release=os.getenv('RELEASE_VERSION', '1.0.0'),
environment=os.getenv('ENVIRONMENT', 'production'),
send_default_pii=False,
debug=DEBUG,
)
ALLOWED_HOSTS = ['*']
CSRF_TRUSTED_ORIGINS = ['https://teams.optovia.ru']
INSTALLED_APPS = [
'whitenoise.runserver_nostatic',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'corsheaders',
'graphene_django',
'teams_app',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'teams.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'teams.wsgi.application'
db_url = os.environ["TEAMS_DATABASE_URL"]
parsed = urlparse(db_url)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': parsed.path.lstrip('/'),
'USER': parsed.username,
'PASSWORD': parsed.password,
'HOST': parsed.hostname,
'PORT': str(parsed.port) if parsed.port else '',
}
}
# Internationalization
LANGUAGE_CODE = 'ru-ru'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# CORS
CORS_ALLOW_ALL_ORIGINS = False
CORS_ALLOWED_ORIGINS = ['https://optovia.ru']
CORS_ALLOW_CREDENTIALS = True
# Logging
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django.request': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': False,
},
},
}
# Logto JWT settings
LOGTO_JWKS_URL = os.getenv('LOGTO_JWKS_URL', 'https://auth.optovia.ru/oidc/jwks')
LOGTO_ISSUER = os.getenv('LOGTO_ISSUER', 'https://auth.optovia.ru/oidc')
LOGTO_TEAMS_AUDIENCE = os.getenv('LOGTO_TEAMS_AUDIENCE', 'https://teams.optovia.ru')
# ID Token audience can be omitted when we only need signature + issuer validation.
LOGTO_ID_TOKEN_AUDIENCE = os.getenv('LOGTO_ID_TOKEN_AUDIENCE')
# Odoo connection (internal M2M)
ODOO_INTERNAL_URL = os.getenv('ODOO_INTERNAL_URL', 'odoo:8069')

View File

@@ -1,71 +0,0 @@
# Local settings for makemigrations (no Infisical, SQLite)
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = 'local-dev-key'
DEBUG = True
ALLOWED_HOSTS = ['*']
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'corsheaders',
'graphene_django',
'teams_app',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'teams.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
LANGUAGE_CODE = 'ru-ru'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# CORS
CORS_ALLOW_ALL_ORIGINS = False
CORS_ALLOWED_ORIGINS = ['http://localhost:3000', 'https://optovia.ru']
CORS_ALLOW_CREDENTIALS = True

View File

@@ -1,17 +0,0 @@
from django.contrib import admin
from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from teams_app.views import test_jwt, PublicGraphQLView, UserGraphQLView, TeamGraphQLView, M2MGraphQLView
from teams_app.schemas.public_schema import public_schema
from teams_app.schemas.user_schema import user_schema
from teams_app.schemas.team_schema import team_schema
from teams_app.schemas.m2m_schema import m2m_schema
urlpatterns = [
path('admin/', admin.site.urls),
path('graphql/public/', csrf_exempt(PublicGraphQLView.as_view(graphiql=True, schema=public_schema))),
path('graphql/user/', csrf_exempt(UserGraphQLView.as_view(graphiql=True, schema=user_schema))),
path('graphql/team/', csrf_exempt(TeamGraphQLView.as_view(graphiql=True, schema=team_schema))),
path('graphql/m2m/', csrf_exempt(M2MGraphQLView.as_view(graphiql=True, schema=m2m_schema))),
path('test-jwt/', test_jwt, name='test_jwt'),
]

View File

@@ -1,6 +0,0 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'teams.settings')
application = get_wsgi_application()

View File

@@ -1 +0,0 @@
# Orders Django app

View File

@@ -1,45 +0,0 @@
from django.contrib import admin
from .models import Team, TeamMember, TeamInvitation, TeamInvitationToken, UserProfile, TeamAddress
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ('uuid', 'name', 'owner', 'logto_org_id', 'created_at')
list_filter = ('created_at',)
search_fields = ('name', 'uuid', 'owner__username', 'owner__profile__logto_id', 'logto_org_id')
readonly_fields = ('uuid', 'created_at', 'updated_at')
@admin.register(TeamMember)
class TeamMemberAdmin(admin.ModelAdmin):
list_display = ('uuid', 'team', 'user', 'role', 'joined_at')
list_filter = ('role', 'joined_at')
search_fields = ('user__username', 'user__profile__logto_id', 'uuid', 'team__name')
readonly_fields = ('uuid', 'joined_at')
@admin.register(TeamInvitation)
class TeamInvitationAdmin(admin.ModelAdmin):
list_display = ('uuid', 'team', 'email', 'role', 'status', 'invited_by', 'expires_at')
list_filter = ('role', 'status', 'expires_at')
search_fields = ('email', 'uuid', 'team__name', 'invited_by')
readonly_fields = ('uuid', 'created_at')
@admin.register(TeamInvitationToken)
class TeamInvitationTokenAdmin(admin.ModelAdmin):
list_display = ('uuid', 'invitation', 'workflow_status', 'expires_at', 'created_at')
list_filter = ('workflow_status', 'expires_at')
search_fields = ('uuid', 'invitation__email', 'invitation__team__name')
readonly_fields = ('uuid', 'created_at')
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ('logto_id', 'user', 'active_team', 'created_at')
search_fields = ('logto_id', 'user__username', 'active_team__name')
@admin.register(TeamAddress)
class TeamAddressAdmin(admin.ModelAdmin):
list_display = ('uuid', 'team', 'name', 'address', 'status', 'country_code', 'created_at')
list_filter = ('status', 'country_code', 'created_at')
search_fields = ('name', 'address', 'uuid', 'team__name')
readonly_fields = ('uuid', 'created_at', 'updated_at', 'processed_at')

View File

@@ -1,5 +0,0 @@
from django.apps import AppConfig
class TeamsAppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'teams_app'

View File

@@ -1,70 +0,0 @@
import logging
from typing import Iterable, Optional
import jwt
from django.conf import settings
from jwt import InvalidTokenError, PyJWKClient
logger = logging.getLogger(__name__)
class LogtoTokenValidator:
"""Validate JWTs issued by Logto using the published JWKS."""
def __init__(self, jwks_url: str, issuer: str):
self._issuer = issuer
self._jwks_client = PyJWKClient(jwks_url)
def decode(
self,
token: str,
audience: Optional[str] = None,
) -> dict:
"""Decode and verify a JWT, enforcing issuer and optional audience."""
try:
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
header_alg = jwt.get_unverified_header(token).get("alg")
return jwt.decode(
token,
signing_key.key,
algorithms=[header_alg] if header_alg else None,
issuer=self._issuer,
audience=audience,
options={"verify_aud": audience is not None},
)
except InvalidTokenError as exc:
logger.warning("Failed to validate Logto token: %s", exc)
raise
def get_bearer_token(request) -> str:
"""Extract Bearer token from Authorization header."""
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
if not auth_header.startswith("Bearer "):
raise InvalidTokenError("Missing Bearer token")
token = auth_header.split(" ", 1)[1]
if not token or token == "undefined":
raise InvalidTokenError("Empty Bearer token")
return token
def scopes_from_payload(payload: dict) -> list[str]:
"""Split scope string (if present) into a list."""
scope_value = payload.get("scope")
if not scope_value:
return []
if isinstance(scope_value, str):
return scope_value.split()
if isinstance(scope_value, Iterable):
return list(scope_value)
return []
validator = LogtoTokenValidator(
getattr(settings, "LOGTO_JWKS_URL", "https://auth.optovia.ru/oidc/jwks"),
getattr(settings, "LOGTO_ISSUER", "https://auth.optovia.ru/oidc"),
)

View File

@@ -1,81 +0,0 @@
"""
GraphQL middleware for JWT authentication.
Each class is bound to a specific GraphQL endpoint (public/user/team/m2m).
"""
import logging
from django.conf import settings
from graphql import GraphQLError
from jwt import InvalidTokenError
from .auth import get_bearer_token, scopes_from_payload, validator
logger = logging.getLogger(__name__)
def _is_introspection(info) -> bool:
"""Возвращает True для любых introspection резолвов."""
field = getattr(info, "field_name", "")
parent = getattr(getattr(info, "parent_type", None), "name", "")
return field.startswith("__") or parent.startswith("__")
class PublicNoAuthMiddleware:
"""Public endpoint - no authentication required."""
def resolve(self, next, root, info, **kwargs):
return next(root, info, **kwargs)
class UserJWTMiddleware:
"""User endpoint - requires ID token."""
def resolve(self, next, root, info, **kwargs):
request = info.context
if _is_introspection(info):
return next(root, info, **kwargs)
# Only process auth once (check if already processed)
if not hasattr(request, 'user_id'):
try:
token = get_bearer_token(request)
payload = validator.decode(token)
request.user_id = payload.get('sub')
logger.info(f"[UserJWTMiddleware] user_id set to: {request.user_id}")
except InvalidTokenError as exc:
logger.warning(f"[UserJWTMiddleware] Token error: {exc}")
raise GraphQLError("Unauthorized") from exc
return next(root, info, **kwargs)
class TeamJWTMiddleware:
"""Team endpoint - requires Access token for teams audience."""
def resolve(self, next, root, info, **kwargs):
request = info.context
if _is_introspection(info):
return next(root, info, **kwargs)
try:
token = get_bearer_token(request)
payload = validator.decode(
token,
audience=getattr(settings, 'LOGTO_TEAMS_AUDIENCE', None),
)
request.user_id = payload.get('sub')
request.team_uuid = payload.get('team_uuid')
request.scopes = scopes_from_payload(payload)
if not request.team_uuid or 'teams:member' not in request.scopes:
raise GraphQLError("Unauthorized")
except InvalidTokenError as exc:
raise GraphQLError("Unauthorized") from exc
return next(root, info, **kwargs)
class M2MNoAuthMiddleware:
"""M2M endpoint - internal services only, no auth for now."""
def resolve(self, next, root, info, **kwargs):
return next(root, info, **kwargs)

View File

@@ -1,56 +0,0 @@
import json
import logging
from django.conf import settings
from django.utils.deprecation import MiddlewareMixin
from jwt import InvalidTokenError
from .auth import get_bearer_token, scopes_from_payload, validator
logger = logging.getLogger(__name__)
class LogtoJWTMiddleware(MiddlewareMixin):
"""
JWT middleware для проверки токенов от Logto
"""
def __init__(self, get_response=None):
super().__init__(get_response)
# Audience validated only for non-introspection API calls
self.audience = getattr(settings, "LOGTO_TEAMS_AUDIENCE", None)
def _is_introspection_query(self, request):
"""Проверяет, является ли запрос introspection (для GraphQL codegen)"""
if request.method != 'POST':
return False
try:
body = json.loads(request.body.decode('utf-8'))
query = body.get('query', '')
return '__schema' in query or '__type' in query
except Exception:
return False
def process_request(self, request):
"""Обрабатывает JWT токен из заголовка Authorization"""
# Пропускаем проверку для admin панели и статики
if request.path.startswith('/admin/') or request.path.startswith('/static/'):
return None
# Пропускаем introspection запросы (для GraphQL codegen)
if self._is_introspection_query(request):
return None
try:
token = get_bearer_token(request)
payload = validator.decode(token, audience=self.audience)
request.user_id = payload.get('sub')
request.team_uuid = payload.get('team_uuid')
request.scopes = scopes_from_payload(payload)
except InvalidTokenError:
return None
return None

View File

@@ -1,68 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-04 08:11
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Team',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
('name', models.CharField(max_length=255)),
('logto_org_id', models.CharField(blank=True, max_length=255, null=True)),
('status', models.CharField(choices=[('PENDING_KYC', 'Требуется KYC'), ('KYC_IN_REVIEW', 'KYC на рассмотрении'), ('KYC_APPROVED', 'KYC одобрен'), ('KYC_REJECTED', 'KYC отклонен'), ('SUSPENDED', 'Заблокировано')], default='PENDING_KYC', max_length=50)),
('prefect_flow_run_id', models.CharField(blank=True, max_length=255, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_teams', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'teams_team',
},
),
migrations.CreateModel(
name='TeamInvitation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
('email', models.EmailField(max_length=254)),
('role', models.CharField(choices=[('OWNER', 'Владелец'), ('ADMIN', 'Администратор'), ('MANAGER', 'Менеджер'), ('MEMBER', 'Участник')], default='MEMBER', max_length=50)),
('status', models.CharField(choices=[('PENDING', 'Ожидает ответа'), ('ACCEPTED', 'Принято'), ('DECLINED', 'Отклонено'), ('EXPIRED', 'Истекло')], default='PENDING', max_length=50)),
('invited_by', models.CharField(max_length=255)),
('expires_at', models.DateTimeField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='teams_app.team')),
],
options={
'db_table': 'teams_invitation',
'unique_together': {('team', 'email')},
},
),
migrations.CreateModel(
name='TeamMember',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
('role', models.CharField(choices=[('OWNER', 'Владелец'), ('ADMIN', 'Администратор'), ('MANAGER', 'Менеджер'), ('MEMBER', 'Участник')], default='MEMBER', max_length=50)),
('joined_at', models.DateTimeField(auto_now_add=True)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='teams_app.team')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='team_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'teams_member',
'unique_together': {('team', 'user')},
},
),
]

View File

@@ -1,30 +0,0 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('teams_app', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('logto_id', models.CharField(max_length=255, unique=True)),
('avatar_id', models.CharField(blank=True, max_length=100, null=True)),
('phone', models.CharField(blank=True, max_length=20, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('active_team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='active_profiles', to='teams_app.team')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'teams_user_profile',
},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-08 09:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams_app', '0002_add_user_profile'),
]
operations = [
migrations.AddField(
model_name='team',
name='team_type',
field=models.CharField(choices=[('BUYER', 'Покупатель'), ('SELLER', 'Продавец')], default='BUYER', max_length=20),
),
]

View File

@@ -1,33 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-09 03:18
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams_app', '0003_add_team_type'),
]
operations = [
migrations.CreateModel(
name='TeamAddress',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
('name', models.CharField(max_length=255)),
('address', models.TextField()),
('latitude', models.FloatField(blank=True, null=True)),
('longitude', models.FloatField(blank=True, null=True)),
('is_default', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to='teams_app.team')),
],
options={
'db_table': 'teams_address',
},
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-16 01:42
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('teams_app', '0004_teamaddress'),
]
operations = [
migrations.RemoveField(
model_name='team',
name='prefect_flow_run_id',
),
]

View File

@@ -1,59 +0,0 @@
# Generated manually
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams_app', '0005_remove_team_prefect_flow_run_id'),
]
operations = [
# TeamAddress fields
migrations.AddField(
model_name='teamaddress',
name='status',
field=models.CharField(
choices=[
('pending', 'Ожидает обработки'),
('processing', 'Обрабатывается'),
('ready', 'Готов'),
('error', 'Ошибка'),
],
default='pending',
max_length=20,
),
),
migrations.AddField(
model_name='teamaddress',
name='graph_node_id',
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AddField(
model_name='teamaddress',
name='processed_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='teamaddress',
name='error_message',
field=models.TextField(blank=True, null=True),
),
# Team selected location fields
migrations.AddField(
model_name='team',
name='selected_location_type',
field=models.CharField(
blank=True,
choices=[('address', 'Адрес'), ('hub', 'Хаб')],
max_length=20,
null=True,
),
),
migrations.AddField(
model_name='team',
name='selected_location_uuid',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-16 12:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams_app', '0006_add_address_status_and_selected_location'),
]
operations = [
migrations.AddField(
model_name='teamaddress',
name='country_code',
field=models.CharField(blank=True, max_length=2, null=True),
),
]

View File

@@ -1,31 +0,0 @@
# Generated manually
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams_app', '0007_teamaddress_country_code'),
]
operations = [
migrations.RemoveField(
model_name='teamaddress',
name='graph_node_id',
),
migrations.AlterField(
model_name='teamaddress',
name='status',
field=models.CharField(
choices=[
('pending', 'Ожидает обработки'),
('processing', 'Обрабатывается'),
('ready', 'Готов'),
('error', 'Ошибка')
],
default='processing',
max_length=20
),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-30 02:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams_app', '0008_remove_graph_node_id_and_change_default_status'),
]
operations = [
migrations.AlterField(
model_name='teamaddress',
name='status',
field=models.CharField(choices=[('pending', 'Ожидает обработки'), ('active', 'Активен'), ('error', 'Ошибка')], default='pending', max_length=20),
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-30 03:00
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('teams_app', '0009_alter_teamaddress_status'),
]
operations = [
migrations.RemoveField(
model_name='team',
name='status',
),
]

View File

@@ -1,30 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-30 07:43
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams_app', '0010_remove_team_status'),
]
operations = [
migrations.CreateModel(
name='TeamInvitationToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
('token_hash', models.CharField(max_length=255, unique=True)),
('workflow_status', models.CharField(choices=[('pending', 'Ожидает обработки'), ('active', 'Активен'), ('error', 'Ошибка')], default='pending', max_length=20)),
('expires_at', models.DateTimeField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('invitation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to='teams_app.teaminvitation')),
],
options={
'db_table': 'teams_invitation_token',
},
),
]

View File

@@ -1,28 +0,0 @@
# Generated by Django 5.2.8 on 2026-01-03 05:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams_app', '0011_teaminvitationtoken'),
]
operations = [
migrations.AddField(
model_name='team',
name='selected_location_latitude',
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name='team',
name='selected_location_longitude',
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name='team',
name='selected_location_name',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@@ -1,165 +0,0 @@
from django.conf import settings
from django.db import models
import uuid
class Team(models.Model):
TEAM_TYPE_CHOICES = [
('BUYER', 'Покупатель'),
('SELLER', 'Продавец'),
]
LOCATION_TYPE_CHOICES = [
('address', 'Адрес'),
('hub', 'Хаб'),
]
uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4)
name = models.CharField(max_length=255)
team_type = models.CharField(max_length=20, choices=TEAM_TYPE_CHOICES, default='BUYER')
logto_org_id = models.CharField(max_length=255, null=True, blank=True)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='owned_teams',
null=True,
blank=True,
)
selected_location_type = models.CharField(max_length=20, choices=LOCATION_TYPE_CHOICES, null=True, blank=True)
selected_location_uuid = models.CharField(max_length=100, null=True, blank=True)
selected_location_name = models.CharField(max_length=255, null=True, blank=True)
selected_location_latitude = models.FloatField(null=True, blank=True)
selected_location_longitude = models.FloatField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'teams_team'
def __str__(self):
return f"Team {self.name}"
class UserProfile(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='profile'
)
logto_id = models.CharField(max_length=255, unique=True)
avatar_id = models.CharField(max_length=100, blank=True, null=True)
phone = models.CharField(max_length=20, blank=True, null=True)
active_team = models.ForeignKey(Team, on_delete=models.SET_NULL, null=True, blank=True, related_name='active_profiles')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'teams_user_profile'
def __str__(self):
return f"Profile {self.logto_id}"
class TeamMember(models.Model):
ROLE_CHOICES = [
('OWNER', 'Владелец'),
('ADMIN', 'Администратор'),
('MANAGER', 'Менеджер'),
('MEMBER', 'Участник'),
]
uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4)
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='members')
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='team_memberships',
null=True,
blank=True,
)
role = models.CharField(max_length=50, choices=ROLE_CHOICES, default='MEMBER')
joined_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'teams_member'
unique_together = ['team', 'user']
def __str__(self):
return f"{self.team.name} - {self.user} ({self.role})"
class TeamInvitation(models.Model):
INVITATION_STATUS_CHOICES = [
('PENDING', 'Ожидает ответа'),
('ACCEPTED', 'Принято'),
('DECLINED', 'Отклонено'),
('EXPIRED', 'Истекло'),
]
ROLE_CHOICES = TeamMember.ROLE_CHOICES
uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4)
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='invitations')
email = models.EmailField()
role = models.CharField(max_length=50, choices=ROLE_CHOICES, default='MEMBER')
status = models.CharField(max_length=50, choices=INVITATION_STATUS_CHOICES, default='PENDING')
invited_by = models.CharField(max_length=255)
expires_at = models.DateTimeField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'teams_invitation'
unique_together = ['team', 'email']
def __str__(self):
return f"Приглашение в {self.team.name} для {self.email}"
class TeamInvitationToken(models.Model):
WORKFLOW_STATUS_CHOICES = [
('pending', 'Ожидает обработки'),
('active', 'Активен'),
('error', 'Ошибка'),
]
uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4)
invitation = models.ForeignKey(TeamInvitation, on_delete=models.CASCADE, related_name='tokens')
token_hash = models.CharField(max_length=255, unique=True)
workflow_status = models.CharField(
max_length=20,
choices=WORKFLOW_STATUS_CHOICES,
default='pending',
)
expires_at = models.DateTimeField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'teams_invitation_token'
def __str__(self):
return f"Token {self.uuid} for invitation {self.invitation_id}"
class TeamAddress(models.Model):
ADDRESS_STATUS_CHOICES = [
('pending', 'Ожидает обработки'),
('active', 'Активен'),
('error', 'Ошибка'),
]
uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4)
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='addresses')
name = models.CharField(max_length=255) # "Офис", "Склад", "Производство"
address = models.TextField()
latitude = models.FloatField(null=True, blank=True)
longitude = models.FloatField(null=True, blank=True)
country_code = models.CharField(max_length=2, null=True, blank=True) # ISO 3166-1 alpha-2
is_default = models.BooleanField(default=False)
status = models.CharField(max_length=20, choices=ADDRESS_STATUS_CHOICES, default='pending')
processed_at = models.DateTimeField(null=True, blank=True)
error_message = models.TextField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'teams_address'
def __str__(self):
return f"{self.team.name} - {self.name}"

View File

@@ -1,74 +0,0 @@
"""
Декоратор для проверки scopes в JWT токене.
Используется для защиты GraphQL резолверов.
"""
from functools import wraps
from graphql import GraphQLError
def require_scopes(*scopes: str):
"""
Декоратор для проверки наличия scopes в JWT токене.
Использование:
@require_scopes("read:teams")
def resolve_team(self, info):
...
@require_scopes("read:teams", "write:teams")
def resolve_update_team(self, info):
...
"""
def decorator(func):
# Сохраняем scopes в метаданных для возможности сбора всех scopes
if not hasattr(func, '_required_scopes'):
func._required_scopes = []
func._required_scopes.extend(scopes)
@wraps(func)
def wrapper(self, info, *args, **kwargs):
# Получаем scopes из контекста (должны быть добавлены в middleware)
user_scopes = set(getattr(info.context, 'scopes', []) or [])
missing = set(scopes) - user_scopes
if missing:
raise GraphQLError(f"Missing required scopes: {', '.join(missing)}")
return func(self, info, *args, **kwargs)
# Переносим метаданные на wrapper
wrapper._required_scopes = func._required_scopes
return wrapper
return decorator
def collect_scopes_from_schema(schema) -> set:
"""
Собирает все scopes из схемы для синхронизации с Logto.
Использование:
from .schema import schema
scopes = collect_scopes_from_schema(schema)
# {'read:team', 'invite:member', ...}
"""
scopes = set()
# Query resolvers
if hasattr(schema, 'query') and schema.query:
query_type = schema.query
for field_name in dir(query_type):
if field_name.startswith('resolve_'):
resolver = getattr(query_type, field_name, None)
if resolver and hasattr(resolver, '_required_scopes'):
scopes.update(resolver._required_scopes)
# Mutation resolvers
if hasattr(schema, 'mutation') and schema.mutation:
mutation_type = schema.mutation
for field_name, field in mutation_type._meta.fields.items():
if hasattr(field, 'type') and hasattr(field.type, 'mutate'):
mutate = field.type.mutate
if hasattr(mutate, '_required_scopes'):
scopes.update(mutate._required_scopes)
return scopes

View File

@@ -1,329 +0,0 @@
"""
M2M (Machine-to-Machine) GraphQL schema.
Used by internal services (Temporal workflows, etc.) without user authentication.
"""
import graphene
import logging
from django.utils import timezone
from graphene_django import DjangoObjectType
from ..models import Team as TeamModel, TeamMember as TeamMemberModel, TeamAddress as TeamAddressModel, TeamInvitation as TeamInvitationModel, TeamInvitationToken as TeamInvitationTokenModel
from .user_schema import _get_or_create_user_with_profile
logger = logging.getLogger(__name__)
class Team(DjangoObjectType):
id = graphene.String()
class Meta:
model = TeamModel
fields = ('uuid', 'name', 'logto_org_id', 'created_at', 'updated_at')
def resolve_id(self, info):
return self.uuid
class M2MQuery(graphene.ObjectType):
team = graphene.Field(Team, teamId=graphene.String(required=True))
invitation = graphene.Field(lambda: TeamInvitation, invitationUuid=graphene.String(required=True))
def resolve_team(self, info, teamId):
try:
return TeamModel.objects.get(uuid=teamId)
except TeamModel.DoesNotExist:
return None
def resolve_invitation(self, info, invitationUuid):
try:
return TeamInvitationModel.objects.get(uuid=invitationUuid)
except TeamInvitationModel.DoesNotExist:
return None
class TeamInvitation(DjangoObjectType):
class Meta:
model = TeamInvitationModel
fields = ('uuid', 'team', 'email', 'role', 'status', 'invited_by', 'expires_at', 'created_at')
class TeamInvitationToken(DjangoObjectType):
class Meta:
model = TeamInvitationTokenModel
fields = ('uuid', 'invitation', 'workflow_status', 'expires_at', 'created_at')
class CreateInvitationFromWorkflowInput(graphene.InputObjectType):
teamUuid = graphene.String(required=True)
email = graphene.String(required=True)
role = graphene.String()
invitedBy = graphene.String(required=True)
expiresAt = graphene.DateTime(required=True)
class CreateInvitationFromWorkflow(graphene.Mutation):
class Arguments:
input = CreateInvitationFromWorkflowInput(required=True)
success = graphene.Boolean()
message = graphene.String()
invitationUuid = graphene.String()
invitation = graphene.Field(TeamInvitation)
def mutate(self, info, input):
try:
team = TeamModel.objects.get(uuid=input.teamUuid)
invitation = TeamInvitationModel.objects.create(
team=team,
email=input.email,
role=input.role or 'MEMBER',
status='PENDING',
invited_by=input.invitedBy,
expires_at=input.expiresAt,
)
return CreateInvitationFromWorkflow(
success=True,
message="Invitation created",
invitationUuid=invitation.uuid,
invitation=invitation,
)
except Exception as exc:
logger.exception("Failed to create invitation")
return CreateInvitationFromWorkflow(success=False, message=str(exc))
class CreateInvitationTokenInput(graphene.InputObjectType):
invitationUuid = graphene.String(required=True)
tokenHash = graphene.String(required=True)
expiresAt = graphene.DateTime(required=True)
class CreateInvitationToken(graphene.Mutation):
class Arguments:
input = CreateInvitationTokenInput(required=True)
success = graphene.Boolean()
message = graphene.String()
token = graphene.Field(TeamInvitationToken)
def mutate(self, info, input):
try:
invitation = TeamInvitationModel.objects.get(uuid=input.invitationUuid)
token = TeamInvitationTokenModel.objects.create(
invitation=invitation,
token_hash=input.tokenHash,
expires_at=input.expiresAt,
workflow_status='pending',
)
return CreateInvitationToken(success=True, message="Token created", token=token)
except Exception as exc:
logger.exception("Failed to create invitation token")
return CreateInvitationToken(success=False, message=str(exc))
class UpdateInvitationTokenStatusInput(graphene.InputObjectType):
tokenUuid = graphene.String(required=True)
status = graphene.String(required=True) # pending | active | error
class UpdateInvitationTokenStatus(graphene.Mutation):
class Arguments:
input = UpdateInvitationTokenStatusInput(required=True)
success = graphene.Boolean()
message = graphene.String()
token = graphene.Field(TeamInvitationToken)
def mutate(self, info, input):
try:
token = TeamInvitationTokenModel.objects.get(uuid=input.tokenUuid)
token.workflow_status = input.status
token.save(update_fields=['workflow_status'])
return UpdateInvitationTokenStatus(success=True, message="Status updated", token=token)
except TeamInvitationTokenModel.DoesNotExist:
return UpdateInvitationTokenStatus(success=False, message="Token not found")
class SetLogtoOrgIdMutation(graphene.Mutation):
"""Set Logto organization ID (used by Temporal workflows)"""
class Arguments:
teamId = graphene.String(required=True)
logtoOrgId = graphene.String(required=True)
team = graphene.Field(Team)
success = graphene.Boolean()
def mutate(self, info, teamId, logtoOrgId):
try:
team = TeamModel.objects.get(uuid=teamId)
team.logto_org_id = logtoOrgId
team.save(update_fields=['logto_org_id'])
logger.info("Team %s: logto_org_id set to %s", teamId, logtoOrgId)
return SetLogtoOrgIdMutation(team=team, success=True)
except TeamModel.DoesNotExist:
raise Exception(f"Team {teamId} not found")
class CreateAddressFromWorkflowMutation(graphene.Mutation):
"""Create TeamAddress from Temporal workflow (workflow-first pattern)"""
class Arguments:
workflowId = graphene.String(required=True)
teamUuid = graphene.String(required=True)
name = graphene.String(required=True)
address = graphene.String(required=True)
latitude = graphene.Float()
longitude = graphene.Float()
countryCode = graphene.String()
isDefault = graphene.Boolean()
success = graphene.Boolean()
addressUuid = graphene.String()
teamType = graphene.String() # "buyer" or "seller"
message = graphene.String()
def mutate(self, info, workflowId, teamUuid, name, address,
latitude=None, longitude=None, countryCode=None, isDefault=False):
try:
team = TeamModel.objects.get(uuid=teamUuid)
# Если новый адрес default - сбрасываем старые
if isDefault:
team.addresses.update(is_default=False)
address_obj = TeamAddressModel.objects.create(
team=team,
name=name,
address=address,
latitude=latitude,
longitude=longitude,
country_code=countryCode,
is_default=isDefault,
status='pending',
)
logger.info(
"Created address %s for team %s (workflow=%s, team_type=%s)",
address_obj.uuid, teamUuid, workflowId, team.team_type
)
return CreateAddressFromWorkflowMutation(
success=True,
addressUuid=str(address_obj.uuid),
teamType=team.team_type.lower() if team.team_type else "buyer",
message="Address created"
)
except TeamModel.DoesNotExist:
return CreateAddressFromWorkflowMutation(
success=False,
message=f"Team {teamUuid} not found"
)
except Exception as e:
logger.error("Failed to create address: %s", e)
return CreateAddressFromWorkflowMutation(
success=False,
message=str(e)
)
class CreateTeamFromWorkflowMutation(graphene.Mutation):
"""Create Team from Temporal workflow (KYC approval flow)"""
class Arguments:
teamName = graphene.String(required=True)
ownerId = graphene.String(required=True) # Logto user ID
teamType = graphene.String() # BUYER | SELLER, default BUYER
countryCode = graphene.String()
success = graphene.Boolean()
teamId = graphene.String()
teamUuid = graphene.String()
message = graphene.String()
def mutate(self, info, teamName, ownerId, teamType=None, countryCode=None):
try:
# Получаем или создаём пользователя
owner = _get_or_create_user_with_profile(ownerId)
# Создаём команду
team = TeamModel.objects.create(
name=teamName,
owner=owner,
team_type=teamType or 'BUYER'
)
# Добавляем owner как участника команды с ролью OWNER
TeamMemberModel.objects.create(
team=team,
user=owner,
role='OWNER'
)
# Устанавливаем как активную команду
if hasattr(owner, 'profile') and not owner.profile.active_team:
owner.profile.active_team = team
owner.profile.save(update_fields=['active_team'])
logger.info(
"Created team %s (%s) for owner %s from workflow",
team.uuid, teamName, ownerId
)
return CreateTeamFromWorkflowMutation(
success=True,
teamId=str(team.id),
teamUuid=str(team.uuid),
message="Team created"
)
except Exception as e:
logger.error("Failed to create team from workflow: %s", e)
return CreateTeamFromWorkflowMutation(
success=False,
message=str(e)
)
class UpdateAddressStatusMutation(graphene.Mutation):
"""Update address processing status (used by Temporal workflows)"""
class Arguments:
addressUuid = graphene.String(required=True)
status = graphene.String(required=True) # pending, active, error
errorMessage = graphene.String()
success = graphene.Boolean()
message = graphene.String()
def mutate(self, info, addressUuid, status, errorMessage=None):
try:
address = TeamAddressModel.objects.get(uuid=addressUuid)
address.status = status
update_fields = ['status', 'updated_at']
if errorMessage:
address.error_message = errorMessage
update_fields.append('error_message')
if status == 'active':
address.processed_at = timezone.now()
update_fields.append('processed_at')
address.save(update_fields=update_fields)
logger.info("Address %s status updated to %s", addressUuid, status)
return UpdateAddressStatusMutation(success=True, message="Status updated")
except TeamAddressModel.DoesNotExist:
return UpdateAddressStatusMutation(
success=False,
message=f"Address {addressUuid} not found"
)
class M2MMutation(graphene.ObjectType):
setLogtoOrgId = SetLogtoOrgIdMutation.Field()
createTeamFromWorkflow = CreateTeamFromWorkflowMutation.Field()
createAddressFromWorkflow = CreateAddressFromWorkflowMutation.Field()
updateAddressStatus = UpdateAddressStatusMutation.Field()
createInvitationFromWorkflow = CreateInvitationFromWorkflow.Field()
createInvitationToken = CreateInvitationToken.Field()
updateInvitationTokenStatus = UpdateInvitationTokenStatus.Field()
m2m_schema = graphene.Schema(query=M2MQuery, mutation=M2MMutation)

View File

@@ -1,12 +0,0 @@
import graphene
class PublicQuery(graphene.ObjectType):
"""Public schema - no authentication required"""
_placeholder = graphene.String(description="Placeholder field")
def resolve__placeholder(self, info):
return None
public_schema = graphene.Schema(query=PublicQuery)

View File

@@ -1,418 +0,0 @@
import graphene
from django.utils import timezone
from datetime import timedelta
from graphene_django import DjangoObjectType
from django.contrib.auth import get_user_model
from ..models import Team as TeamModel, TeamMember as TeamMemberModel, TeamAddress as TeamAddressModel
from ..permissions import require_scopes
UserModel = get_user_model()
class User(DjangoObjectType):
id = graphene.String()
firstName = graphene.String()
lastName = graphene.String()
phone = graphene.String()
avatarId = graphene.String()
createdAt = graphene.String()
class Meta:
model = UserModel
fields = ('username', 'first_name', 'last_name', 'email')
def resolve_id(self, info):
if hasattr(self, 'profile') and self.profile:
return self.profile.logto_id
return self.username
def resolve_firstName(self, info):
return self.first_name
def resolve_lastName(self, info):
return self.last_name
def resolve_phone(self, info):
return getattr(self.profile, 'phone', None)
def resolve_avatarId(self, info):
return getattr(self.profile, 'avatar_id', None)
def resolve_createdAt(self, info):
return self.date_joined.isoformat() if self.date_joined else None
class TeamMember(DjangoObjectType):
user = graphene.Field(User)
joinedAt = graphene.String()
class Meta:
model = TeamMemberModel
fields = ('role', 'joined_at')
def resolve_user(self, info):
return self.user
def resolve_joinedAt(self, info):
return self.joined_at.isoformat() if self.joined_at else None
class TeamAddress(DjangoObjectType):
isDefault = graphene.Boolean()
createdAt = graphene.String()
graphNodeId = graphene.String()
processedAt = graphene.String()
countryCode = graphene.String()
class Meta:
model = TeamAddressModel
fields = ('uuid', 'name', 'address', 'latitude', 'longitude', 'is_default', 'created_at', 'country_code')
def resolve_isDefault(self, info):
return self.is_default
def resolve_createdAt(self, info):
return self.created_at.isoformat() if self.created_at else None
def resolve_graphNodeId(self, info):
return self.graph_node_id
def resolve_processedAt(self, info):
return self.processed_at.isoformat() if self.processed_at else None
def resolve_countryCode(self, info):
return self.country_code
class SelectedLocation(graphene.ObjectType):
type = graphene.String()
uuid = graphene.String()
name = graphene.String()
latitude = graphene.Float()
longitude = graphene.Float()
class Team(DjangoObjectType):
id = graphene.String()
ownerId = graphene.String()
members = graphene.List(TeamMember)
addresses = graphene.List(lambda: TeamAddress)
selectedLocation = graphene.Field(SelectedLocation)
class Meta:
model = TeamModel
fields = ('uuid', 'name', 'logto_org_id', 'owner', 'created_at', 'updated_at')
def resolve_id(self, info):
return self.uuid
def resolve_ownerId(self, info):
if self.owner and hasattr(self.owner, 'profile'):
return self.owner.profile.logto_id
return self.owner.username if self.owner else None
def resolve_members(self, info):
return self.members.all()
def resolve_addresses(self, info):
return self.addresses.all()
def resolve_selectedLocation(self, info):
loc_type = getattr(self, 'selected_location_type', None)
loc_uuid = getattr(self, 'selected_location_uuid', None)
if loc_type and loc_uuid:
return SelectedLocation(
type=loc_type,
uuid=loc_uuid,
name=getattr(self, 'selected_location_name', None),
latitude=getattr(self, 'selected_location_latitude', None),
longitude=getattr(self, 'selected_location_longitude', None)
)
return None
class TeamQuery(graphene.ObjectType):
team = graphene.Field(Team)
getTeam = graphene.Field(Team, teamId=graphene.String(required=True))
team_members = graphene.List(TeamMember)
team_addresses = graphene.List(TeamAddress)
@require_scopes("teams:member")
def resolve_team(self, info):
team_uuid = getattr(info.context, 'team_uuid', None)
if not team_uuid:
return None
try:
return TeamModel.objects.get(uuid=team_uuid)
except TeamModel.DoesNotExist:
return None
@require_scopes("teams:member")
def resolve_getTeam(self, info, teamId):
# Получаем конкретную команду по ID
try:
return TeamModel.objects.get(uuid=teamId)
except TeamModel.DoesNotExist:
return None
@require_scopes("teams:member")
def resolve_team_members(self, info):
# Получаем участников команды
team_uuid = getattr(info.context, 'team_uuid', None)
if not team_uuid:
return []
try:
team = TeamModel.objects.get(uuid=team_uuid)
return team.members.all()
except TeamModel.DoesNotExist:
return []
@require_scopes("teams:member")
def resolve_team_addresses(self, info):
team_uuid = getattr(info.context, 'team_uuid', None)
if not team_uuid:
return []
try:
team = TeamModel.objects.get(uuid=team_uuid)
return team.addresses.all()
except TeamModel.DoesNotExist:
return []
class InviteMemberInput(graphene.InputObjectType):
email = graphene.String(required=True)
role = graphene.String()
class InviteMemberMutation(graphene.Mutation):
class Arguments:
input = InviteMemberInput(required=True)
success = graphene.Boolean()
message = graphene.String()
@require_scopes("teams:member")
def mutate(self, info, input):
from ..temporal_client import start_invite_workflow
# Проверяем права - только owner может приглашать
team_uuid = getattr(info.context, 'team_uuid', None)
user_id = getattr(info.context, 'user_id', None)
if not team_uuid or not user_id:
return InviteMemberMutation(success=False, message="Недостаточно прав")
try:
team = TeamModel.objects.get(uuid=team_uuid)
# Проверяем что пользователь - owner команды
if not team.owner:
return InviteMemberMutation(success=False, message="Только owner может приглашать")
owner_identifier = team.owner.profile.logto_id if hasattr(team.owner, 'profile') and team.owner.profile else team.owner.username
if owner_identifier != user_id:
return InviteMemberMutation(success=False, message="Только owner может приглашать")
expires_at = timezone.now() + timedelta(days=7)
start_invite_workflow(
team_uuid=str(team.uuid),
email=input.email,
role=input.role or 'MEMBER',
invited_by=owner_identifier,
expires_at=expires_at.isoformat(),
)
return InviteMemberMutation(success=True, message="Приглашение отправлено")
except TeamModel.DoesNotExist:
return InviteMemberMutation(success=False, message="Команда не найдена")
class CreateTeamAddressInput(graphene.InputObjectType):
name = graphene.String(required=True)
address = graphene.String(required=True)
latitude = graphene.Float()
longitude = graphene.Float()
countryCode = graphene.String()
isDefault = graphene.Boolean()
class CreateTeamAddressMutation(graphene.Mutation):
class Arguments:
input = CreateTeamAddressInput(required=True)
success = graphene.Boolean()
message = graphene.String()
workflowId = graphene.String()
@require_scopes("teams:member")
def mutate(self, info, input):
from ..temporal_client import start_address_workflow
team_uuid = getattr(info.context, 'team_uuid', None)
if not team_uuid:
return CreateTeamAddressMutation(success=False, message="Не авторизован")
try:
team = TeamModel.objects.get(uuid=team_uuid)
# Запускаем workflow - он сам создаст адрес через M2M мутацию
workflow_id, _ = start_address_workflow(
team_uuid=str(team.uuid),
name=input.name,
address=input.address,
latitude=input.get('latitude'),
longitude=input.get('longitude'),
country_code=input.get('countryCode'),
is_default=input.get('isDefault', False),
)
return CreateTeamAddressMutation(
success=True,
message="Адрес создается",
workflowId=workflow_id,
)
except TeamModel.DoesNotExist:
return CreateTeamAddressMutation(success=False, message="Команда не найдена")
except Exception as e:
return CreateTeamAddressMutation(success=False, message=str(e))
class DeleteTeamAddressMutation(graphene.Mutation):
class Arguments:
uuid = graphene.String(required=True)
success = graphene.Boolean()
message = graphene.String()
@require_scopes("teams:member")
def mutate(self, info, uuid):
team_uuid = getattr(info.context, 'team_uuid', None)
if not team_uuid:
return DeleteTeamAddressMutation(success=False, message="Не авторизован")
try:
team = TeamModel.objects.get(uuid=team_uuid)
address = team.addresses.get(uuid=uuid)
address.delete()
return DeleteTeamAddressMutation(success=True, message="Адрес удален")
except TeamModel.DoesNotExist:
return DeleteTeamAddressMutation(success=False, message="Команда не найдена")
except TeamAddressModel.DoesNotExist:
return DeleteTeamAddressMutation(success=False, message="Адрес не найден")
class UpdateTeamAddressInput(graphene.InputObjectType):
uuid = graphene.String(required=True)
name = graphene.String()
address = graphene.String()
latitude = graphene.Float()
longitude = graphene.Float()
countryCode = graphene.String()
isDefault = graphene.Boolean()
class UpdateTeamAddressMutation(graphene.Mutation):
class Arguments:
input = UpdateTeamAddressInput(required=True)
success = graphene.Boolean()
message = graphene.String()
address = graphene.Field(TeamAddress)
@require_scopes("teams:member")
def mutate(self, info, input):
team_uuid = getattr(info.context, 'team_uuid', None)
if not team_uuid:
return UpdateTeamAddressMutation(success=False, message="Не авторизован")
try:
team = TeamModel.objects.get(uuid=team_uuid)
address = team.addresses.get(uuid=input.uuid)
if input.name is not None:
address.name = input.name
if input.address is not None:
address.address = input.address
if input.latitude is not None:
address.latitude = input.latitude
if input.longitude is not None:
address.longitude = input.longitude
if input.countryCode is not None:
address.country_code = input.countryCode
if input.isDefault is not None:
address.is_default = input.isDefault
address.save()
return UpdateTeamAddressMutation(success=True, message="Адрес обновлен", address=address)
except TeamModel.DoesNotExist:
return UpdateTeamAddressMutation(success=False, message="Команда не найдена")
except TeamAddressModel.DoesNotExist:
return UpdateTeamAddressMutation(success=False, message="Адрес не найден")
class SetSelectedLocationInput(graphene.InputObjectType):
type = graphene.String(required=True) # 'address' или 'hub'
uuid = graphene.String(required=True)
name = graphene.String(required=True)
latitude = graphene.Float(required=True)
longitude = graphene.Float(required=True)
class SetSelectedLocationMutation(graphene.Mutation):
class Arguments:
input = SetSelectedLocationInput(required=True)
success = graphene.Boolean()
message = graphene.String()
selectedLocation = graphene.Field(SelectedLocation)
@require_scopes("teams:member")
def mutate(self, info, input):
team_uuid = getattr(info.context, 'team_uuid', None)
if not team_uuid:
return SetSelectedLocationMutation(success=False, message="Не авторизован")
location_type = input.type
if location_type not in ('address', 'hub'):
return SetSelectedLocationMutation(success=False, message="Неверный тип локации")
try:
team = TeamModel.objects.get(uuid=team_uuid)
team.selected_location_type = location_type
team.selected_location_uuid = input.uuid
team.selected_location_name = input.name
team.selected_location_latitude = input.latitude
team.selected_location_longitude = input.longitude
team.save(update_fields=[
'selected_location_type',
'selected_location_uuid',
'selected_location_name',
'selected_location_latitude',
'selected_location_longitude'
])
return SetSelectedLocationMutation(
success=True,
message="Локация выбрана",
selectedLocation=SelectedLocation(
type=location_type,
uuid=input.uuid,
name=input.name,
latitude=input.latitude,
longitude=input.longitude
)
)
except TeamModel.DoesNotExist:
return SetSelectedLocationMutation(success=False, message="Команда не найдена")
class TeamMutation(graphene.ObjectType):
invite_member = InviteMemberMutation.Field()
create_team_address = CreateTeamAddressMutation.Field()
update_team_address = UpdateTeamAddressMutation.Field()
delete_team_address = DeleteTeamAddressMutation.Field()
set_selected_location = SetSelectedLocationMutation.Field()
team_schema = graphene.Schema(query=TeamQuery, mutation=TeamMutation)

View File

@@ -1,287 +0,0 @@
import graphene
from graphene_django import DjangoObjectType
from django.contrib.auth import get_user_model
from ..models import Team as TeamModel, TeamMember as TeamMemberModel, TeamInvitation as TeamInvitationModel, UserProfile
from .team_schema import SelectedLocation
UserModel = get_user_model()
def _get_or_create_user_with_profile(logto_id: str):
user, _ = UserModel.objects.get_or_create(
username=logto_id,
defaults={'email': ''}
)
profile, _ = UserProfile.objects.get_or_create(
logto_id=logto_id,
defaults={'user': user}
)
if profile.user_id != user.id:
profile.user = user
profile.save(update_fields=['user'])
# Attach profile to user for resolvers
user.profile = profile
return user
class Team(DjangoObjectType):
id = graphene.String()
logtoOrgId = graphene.String()
teamType = graphene.String()
selectedLocation = graphene.Field(SelectedLocation)
class Meta:
model = TeamModel
fields = ('uuid', 'name', 'logto_org_id', 'team_type', 'created_at')
def resolve_id(self, info):
return self.uuid
def resolve_logtoOrgId(self, info):
return self.logto_org_id
def resolve_teamType(self, info):
return self.team_type
def resolve_selectedLocation(self, info):
loc_type = getattr(self, 'selected_location_type', None)
loc_uuid = getattr(self, 'selected_location_uuid', None)
if loc_type and loc_uuid:
return SelectedLocation(
type=loc_type,
uuid=loc_uuid,
name=getattr(self, 'selected_location_name', None),
latitude=getattr(self, 'selected_location_latitude', None),
longitude=getattr(self, 'selected_location_longitude', None)
)
return None
class User(DjangoObjectType):
id = graphene.String()
firstName = graphene.String()
lastName = graphene.String()
phone = graphene.String()
avatarId = graphene.String()
activeTeamId = graphene.String()
activeTeam = graphene.Field(Team)
teams = graphene.List(Team)
class Meta:
model = UserModel
fields = ('username', 'first_name', 'last_name', 'email')
def resolve_id(self, info):
if hasattr(self, 'profile') and self.profile:
return self.profile.logto_id
return self.username
def resolve_firstName(self, info):
return self.first_name
def resolve_lastName(self, info):
return self.last_name
def resolve_phone(self, info):
return getattr(self.profile, 'phone', None)
def resolve_avatarId(self, info):
return getattr(self.profile, 'avatar_id', None)
def resolve_activeTeamId(self, info):
return self.profile.active_team.uuid if getattr(self, 'profile', None) and self.profile.active_team else None
def resolve_activeTeam(self, info):
return self.profile.active_team if getattr(self, 'profile', None) else None
def resolve_teams(self, info):
# Возвращаем Team объекты через TeamMember отношения
from ..models import TeamMember as TeamMemberModel
team_members = TeamMemberModel.objects.filter(user=self)
return [member.team for member in team_members]
class TeamMember(DjangoObjectType):
user = graphene.Field(User)
role = graphene.String()
joinedAt = graphene.String()
class Meta:
from ..models import TeamMember as TeamMemberModel
model = TeamMemberModel
fields = ('uuid', 'role')
def resolve_joinedAt(self, info):
return self.joined_at.isoformat() if self.joined_at else None
class TeamInvitation(DjangoObjectType):
email = graphene.String()
role = graphene.String()
status = graphene.String()
invitedBy = graphene.String()
expiresAt = graphene.String()
createdAt = graphene.String()
class Meta:
model = TeamInvitationModel
fields = ('uuid', 'email', 'role', 'status')
def resolve_invitedBy(self, info):
return self.invited_by
def resolve_expiresAt(self, info):
return self.expires_at.isoformat() if self.expires_at else None
def resolve_createdAt(self, info):
return self.created_at.isoformat() if self.created_at else None
class TeamWithMembers(DjangoObjectType):
id = graphene.String()
members = graphene.List(TeamMember)
invitations = graphene.List(TeamInvitation)
class Meta:
model = TeamModel
fields = ('uuid', 'name', 'created_at')
def resolve_id(self, info):
return self.uuid
def resolve_members(self, info):
return self.members.all()
def resolve_invitations(self, info):
return self.invitations.filter(status='PENDING')
class UserQuery(graphene.ObjectType):
me = graphene.Field(User)
get_team = graphene.Field(TeamWithMembers, team_id=graphene.String(required=True))
def resolve_me(self, info):
# Получаем user_id из ID Token
user_id = getattr(info.context, 'user_id', None)
if not user_id:
return None
try:
return _get_or_create_user_with_profile(user_id)
except Exception:
return None
def resolve_get_team(self, info, team_id):
try:
return TeamModel.objects.get(uuid=team_id)
except TeamModel.DoesNotExist:
return None
class CreateTeamInput(graphene.InputObjectType):
name = graphene.String(required=True)
teamType = graphene.String() # BUYER или SELLER
class UpdateUserInput(graphene.InputObjectType):
firstName = graphene.String()
lastName = graphene.String()
phone = graphene.String()
avatarId = graphene.String()
class CreateTeamMutation(graphene.Mutation):
class Arguments:
input = CreateTeamInput(required=True)
team = graphene.Field(Team)
def mutate(self, info, input):
# Получаем user_id из контекста (ID Token)
user_id = getattr(info.context, 'user_id', None)
if not user_id:
raise Exception("User not authenticated")
try:
owner = _get_or_create_user_with_profile(user_id)
team = TeamModel.objects.create(
name=input.name,
owner=owner,
team_type=input.teamType or 'BUYER'
)
# Добавляем owner как участника команды с ролью OWNER
TeamMemberModel.objects.create(
team=team,
user=owner,
role='OWNER'
)
# Устанавливаем как активную команду, если у пользователя её нет
if hasattr(owner, 'profile') and not owner.profile.active_team:
owner.profile.active_team = team
owner.profile.save(update_fields=['active_team'])
return CreateTeamMutation(team=team)
except Exception as e:
raise Exception(f"Failed to create team: {str(e)}")
class UpdateUserMutation(graphene.Mutation):
class Arguments:
userId = graphene.String(required=True)
input = UpdateUserInput(required=True)
user = graphene.Field(User)
def mutate(self, info, userId, input):
# Проверяем права - пользователь может редактировать только себя
context_user_id = getattr(info.context, 'user_id', None)
if context_user_id != userId:
return UpdateUserMutation(user=None)
try:
user = _get_or_create_user_with_profile(userId)
if input.firstName is not None:
user.first_name = input.firstName
if input.lastName is not None:
user.last_name = input.lastName
user.save()
if hasattr(user, 'profile'):
if input.phone is not None:
user.profile.phone = input.phone
if input.avatarId is not None:
user.profile.avatar_id = input.avatarId
user.profile.save()
return UpdateUserMutation(user=user)
except Exception:
return UpdateUserMutation(user=None)
class SwitchTeamMutation(graphene.Mutation):
class Arguments:
teamId = graphene.String(required=True)
user = graphene.Field(User)
def mutate(self, info, teamId):
user_id = getattr(info.context, 'user_id', None)
if not user_id:
raise Exception("User not authenticated")
try:
team = TeamModel.objects.get(uuid=teamId)
user = _get_or_create_user_with_profile(user_id)
if hasattr(user, 'profile'):
user.profile.active_team = team
user.profile.save(update_fields=['active_team'])
return SwitchTeamMutation(user=user)
except TeamModel.DoesNotExist:
raise Exception("Team not found")
class UserMutation(graphene.ObjectType):
create_team = CreateTeamMutation.Field()
update_user = UpdateUserMutation.Field()
switch_team = SwitchTeamMutation.Field()
user_schema = graphene.Schema(query=UserQuery, mutation=UserMutation)

View File

@@ -1,160 +0,0 @@
import requests
from django.conf import settings
from .models import Order, OrderLine, Stage, Trip
class OdooService:
def __init__(self):
self.base_url = f"http://{settings.ODOO_INTERNAL_URL}"
def get_odoo_orders(self, team_uuid):
"""Получить заказы из Odoo API"""
try:
url = f"{self.base_url}/fastapi/orders/api/v1/orders/team/{team_uuid}"
response = requests.get(url, timeout=10)
if response.status_code == 200:
return response.json()
return []
except Exception as e:
print(f"Error fetching from Odoo: {e}")
return []
def get_odoo_order(self, order_uuid):
"""Получить заказ из Odoo API"""
try:
url = f"{self.base_url}/fastapi/orders/api/v1/orders/{order_uuid}"
response = requests.get(url, timeout=10)
if response.status_code == 200:
return response.json()
return None
except Exception as e:
print(f"Error fetching order from Odoo: {e}")
return None
def sync_team_orders(self, team_uuid):
"""Синхронизировать заказы команды с Odoo"""
odoo_orders = self.get_odoo_orders(team_uuid)
django_orders = []
for odoo_order in odoo_orders:
# Создаем или обновляем заказ в Django
order, created = Order.objects.get_or_create(
uuid=odoo_order['uuid'],
defaults={
'name': odoo_order['name'],
'team_uuid': odoo_order['teamUuid'],
'user_id': odoo_order['userId'],
'source_location_uuid': odoo_order['sourceLocationUuid'],
'source_location_name': odoo_order['sourceLocationName'],
'destination_location_uuid': odoo_order['destinationLocationUuid'],
'destination_location_name': odoo_order['destinationLocationName'],
'status': odoo_order['status'],
'total_amount': odoo_order['totalAmount'],
'currency': odoo_order['currency'],
'notes': odoo_order.get('notes', ''),
}
)
# Синхронизируем order lines
self.sync_order_lines(order, odoo_order.get('orderLines', []))
# Синхронизируем stages
self.sync_stages(order, odoo_order.get('stages', []))
django_orders.append(order)
return django_orders
def sync_order(self, order_uuid):
"""Синхронизировать один заказ с Odoo"""
odoo_order = self.get_odoo_order(order_uuid)
if not odoo_order:
return None
# Создаем или обновляем заказ
order, created = Order.objects.get_or_create(
uuid=odoo_order['uuid'],
defaults={
'name': odoo_order['name'],
'team_uuid': odoo_order['teamUuid'],
'user_id': odoo_order['userId'],
'source_location_uuid': odoo_order['sourceLocationUuid'],
'source_location_name': odoo_order['sourceLocationName'],
'destination_location_uuid': odoo_order['destinationLocationUuid'],
'destination_location_name': odoo_order['destinationLocationName'],
'status': odoo_order['status'],
'total_amount': odoo_order['totalAmount'],
'currency': odoo_order['currency'],
'notes': odoo_order.get('notes', ''),
}
)
# Синхронизируем связанные данные
self.sync_order_lines(order, odoo_order.get('orderLines', []))
self.sync_stages(order, odoo_order.get('stages', []))
return order
def sync_order_lines(self, order, odoo_lines):
"""Синхронизировать строки заказа"""
# Удаляем старые
order.order_lines.all().delete()
# Создаем новые
for line_data in odoo_lines:
OrderLine.objects.create(
uuid=line_data['uuid'],
order=order,
product_uuid=line_data['productUuid'],
product_name=line_data['productName'],
quantity=line_data['quantity'],
unit=line_data['unit'],
price_unit=line_data['priceUnit'],
subtotal=line_data['subtotal'],
currency=line_data.get('currency', 'RUB'),
notes=line_data.get('notes', ''),
)
def sync_stages(self, order, odoo_stages):
"""Синхронизировать этапы заказа"""
# Удаляем старые
order.stages.all().delete()
# Создаем новые
for stage_data in odoo_stages:
stage = Stage.objects.create(
uuid=stage_data['uuid'],
order=order,
name=stage_data['name'],
sequence=stage_data['sequence'],
stage_type=stage_data['stageType'],
transport_type=stage_data.get('transportType', ''),
source_location_name=stage_data.get('sourceLocationName', ''),
destination_location_name=stage_data.get('destinationLocationName', ''),
location_name=stage_data.get('locationName', ''),
selected_company_uuid=stage_data.get('selectedCompany', {}).get('uuid', '') if stage_data.get('selectedCompany') else '',
selected_company_name=stage_data.get('selectedCompany', {}).get('name', '') if stage_data.get('selectedCompany') else '',
)
# Синхронизируем trips
self.sync_trips(stage, stage_data.get('trips', []))
def sync_trips(self, stage, odoo_trips):
"""Синхронизировать рейсы этапа"""
for trip_data in odoo_trips:
Trip.objects.create(
uuid=trip_data['uuid'],
stage=stage,
name=trip_data['name'],
sequence=trip_data['sequence'],
company_uuid=trip_data.get('company', {}).get('uuid', '') if trip_data.get('company') else '',
company_name=trip_data.get('company', {}).get('name', '') if trip_data.get('company') else '',
planned_weight=trip_data.get('plannedWeight'),
weight_at_loading=trip_data.get('weightAtLoading'),
weight_at_unloading=trip_data.get('weightAtUnloading'),
planned_loading_date=trip_data.get('plannedLoadingDate'),
actual_loading_date=trip_data.get('actualLoadingDate'),
real_loading_date=trip_data.get('realLoadingDate'),
planned_unloading_date=trip_data.get('plannedUnloadingDate'),
actual_unloading_date=trip_data.get('actualUnloadingDate'),
notes=trip_data.get('notes', ''),
)

View File

@@ -1,168 +0,0 @@
import asyncio
import logging
import os
import uuid
from dataclasses import asdict
from typing import Tuple
from temporalio.client import Client
from .models import Team
logger = logging.getLogger(__name__)
# Default Temporal connection settings; override via env.
TEMPORAL_ADDRESS = os.getenv("TEMPORAL_ADDRESS", "temporal:7233")
TEMPORAL_NAMESPACE = os.getenv("TEMPORAL_NAMESPACE", "default")
TEMPORAL_TASK_QUEUE = os.getenv("TEMPORAL_TASK_QUEUE", "platform-worker")
async def _start_team_created_async(team: Team) -> Tuple[str, str]:
"""
Start the team_created workflow in Temporal and return (workflow_id, run_id).
"""
client = await Client.connect(TEMPORAL_ADDRESS, namespace=TEMPORAL_NAMESPACE)
# We re-use team.uuid as workflow_id to keep idempotency.
handle = await client.start_workflow(
"team_created_workflow", # workflow name registered in worker
{
"team_id": team.uuid,
"team_name": team.name,
"owner_id": getattr(getattr(team.owner, "profile", None), "logto_id", "") or getattr(team.owner, "username", ""),
"logto_org_id": team.logto_org_id or "",
},
id=team.uuid,
task_queue=TEMPORAL_TASK_QUEUE,
)
return handle.id, handle.run_id
def start_team_created(team: Team) -> Tuple[str, str]:
"""
Sync wrapper for Django mutation handlers.
"""
try:
return asyncio.run(_start_team_created_async(team))
except Exception:
logger.exception("Failed to start Temporal workflow for team %s", team.uuid)
raise
async def _start_address_workflow_async(
team_uuid: str,
name: str,
address: str,
latitude: float | None = None,
longitude: float | None = None,
country_code: str | None = None,
is_default: bool = False,
) -> Tuple[str, str]:
"""
Start the create_address workflow in Temporal.
Returns (workflow_id, run_id).
"""
client = await Client.connect(TEMPORAL_ADDRESS, namespace=TEMPORAL_NAMESPACE)
workflow_id = f"address-{uuid.uuid4()}"
handle = await client.start_workflow(
"create_address",
{
"workflow_id": workflow_id,
"team_uuid": team_uuid,
"name": name,
"address": address,
"latitude": latitude,
"longitude": longitude,
"country_code": country_code,
"is_default": is_default,
},
id=workflow_id,
task_queue=TEMPORAL_TASK_QUEUE,
)
logger.info("Started address workflow %s for team %s", workflow_id, team_uuid)
return handle.id, handle.result_run_id
def start_address_workflow(
team_uuid: str,
name: str,
address: str,
latitude: float | None = None,
longitude: float | None = None,
country_code: str | None = None,
is_default: bool = False,
) -> Tuple[str, str]:
"""
Sync wrapper for starting address workflow.
"""
try:
return asyncio.run(_start_address_workflow_async(
team_uuid=team_uuid,
name=name,
address=address,
latitude=latitude,
longitude=longitude,
country_code=country_code,
is_default=is_default,
))
except Exception:
logger.exception("Failed to start address workflow for team %s", team_uuid)
raise
async def _start_invite_workflow_async(
team_uuid: str,
email: str,
role: str,
invited_by: str,
expires_at: str,
) -> Tuple[str, str]:
"""
Start the invite_user workflow in Temporal.
Returns (workflow_id, run_id).
"""
client = await Client.connect(TEMPORAL_ADDRESS, namespace=TEMPORAL_NAMESPACE)
workflow_id = f"invite-{uuid.uuid4()}"
handle = await client.start_workflow(
"invite_user",
{
"team_uuid": team_uuid,
"email": email,
"role": role,
"invited_by": invited_by,
"expires_at": expires_at,
},
id=workflow_id,
task_queue=TEMPORAL_TASK_QUEUE,
)
logger.info("Started invite workflow %s for %s", workflow_id, email)
return handle.id, handle.result_run_id
def start_invite_workflow(
team_uuid: str,
email: str,
role: str,
invited_by: str,
expires_at: str,
) -> Tuple[str, str]:
"""
Sync wrapper for starting invite workflow.
"""
try:
return asyncio.run(_start_invite_workflow_async(
team_uuid=team_uuid,
email=email,
role=role,
invited_by=invited_by,
expires_at=expires_at,
))
except Exception:
logger.exception("Failed to start invite workflow for %s", email)
raise

View File

@@ -1,98 +0,0 @@
from django.test import TestCase
from graphene.test import Client
from teams_app.schema import schema
class TeamsGraphQLTestCase(TestCase):
def setUp(self):
self.client = Client(schema)
def test_get_user_teams_with_params(self):
"""Тест getUserTeams с userId"""
query = '''
{
getUserTeams(userId: "demo-user") {
id
name
ownerId
logtoOrgId
createdAt
updatedAt
}
}
'''
result = self.client.execute(query)
print(f"\n=== getUserTeams WITH PARAMS ===")
print(f"Result: {result}")
if result.get('errors'):
print(f"ERRORS: {result['errors']}")
if result.get('data'):
teams = result['data']['getUserTeams']
print(f"Found {len(teams)} teams")
for team in teams:
print(f"Team: {team.get('name')} - {team.get('id')}")
# Проверки
self.assertIsNone(result.get('errors'))
self.assertIn('getUserTeams', result['data'])
def test_get_user_teams_no_params(self):
"""Тест getUserTeams без параметров"""
query = '''
{
getUserTeams {
id
name
}
}
'''
result = self.client.execute(query)
print(f"\n=== getUserTeams NO PARAMS ===")
print(f"Result: {result}")
if result.get('errors'):
print(f"ERRORS: {result['errors']}")
if result.get('data'):
teams = result['data']['getUserTeams']
print(f"Found {len(teams)} teams")
def test_schema_fields(self):
"""Тест что схема содержит нужные поля"""
query = '''
{
__type(name: "Team") {
fields {
name
type {
name
}
}
}
}
'''
result = self.client.execute(query)
print(f"\n=== TEAM SCHEMA FIELDS ===")
if result.get('data') and result['data']['__type']:
fields = result['data']['__type']['fields']
field_names = [f['name'] for f in fields]
print(f"Team fields: {field_names}")
required_fields = ['id', 'name', 'ownerId']
for field in required_fields:
if field in field_names:
print(f"{field} - OK")
else:
print(f"{field} - MISSING")
def test_invalid_query(self):
"""Тест неправильного запроса"""
query = '{ nonExistentField }'
result = self.client.execute(query)
print(f"\n=== INVALID QUERY TEST ===")
print(f"Result: {result}")
# Должна быть ошибка
self.assertIsNotNone(result.get('errors'))

View File

@@ -1,97 +0,0 @@
import json
import jwt
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from jwt import InvalidTokenError
from .auth import get_bearer_token, scopes_from_payload, validator
@csrf_exempt
def test_jwt(request):
"""Тестовый endpoint для проверки JWT токена с подписью."""
try:
token = get_bearer_token(request)
except InvalidTokenError as exc:
return JsonResponse({"status": "error", "error": str(exc)}, status=403)
response = {"token_length": len(token), "token_preview": f"{token[:32]}...{token[-32:]}"}
try:
audience = getattr(settings, "LOGTO_TEAMS_AUDIENCE", None)
payload = validator.decode(token, audience=audience)
response.update(
{
"status": "ok",
"header": jwt.get_unverified_header(token),
"payload": payload,
"user_id": payload.get("sub"),
"team_uuid": payload.get("team_uuid"),
"scopes": scopes_from_payload(payload),
}
)
return JsonResponse(response, json_dumps_params={"indent": 2})
except InvalidTokenError as exc:
response["status"] = "invalid"
response["error"] = str(exc)
return JsonResponse(response, status=403, json_dumps_params={"indent": 2})
# GraphQL Views - authentication handled by GRAPHENE MIDDLEWARE
from graphene_django.views import GraphQLView
from .graphql_middleware import (
M2MNoAuthMiddleware,
PublicNoAuthMiddleware,
TeamJWTMiddleware,
UserJWTMiddleware,
)
def _is_introspection_query(request):
"""Проверяет, является ли запрос introspection (для GraphQL codegen)"""
if request.method != 'POST':
return False
try:
body = json.loads(request.body.decode('utf-8'))
query = body.get('query', '')
return '__schema' in query or '__type' in query
except Exception:
return False
class PublicGraphQLView(GraphQLView):
"""GraphQL view for public operations (no authentication)."""
def __init__(self, *args, **kwargs):
kwargs['middleware'] = [PublicNoAuthMiddleware()]
super().__init__(*args, **kwargs)
class UserGraphQLView(GraphQLView):
"""GraphQL view for user-level operations (ID Token)."""
def __init__(self, *args, **kwargs):
kwargs['middleware'] = [UserJWTMiddleware()]
super().__init__(*args, **kwargs)
class TeamGraphQLView(GraphQLView):
"""GraphQL view for team-level operations (Access Token)."""
def __init__(self, *args, **kwargs):
kwargs['middleware'] = [TeamJWTMiddleware()]
super().__init__(*args, **kwargs)
class M2MGraphQLView(GraphQLView):
"""GraphQL view for M2M (machine-to-machine) operations.
No authentication required - used by internal services (Temporal, etc.)
"""
def __init__(self, *args, **kwargs):
kwargs['middleware'] = [M2MNoAuthMiddleware()]
super().__init__(*args, **kwargs)

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}