Добавлены новые зависимости, обновлены стили и улучшена структура проекта. Обновлен README с описанием функционала и технологий. Реализована анимация и адаптивный дизайн. Настроена авторизация с использованием Apollo Client.

This commit is contained in:
Bivekich
2025-07-16 18:00:41 +03:00
parent d260749bc9
commit 823ef9a28c
69 changed files with 15539 additions and 210 deletions

358
src/graphql/mutations.ts Normal file
View File

@ -0,0 +1,358 @@
import { gql } from 'graphql-tag'
export const SEND_SMS_CODE = gql`
mutation SendSmsCode($phone: String!) {
sendSmsCode(phone: $phone) {
success
message
}
}
`
export const VERIFY_SMS_CODE = gql`
mutation VerifySmsCode($phone: String!, $code: String!) {
verifySmsCode(phone: $phone, code: $code) {
success
message
token
user {
id
phone
organization {
id
inn
kpp
name
fullName
address
addressFull
ogrn
ogrnDate
type
status
actualityDate
registrationDate
liquidationDate
managementName
managementPost
opfCode
opfFull
opfShort
okato
oktmo
okpo
okved
employeeCount
revenue
taxSystem
phones
emails
apiKeys {
id
marketplace
isActive
}
}
}
}
}
`
export const VERIFY_INN = gql`
mutation VerifyInn($inn: String!) {
verifyInn(inn: $inn) {
success
message
organization {
name
fullName
address
isActive
}
}
}
`
export const REGISTER_FULFILLMENT_ORGANIZATION = gql`
mutation RegisterFulfillmentOrganization($input: FulfillmentRegistrationInput!) {
registerFulfillmentOrganization(input: $input) {
success
message
user {
id
phone
organization {
id
inn
kpp
name
fullName
address
addressFull
ogrn
ogrnDate
type
status
actualityDate
registrationDate
liquidationDate
managementName
managementPost
opfCode
opfFull
opfShort
okato
oktmo
okpo
okved
employeeCount
revenue
taxSystem
phones
emails
apiKeys {
id
marketplace
isActive
}
}
}
}
}
`
export const REGISTER_SELLER_ORGANIZATION = gql`
mutation RegisterSellerOrganization($input: SellerRegistrationInput!) {
registerSellerOrganization(input: $input) {
success
message
user {
id
phone
organization {
id
inn
kpp
name
fullName
address
addressFull
ogrn
ogrnDate
type
status
actualityDate
registrationDate
liquidationDate
managementName
managementPost
opfCode
opfFull
opfShort
okato
oktmo
okpo
okved
employeeCount
revenue
taxSystem
phones
emails
apiKeys {
id
marketplace
isActive
}
}
}
}
}
`
export const ADD_MARKETPLACE_API_KEY = gql`
mutation AddMarketplaceApiKey($input: MarketplaceApiKeyInput!) {
addMarketplaceApiKey(input: $input) {
success
message
apiKey {
id
marketplace
isActive
validationData
}
}
}
`
export const REMOVE_MARKETPLACE_API_KEY = gql`
mutation RemoveMarketplaceApiKey($marketplace: MarketplaceType!) {
removeMarketplaceApiKey(marketplace: $marketplace)
}
`
export const UPDATE_USER_PROFILE = gql`
mutation UpdateUserProfile($input: UpdateUserProfileInput!) {
updateUserProfile(input: $input) {
success
message
user {
id
phone
organization {
id
inn
kpp
name
fullName
address
addressFull
ogrn
ogrnDate
type
status
actualityDate
registrationDate
liquidationDate
managementName
managementPost
opfCode
opfFull
opfShort
okato
oktmo
okpo
okved
employeeCount
revenue
taxSystem
phones
emails
apiKeys {
id
marketplace
isActive
}
}
}
}
}
`
export const UPDATE_ORGANIZATION_BY_INN = gql`
mutation UpdateOrganizationByInn($inn: String!) {
updateOrganizationByInn(inn: $inn) {
success
message
user {
id
phone
organization {
id
inn
kpp
name
fullName
address
addressFull
ogrn
ogrnDate
type
status
actualityDate
registrationDate
liquidationDate
managementName
managementPost
opfCode
opfFull
opfShort
okato
oktmo
okpo
okved
employeeCount
revenue
taxSystem
phones
emails
apiKeys {
id
marketplace
isActive
}
}
}
}
}
`
// Мутации для контрагентов
export const SEND_COUNTERPARTY_REQUEST = gql`
mutation SendCounterpartyRequest($organizationId: ID!, $message: String) {
sendCounterpartyRequest(organizationId: $organizationId, message: $message) {
success
message
request {
id
status
message
createdAt
sender {
id
inn
name
fullName
type
}
receiver {
id
inn
name
fullName
type
}
}
}
}
`
export const RESPOND_TO_COUNTERPARTY_REQUEST = gql`
mutation RespondToCounterpartyRequest($requestId: ID!, $accept: Boolean!) {
respondToCounterpartyRequest(requestId: $requestId, accept: $accept) {
success
message
request {
id
status
message
createdAt
sender {
id
inn
name
fullName
type
}
receiver {
id
inn
name
fullName
type
}
}
}
}
`
export const CANCEL_COUNTERPARTY_REQUEST = gql`
mutation CancelCounterpartyRequest($requestId: ID!) {
cancelCounterpartyRequest(requestId: $requestId)
}
`
export const REMOVE_COUNTERPARTY = gql`
mutation RemoveCounterparty($organizationId: ID!) {
removeCounterparty(organizationId: $organizationId)
}
`

169
src/graphql/queries.ts Normal file
View File

@ -0,0 +1,169 @@
import { gql } from 'graphql-tag'
export const GET_ME = gql`
query GetMe {
me {
id
phone
avatar
createdAt
organization {
id
inn
kpp
name
fullName
address
addressFull
ogrn
ogrnDate
type
status
actualityDate
registrationDate
liquidationDate
managementName
managementPost
opfCode
opfFull
opfShort
okato
oktmo
okpo
okved
employeeCount
revenue
taxSystem
phones
emails
apiKeys {
id
marketplace
isActive
validationData
}
}
}
}
`
// Запросы для контрагентов
export const SEARCH_ORGANIZATIONS = gql`
query SearchOrganizations($type: OrganizationType, $search: String) {
searchOrganizations(type: $type, search: $search) {
id
inn
name
fullName
type
address
phones
emails
createdAt
isCounterparty
users {
id
avatar
}
}
}
`
export const GET_MY_COUNTERPARTIES = gql`
query GetMyCounterparties {
myCounterparties {
id
inn
name
fullName
type
address
phones
emails
createdAt
users {
id
avatar
}
}
}
`
export const GET_INCOMING_REQUESTS = gql`
query GetIncomingRequests {
incomingRequests {
id
status
message
createdAt
sender {
id
inn
name
fullName
type
address
phones
emails
}
receiver {
id
inn
name
fullName
type
}
}
}
`
export const GET_OUTGOING_REQUESTS = gql`
query GetOutgoingRequests {
outgoingRequests {
id
status
message
createdAt
sender {
id
inn
name
fullName
type
}
receiver {
id
inn
name
fullName
type
address
phones
emails
}
}
}
`
export const GET_ORGANIZATION = gql`
query GetOrganization($id: ID!) {
organization(id: $id) {
id
inn
name
fullName
address
type
apiKeys {
id
marketplace
isActive
validationData
createdAt
updatedAt
}
createdAt
updatedAt
}
}
`

1363
src/graphql/resolvers.ts Normal file
View File

@ -0,0 +1,1363 @@
import jwt from 'jsonwebtoken'
import { GraphQLError } from 'graphql'
import { GraphQLScalarType, Kind } from 'graphql'
import { prisma } from '@/lib/prisma'
import { SmsService } from '@/services/sms-service'
import { DaDataService } from '@/services/dadata-service'
import { MarketplaceService } from '@/services/marketplace-service'
import { Prisma } from '@prisma/client'
// Сервисы
const smsService = new SmsService()
const dadataService = new DaDataService()
const marketplaceService = new MarketplaceService()
// Интерфейсы для типизации
interface Context {
user?: {
id: string
phone: string
}
}
interface AuthTokenPayload {
userId: string
phone: string
}
// JWT утилиты
const generateToken = (payload: AuthTokenPayload): string => {
return jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: '30d' })
}
const verifyToken = (token: string): AuthTokenPayload => {
try {
return jwt.verify(token, process.env.JWT_SECRET!) as AuthTokenPayload
} catch (error) {
throw new GraphQLError('Недействительный токен', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
}
// Скалярный тип для JSON
const JSONScalar = new GraphQLScalarType({
name: 'JSON',
description: 'JSON custom scalar type',
serialize(value: unknown) {
return value // значение отправляется клиенту
},
parseValue(value: unknown) {
return value // значение получено от клиента
},
parseLiteral(ast) {
switch (ast.kind) {
case Kind.STRING:
case Kind.BOOLEAN:
return ast.value
case Kind.INT:
case Kind.FLOAT:
return parseFloat(ast.value)
case Kind.OBJECT: {
const value = Object.create(null)
ast.fields.forEach(field => {
value[field.name.value] = parseLiteral(field.value)
})
return value
}
case Kind.LIST:
return ast.values.map(parseLiteral)
default:
return null
}
}
})
function parseLiteral(ast: unknown): unknown {
const astNode = ast as { kind: string; value?: unknown; fields?: unknown[]; values?: unknown[] }
switch (astNode.kind) {
case Kind.STRING:
case Kind.BOOLEAN:
return astNode.value
case Kind.INT:
case Kind.FLOAT:
return parseFloat(astNode.value as string)
case Kind.OBJECT: {
const value = Object.create(null)
if (astNode.fields) {
astNode.fields.forEach((field: unknown) => {
const fieldNode = field as { name: { value: string }; value: unknown }
value[fieldNode.name.value] = parseLiteral(fieldNode.value)
})
}
return value
}
case Kind.LIST:
return ast.values.map(parseLiteral)
default:
return null
}
}
export const resolvers = {
JSON: JSONScalar,
Query: {
me: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
return await prisma.user.findUnique({
where: { id: context.user.id },
include: {
organization: {
include: {
apiKeys: true
}
}
}
})
},
organization: async (_: unknown, args: { id: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const organization = await prisma.organization.findUnique({
where: { id: args.id },
include: {
apiKeys: true,
users: true
}
})
if (!organization) {
throw new GraphQLError('Организация не найдена')
}
// Проверяем, что пользователь имеет доступ к этой организации
const hasAccess = organization.users.some(user => user.id === context.user!.id)
if (!hasAccess) {
throw new GraphQLError('Нет доступа к этой организации', {
extensions: { code: 'FORBIDDEN' }
})
}
return organization
},
// Поиск организаций по типу для добавления в контрагенты
searchOrganizations: async (_: unknown, args: { type?: string; search?: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
// Получаем текущую организацию пользователя
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
// Получаем уже существующих контрагентов для добавления флага
const existingCounterparties = await prisma.counterparty.findMany({
where: { organizationId: currentUser.organization.id },
select: { counterpartyId: true }
})
const existingCounterpartyIds = existingCounterparties.map(c => c.counterpartyId)
const where: any = {
id: { not: currentUser.organization.id } // Исключаем только собственную организацию
}
if (args.type) {
where.type = args.type
}
if (args.search) {
where.OR = [
{ name: { contains: args.search, mode: 'insensitive' } },
{ fullName: { contains: args.search, mode: 'insensitive' } },
{ inn: { contains: args.search } }
]
}
const organizations = await prisma.organization.findMany({
where,
take: 50, // Ограничиваем количество результатов
orderBy: { createdAt: 'desc' },
include: {
users: true,
apiKeys: true
}
})
// Добавляем флаг isCounterparty к каждой организации
return organizations.map(org => ({
...org,
isCounterparty: existingCounterpartyIds.includes(org.id)
}))
},
// Мои контрагенты
myCounterparties: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
const counterparties = await prisma.counterparty.findMany({
where: { organizationId: currentUser.organization.id },
include: {
counterparty: {
include: {
users: true,
apiKeys: true
}
}
}
})
return counterparties.map(c => c.counterparty)
},
// Входящие заявки
incomingRequests: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
return await prisma.counterpartyRequest.findMany({
where: {
receiverId: currentUser.organization.id,
status: 'PENDING'
},
include: {
sender: {
include: {
users: true,
apiKeys: true
}
},
receiver: {
include: {
users: true,
apiKeys: true
}
}
},
orderBy: { createdAt: 'desc' }
})
},
// Исходящие заявки
outgoingRequests: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
return await prisma.counterpartyRequest.findMany({
where: {
senderId: currentUser.organization.id,
status: { in: ['PENDING', 'REJECTED'] }
},
include: {
sender: {
include: {
users: true,
apiKeys: true
}
},
receiver: {
include: {
users: true,
apiKeys: true
}
}
},
orderBy: { createdAt: 'desc' }
})
}
},
Mutation: {
sendSmsCode: async (_: unknown, args: { phone: string }) => {
const result = await smsService.sendSmsCode(args.phone)
return {
success: result.success,
message: result.message || 'SMS код отправлен'
}
},
verifySmsCode: async (_: unknown, args: { phone: string; code: string }) => {
const verificationResult = await smsService.verifySmsCode(args.phone, args.code)
if (!verificationResult.success) {
return {
success: false,
message: verificationResult.message || 'Неверный код'
}
}
// Найти или создать пользователя
const formattedPhone = args.phone.replace(/\D/g, '')
let user = await prisma.user.findUnique({
where: { phone: formattedPhone },
include: {
organization: {
include: {
apiKeys: true
}
}
}
})
if (!user) {
user = await prisma.user.create({
data: {
phone: formattedPhone
},
include: {
organization: {
include: {
apiKeys: true
}
}
}
})
}
const token = generateToken({
userId: user.id,
phone: user.phone
})
console.log('verifySmsCode - Generated token:', token ? `${token.substring(0, 20)}...` : 'No token')
console.log('verifySmsCode - Full token:', token)
console.log('verifySmsCode - User object:', { id: user.id, phone: user.phone })
const result = {
success: true,
message: 'Авторизация успешна',
token,
user
}
console.log('verifySmsCode - Returning result:', {
success: result.success,
hasToken: !!result.token,
hasUser: !!result.user,
message: result.message,
tokenPreview: result.token ? `${result.token.substring(0, 20)}...` : 'No token in result'
})
return result
},
verifyInn: async (_: unknown, args: { inn: string }) => {
// Валидируем ИНН
if (!dadataService.validateInn(args.inn)) {
return {
success: false,
message: 'Неверный формат ИНН'
}
}
// Получаем данные организации из DaData
const organizationData = await dadataService.getOrganizationByInn(args.inn)
if (!organizationData) {
return {
success: false,
message: 'Организация с указанным ИНН не найдена'
}
}
return {
success: true,
message: 'ИНН найден',
organization: {
name: organizationData.name,
fullName: organizationData.fullName,
address: organizationData.address,
isActive: organizationData.isActive
}
}
},
registerFulfillmentOrganization: async (
_: unknown,
args: { input: { phone: string; inn: string } },
context: Context
) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const { inn } = args.input
// Валидируем ИНН
if (!dadataService.validateInn(inn)) {
return {
success: false,
message: 'Неверный формат ИНН'
}
}
// Получаем данные организации из DaData
const organizationData = await dadataService.getOrganizationByInn(inn)
if (!organizationData) {
return {
success: false,
message: 'Организация с указанным ИНН не найдена'
}
}
try {
// Проверяем, что организация еще не зарегистрирована
const existingOrg = await prisma.organization.findUnique({
where: { inn: organizationData.inn }
})
if (existingOrg) {
return {
success: false,
message: 'Организация с таким ИНН уже зарегистрирована'
}
}
// Создаем организацию со всеми данными из DaData
const organization = await prisma.organization.create({
data: {
inn: organizationData.inn,
kpp: organizationData.kpp,
name: organizationData.name,
fullName: organizationData.fullName,
address: organizationData.address,
addressFull: organizationData.addressFull,
ogrn: organizationData.ogrn,
ogrnDate: organizationData.ogrnDate,
// Статус организации
status: organizationData.status,
actualityDate: organizationData.actualityDate,
registrationDate: organizationData.registrationDate,
liquidationDate: organizationData.liquidationDate,
// Руководитель
managementName: organizationData.managementName,
managementPost: organizationData.managementPost,
// ОПФ
opfCode: organizationData.opfCode,
opfFull: organizationData.opfFull,
opfShort: organizationData.opfShort,
// Коды статистики
okato: organizationData.okato,
oktmo: organizationData.oktmo,
okpo: organizationData.okpo,
okved: organizationData.okved,
// Контакты
phones: organizationData.phones ? JSON.parse(JSON.stringify(organizationData.phones)) : null,
emails: organizationData.emails ? JSON.parse(JSON.stringify(organizationData.emails)) : null,
// Финансовые данные
employeeCount: organizationData.employeeCount,
revenue: organizationData.revenue,
taxSystem: organizationData.taxSystem,
type: 'FULFILLMENT',
dadataData: JSON.parse(JSON.stringify(organizationData.rawData))
}
})
// Привязываем пользователя к организации
const updatedUser = await prisma.user.update({
where: { id: context.user.id },
data: { organizationId: organization.id },
include: {
organization: {
include: {
apiKeys: true
}
}
}
})
return {
success: true,
message: 'Фулфилмент организация успешно зарегистрирована',
user: updatedUser
}
} catch (error) {
console.error('Error registering fulfillment organization:', error)
return {
success: false,
message: 'Ошибка при регистрации организации'
}
}
},
registerSellerOrganization: async (
_: unknown,
args: {
input: {
phone: string
wbApiKey?: string
ozonApiKey?: string
ozonClientId?: string
}
},
context: Context
) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const { wbApiKey, ozonApiKey, ozonClientId } = args.input
if (!wbApiKey && !ozonApiKey) {
return {
success: false,
message: 'Необходимо указать хотя бы один API ключ маркетплейса'
}
}
try {
// Валидируем API ключи
const validationResults = []
if (wbApiKey) {
const wbResult = await marketplaceService.validateWildberriesApiKey(wbApiKey)
if (!wbResult.isValid) {
return {
success: false,
message: `Wildberries: ${wbResult.message}`
}
}
validationResults.push({
marketplace: 'WILDBERRIES',
apiKey: wbApiKey,
data: wbResult.data
})
}
if (ozonApiKey && ozonClientId) {
const ozonResult = await marketplaceService.validateOzonApiKey(ozonApiKey, ozonClientId)
if (!ozonResult.isValid) {
return {
success: false,
message: `Ozon: ${ozonResult.message}`
}
}
validationResults.push({
marketplace: 'OZON',
apiKey: ozonApiKey,
data: ozonResult.data
})
}
// Создаем организацию селлера - используем название магазина как основное имя
const shopName = validationResults[0]?.data?.sellerName || 'Магазин'
const organization = await prisma.organization.create({
data: {
inn: validationResults[0]?.data?.inn || `SELLER_${Date.now()}`,
name: shopName,
fullName: `Интернет-магазин "${shopName}"`,
type: 'SELLER'
}
})
// Добавляем API ключи
for (const validation of validationResults) {
await prisma.apiKey.create({
data: {
marketplace: validation.marketplace as 'WILDBERRIES' | 'OZON',
apiKey: validation.apiKey,
organizationId: organization.id,
validationData: validation.data
}
})
}
// Привязываем пользователя к организации
const updatedUser = await prisma.user.update({
where: { id: context.user.id },
data: { organizationId: organization.id },
include: {
organization: {
include: {
apiKeys: true
}
}
}
})
return {
success: true,
message: 'Селлер организация успешно зарегистрирована',
user: updatedUser
}
} catch (error) {
console.error('Error registering seller organization:', error)
return {
success: false,
message: 'Ошибка при регистрации организации'
}
}
},
addMarketplaceApiKey: async (
_: unknown,
args: {
input: {
marketplace: 'WILDBERRIES' | 'OZON'
apiKey: string
clientId?: string
validateOnly?: boolean
}
},
context: Context
) => {
// Разрешаем валидацию без авторизации
if (!args.input.validateOnly && !context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const { marketplace, apiKey, clientId, validateOnly } = args.input
// Валидируем API ключ
const validationResult = await marketplaceService.validateApiKey(
marketplace,
apiKey,
clientId
)
if (!validationResult.isValid) {
return {
success: false,
message: validationResult.message
}
}
// Если это только валидация, возвращаем результат без сохранения
if (validateOnly) {
return {
success: true,
message: 'API ключ действителен',
apiKey: {
id: 'validate-only',
marketplace,
isActive: true,
validationData: validationResult,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
}
}
// Для сохранения API ключа нужна авторизация
if (!context.user) {
throw new GraphQLError('Требуется авторизация для сохранения API ключа', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const user = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!user?.organization) {
return {
success: false,
message: 'Пользователь не привязан к организации'
}
}
try {
// Проверяем, что такого ключа еще нет
const existingKey = await prisma.apiKey.findUnique({
where: {
organizationId_marketplace: {
organizationId: user.organization.id,
marketplace
}
}
})
if (existingKey) {
// Обновляем существующий ключ
const updatedKey = await prisma.apiKey.update({
where: { id: existingKey.id },
data: {
apiKey,
validationData: validationResult.data,
isActive: true
}
})
return {
success: true,
message: 'API ключ успешно обновлен',
apiKey: updatedKey
}
} else {
// Создаем новый ключ
const newKey = await prisma.apiKey.create({
data: {
marketplace,
apiKey,
organizationId: user.organization.id,
validationData: validationResult.data
}
})
return {
success: true,
message: 'API ключ успешно добавлен',
apiKey: newKey
}
}
} catch (error) {
console.error('Error adding marketplace API key:', error)
return {
success: false,
message: 'Ошибка при добавлении API ключа'
}
}
},
removeMarketplaceApiKey: async (
_: unknown,
args: { marketplace: 'WILDBERRIES' | 'OZON' },
context: Context
) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const user = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!user?.organization) {
throw new GraphQLError('Пользователь не привязан к организации')
}
try {
await prisma.apiKey.delete({
where: {
organizationId_marketplace: {
organizationId: user.organization.id,
marketplace: args.marketplace
}
}
})
return true
} catch (error) {
console.error('Error removing marketplace API key:', error)
return false
}
},
updateUserProfile: async (_: unknown, args: { input: {
avatar?: string
orgPhone?: string
managerName?: string
telegram?: string
whatsapp?: string
email?: string
bankName?: string
bik?: string
accountNumber?: string
corrAccount?: string
} }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const user = await prisma.user.findUnique({
where: { id: context.user.id },
include: {
organization: {
include: {
apiKeys: true
}
}
}
})
if (!user?.organization) {
throw new GraphQLError('Пользователь не привязан к организации')
}
try {
const { input } = args
// Обновляем аватар пользователя если указан
if (input.avatar) {
await prisma.user.update({
where: { id: context.user.id },
data: { avatar: input.avatar }
})
}
// Подготавливаем данные для обновления организации
const updateData: {
phones?: object
emails?: object
managementName?: string
managementPost?: string
} = {}
// Обновляем контактные данные в JSON поле phones
if (input.orgPhone) {
updateData.phones = [{ value: input.orgPhone, type: 'main' }]
}
// Обновляем email в JSON поле emails
if (input.email) {
updateData.emails = [{ value: input.email, type: 'main' }]
}
// Сохраняем дополнительные контакты в custom полях
// Пока добавим их как дополнительные JSON поля
const customContacts: {
managerName?: string
telegram?: string
whatsapp?: string
bankDetails?: {
bankName?: string
bik?: string
accountNumber?: string
corrAccount?: string
}
} = {}
if (input.managerName) {
customContacts.managerName = input.managerName
}
if (input.telegram) {
customContacts.telegram = input.telegram
}
if (input.whatsapp) {
customContacts.whatsapp = input.whatsapp
}
if (input.bankName || input.bik || input.accountNumber || input.corrAccount) {
customContacts.bankDetails = {
bankName: input.bankName,
bik: input.bik,
accountNumber: input.accountNumber,
corrAccount: input.corrAccount
}
}
// Если есть дополнительные контакты, сохраним их в поле managementPost временно
// В идеале нужно добавить отдельную таблицу для контактов
if (Object.keys(customContacts).length > 0) {
updateData.managementPost = JSON.stringify(customContacts)
}
// Обновляем организацию
const updatedOrganization = await prisma.organization.update({
where: { id: user.organization.id },
data: updateData,
include: {
apiKeys: true
}
})
// Получаем обновленного пользователя
const updatedUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: {
organization: {
include: {
apiKeys: true
}
}
}
})
return {
success: true,
message: 'Профиль успешно обновлен',
user: updatedUser
}
} catch (error) {
console.error('Error updating user profile:', error)
return {
success: false,
message: 'Ошибка при обновлении профиля'
}
}
},
updateOrganizationByInn: async (_: unknown, args: { inn: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const user = await prisma.user.findUnique({
where: { id: context.user.id },
include: {
organization: {
include: {
apiKeys: true
}
}
}
})
if (!user?.organization) {
throw new GraphQLError('Пользователь не привязан к организации')
}
try {
// Валидируем ИНН
if (!dadataService.validateInn(args.inn)) {
return {
success: false,
message: 'Неверный формат ИНН'
}
}
// Получаем данные организации из DaData
const organizationData = await dadataService.getOrganizationByInn(args.inn)
if (!organizationData) {
return {
success: false,
message: 'Организация с указанным ИНН не найдена в федеральном реестре'
}
}
// Проверяем, есть ли уже организация с таким ИНН в базе (кроме текущей)
const existingOrganization = await prisma.organization.findUnique({
where: { inn: organizationData.inn }
})
if (existingOrganization && existingOrganization.id !== user.organization.id) {
return {
success: false,
message: `Организация с ИНН ${organizationData.inn} уже существует в системе`
}
}
// Подготавливаем данные для обновления
const updateData: Prisma.OrganizationUpdateInput = {
kpp: organizationData.kpp,
name: organizationData.name,
fullName: organizationData.fullName,
address: organizationData.address,
addressFull: organizationData.addressFull,
ogrn: organizationData.ogrn,
ogrnDate: organizationData.ogrnDate ? organizationData.ogrnDate.toISOString() : null,
registrationDate: organizationData.registrationDate ? organizationData.registrationDate.toISOString() : null,
liquidationDate: organizationData.liquidationDate ? organizationData.liquidationDate.toISOString() : null,
managementName: organizationData.managementName,
managementPost: user.organization.managementPost, // Сохраняем кастомные данные пользователя
opfCode: organizationData.opfCode,
opfFull: organizationData.opfFull,
opfShort: organizationData.opfShort,
okato: organizationData.okato,
oktmo: organizationData.oktmo,
okpo: organizationData.okpo,
okved: organizationData.okved,
status: organizationData.status
}
// Добавляем ИНН только если он отличается от текущего
if (user.organization.inn !== organizationData.inn) {
updateData.inn = organizationData.inn
}
// Обновляем организацию
const updatedOrganization = await prisma.organization.update({
where: { id: user.organization.id },
data: updateData,
include: {
apiKeys: true
}
})
// Получаем обновленного пользователя
const updatedUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: {
organization: {
include: {
apiKeys: true
}
}
}
})
return {
success: true,
message: 'Данные организации успешно обновлены',
user: updatedUser
}
} catch (error) {
console.error('Error updating organization by INN:', error)
return {
success: false,
message: 'Ошибка при обновлении данных организации'
}
}
},
logout: () => {
// В stateless JWT системе logout происходит на клиенте
// Можно добавить blacklist токенов, если нужно
return true
},
// Отправить заявку на добавление в контрагенты
sendCounterpartyRequest: async (_: unknown, args: { organizationId: string; message?: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
if (currentUser.organization.id === args.organizationId) {
throw new GraphQLError('Нельзя отправить заявку самому себе')
}
// Проверяем, что организация-получатель существует
const receiverOrganization = await prisma.organization.findUnique({
where: { id: args.organizationId }
})
if (!receiverOrganization) {
throw new GraphQLError('Организация не найдена')
}
try {
// Создаем или обновляем заявку
const request = await prisma.counterpartyRequest.upsert({
where: {
senderId_receiverId: {
senderId: currentUser.organization.id,
receiverId: args.organizationId
}
},
update: {
status: 'PENDING',
message: args.message,
updatedAt: new Date()
},
create: {
senderId: currentUser.organization.id,
receiverId: args.organizationId,
message: args.message,
status: 'PENDING'
},
include: {
sender: true,
receiver: true
}
})
return {
success: true,
message: 'Заявка отправлена',
request
}
} catch (error) {
console.error('Error sending counterparty request:', error)
return {
success: false,
message: 'Ошибка при отправке заявки'
}
}
},
// Ответить на заявку контрагента
respondToCounterpartyRequest: async (_: unknown, args: { requestId: string; accept: boolean }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
try {
// Найти заявку и проверить права
const request = await prisma.counterpartyRequest.findUnique({
where: { id: args.requestId },
include: {
sender: true,
receiver: true
}
})
if (!request) {
throw new GraphQLError('Заявка не найдена')
}
if (request.receiverId !== currentUser.organization.id) {
throw new GraphQLError('Нет прав на обработку этой заявки')
}
if (request.status !== 'PENDING') {
throw new GraphQLError('Заявка уже обработана')
}
const newStatus = args.accept ? 'ACCEPTED' : 'REJECTED'
// Обновляем статус заявки
const updatedRequest = await prisma.counterpartyRequest.update({
where: { id: args.requestId },
data: { status: newStatus },
include: {
sender: true,
receiver: true
}
})
// Если заявка принята, создаем связи контрагентов в обе стороны
if (args.accept) {
await prisma.$transaction([
// Добавляем отправителя в контрагенты получателя
prisma.counterparty.create({
data: {
organizationId: request.receiverId,
counterpartyId: request.senderId
}
}),
// Добавляем получателя в контрагенты отправителя
prisma.counterparty.create({
data: {
organizationId: request.senderId,
counterpartyId: request.receiverId
}
})
])
}
return {
success: true,
message: args.accept ? 'Заявка принята' : 'Заявка отклонена',
request: updatedRequest
}
} catch (error) {
console.error('Error responding to counterparty request:', error)
return {
success: false,
message: 'Ошибка при обработке заявки'
}
}
},
// Отменить заявку
cancelCounterpartyRequest: async (_: unknown, args: { requestId: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
try {
const request = await prisma.counterpartyRequest.findUnique({
where: { id: args.requestId }
})
if (!request) {
throw new GraphQLError('Заявка не найдена')
}
if (request.senderId !== currentUser.organization.id) {
throw new GraphQLError('Можно отменить только свои заявки')
}
if (request.status !== 'PENDING') {
throw new GraphQLError('Можно отменить только ожидающие заявки')
}
await prisma.counterpartyRequest.update({
where: { id: args.requestId },
data: { status: 'CANCELLED' }
})
return true
} catch (error) {
console.error('Error cancelling counterparty request:', error)
return false
}
},
// Удалить контрагента
removeCounterparty: async (_: unknown, args: { organizationId: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
try {
// Удаляем связь в обе стороны
await prisma.$transaction([
prisma.counterparty.deleteMany({
where: {
organizationId: currentUser.organization.id,
counterpartyId: args.organizationId
}
}),
prisma.counterparty.deleteMany({
where: {
organizationId: args.organizationId,
counterpartyId: currentUser.organization.id
}
})
])
return true
} catch (error) {
console.error('Error removing counterparty:', error)
return false
}
}
},
// Резолверы типов
Organization: {
users: async (parent: { id: string; users?: unknown[] }) => {
// Если пользователи уже загружены через include, возвращаем их
if (parent.users) {
return parent.users
}
// Иначе загружаем отдельно
return await prisma.user.findMany({
where: { organizationId: parent.id }
})
}
},
User: {
organization: async (parent: { organizationId?: string; organization?: unknown }) => {
// Если организация уже загружена через include, возвращаем её
if (parent.organization) {
return parent.organization
}
// Иначе загружаем отдельно если есть organizationId
if (parent.organizationId) {
return await prisma.organization.findUnique({
where: { id: parent.organizationId },
include: {
apiKeys: true,
users: true
}
})
}
return null
}
}
}

229
src/graphql/typedefs.ts Normal file
View File

@ -0,0 +1,229 @@
import { gql } from 'graphql-tag'
export const typeDefs = gql`
type Query {
me: User
organization(id: ID!): Organization
# Поиск организаций по типу для добавления в контрагенты
searchOrganizations(type: OrganizationType, search: String): [Organization!]!
# Мои контрагенты
myCounterparties: [Organization!]!
# Входящие заявки
incomingRequests: [CounterpartyRequest!]!
# Исходящие заявки
outgoingRequests: [CounterpartyRequest!]!
}
type Mutation {
# Авторизация через SMS
sendSmsCode(phone: String!): SmsResponse!
verifySmsCode(phone: String!, code: String!): AuthResponse!
# Валидация ИНН
verifyInn(inn: String!): InnValidationResponse!
# Обновление профиля пользователя
updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfileResponse!
# Обновление данных организации по ИНН
updateOrganizationByInn(inn: String!): UpdateOrganizationResponse!
# Регистрация организации
registerFulfillmentOrganization(input: FulfillmentRegistrationInput!): AuthResponse!
registerSellerOrganization(input: SellerRegistrationInput!): AuthResponse!
# Работа с API ключами
addMarketplaceApiKey(input: MarketplaceApiKeyInput!): ApiKeyResponse!
removeMarketplaceApiKey(marketplace: MarketplaceType!): Boolean!
# Выход из системы
logout: Boolean!
# Работа с контрагентами
sendCounterpartyRequest(organizationId: ID!, message: String): CounterpartyRequestResponse!
respondToCounterpartyRequest(requestId: ID!, accept: Boolean!): CounterpartyRequestResponse!
cancelCounterpartyRequest(requestId: ID!): Boolean!
removeCounterparty(organizationId: ID!): Boolean!
}
# Типы данных
type User {
id: ID!
phone: String!
avatar: String
organization: Organization
createdAt: String!
updatedAt: String!
}
type Organization {
id: ID!
inn: String!
kpp: String
name: String
fullName: String
address: String
addressFull: String
ogrn: String
ogrnDate: String
type: OrganizationType!
status: String
actualityDate: String
registrationDate: String
liquidationDate: String
managementName: String
managementPost: String
opfCode: String
opfFull: String
opfShort: String
okato: String
oktmo: String
okpo: String
okved: String
employeeCount: Int
revenue: String
taxSystem: String
phones: JSON
emails: JSON
users: [User!]!
apiKeys: [ApiKey!]!
isCounterparty: Boolean
createdAt: String!
updatedAt: String!
}
type ApiKey {
id: ID!
marketplace: MarketplaceType!
isActive: Boolean!
validationData: JSON
createdAt: String!
updatedAt: String!
}
# Входные типы для мутаций
input UpdateUserProfileInput {
# Аватар пользователя
avatar: String
# Контактные данные организации
orgPhone: String
managerName: String
telegram: String
whatsapp: String
email: String
# Банковские данные
bankName: String
bik: String
accountNumber: String
corrAccount: String
}
input FulfillmentRegistrationInput {
phone: String!
inn: String!
}
input SellerRegistrationInput {
phone: String!
wbApiKey: String
ozonApiKey: String
ozonClientId: String
}
input MarketplaceApiKeyInput {
marketplace: MarketplaceType!
apiKey: String!
clientId: String # Для Ozon
validateOnly: Boolean # Только валидация без сохранения
}
# Ответные типы
type SmsResponse {
success: Boolean!
message: String!
}
type AuthResponse {
success: Boolean!
message: String!
token: String
user: User
}
type InnValidationResponse {
success: Boolean!
message: String!
organization: ValidatedOrganization
}
type ValidatedOrganization {
name: String!
fullName: String!
address: String!
isActive: Boolean!
}
type ApiKeyResponse {
success: Boolean!
message: String!
apiKey: ApiKey
}
type UpdateUserProfileResponse {
success: Boolean!
message: String!
user: User
}
type UpdateOrganizationResponse {
success: Boolean!
message: String!
user: User
}
# Enums
enum OrganizationType {
FULFILLMENT
SELLER
LOGIST
WHOLESALE
}
enum MarketplaceType {
WILDBERRIES
OZON
}
enum CounterpartyRequestStatus {
PENDING
ACCEPTED
REJECTED
CANCELLED
}
# Типы для контрагентов
type CounterpartyRequest {
id: ID!
status: CounterpartyRequestStatus!
message: String
sender: Organization!
receiver: Organization!
createdAt: String!
updatedAt: String!
}
type CounterpartyRequestResponse {
success: Boolean!
message: String!
request: CounterpartyRequest
}
# JSON скаляр
scalar JSON
`