Files
sfera-new/docs/api-layer/GRAPHQL_SCHEMA_RULES.md
Veronika Smirnova 12fd8ddf61 feat(supplier-orders): добавить параметры поставки в таблицу заявок
- Добавлены колонки Объём и Грузовые места между Цена товаров и Статус
- Реализованы инпуты для ввода volume и packagesCount в статусе PENDING для роли WHOLESALE
- Добавлена мутация UPDATE_SUPPLY_PARAMETERS с проверками безопасности
- Скрыта строка Поставщик для роли WHOLESALE (поставщик знает свои данные)
- Исправлено выравнивание таблицы при скрытии уровня поставщика
- Реорганизованы документы: legacy-rules/, docs/, docs-and-reports/

ВНИМАНИЕ: Компонент multilevel-supplies-table.tsx (1697 строк) нарушает правило модульной архитектуры (>800 строк требует рефакторинга)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 18:47:23 +03:00

27 KiB
Raw Permalink Blame History

ПРАВИЛА GRAPHQL СХЕМЫ СИСТЕМЫ SFERA

🎯 ОБЩИЕ ПРИНЦИПЫ СХЕМЫ

1. ТИПОБЕЗОПАСНОСТЬ

  • Строгая типизация: Все поля должны иметь четко определенный тип
  • Обязательные поля: Использование ! для критичных данных
  • Nullable поля: Явное указание опциональности без !

2. КОНСИСТЕНТНОСТЬ ИМЕНОВАНИЯ

// ✅ Правильное именование
type Organization {
  id: ID!           // Всегда ID! для идентификаторов
  name: String      // Nullable для опциональных данных
  createdAt: DateTime!  // Обязательные временные метки
}

// ❌ Неправильное именование
type organization { ... }  // Должно быть PascalCase
type User {
  user_id: String  // Должно быть camelCase: userId
}

📋 ОСНОВНЫЕ ENUMS СИСТЕМЫ

OrganizationType (Типы организаций)

enum OrganizationType {
  FULFILLMENT # Фулфилмент-центры
  SELLER # Селлеры (продавцы)
  LOGIST # Логистические компании
  WHOLESALE # Поставщики (оптовики)
}

Правила использования:

  • Обязательное поле в модели Organization
  • Определяет доступные функции в UI
  • Используется для фильтрации в поиске контрагентов
  • Нельзя изменить тип существующей организации

SupplyOrderStatus (Статусы поставок)

enum SupplyOrderStatus {
  PENDING # Ожидает одобрения поставщика
  SUPPLIER_APPROVED # Поставщик одобрил, ждет логистику
  LOGISTICS_CONFIRMED # Логистика подтвердила, ждет отправки
  SHIPPED # Отправлено поставщиком
  DELIVERED # Доставлено и принято
  CANCELLED # Отменено любым участником
  # Legacy статусы (обратная совместимость):
  CONFIRMED # → SUPPLIER_APPROVED
  IN_TRANSIT # → SHIPPED
}

Правила переходов:

  • Только последовательные переходы
  • Любой статус → CANCELLED
  • Возврат к предыдущим статусам
  • Пропуск промежуточных статусов

CounterpartyRequestStatus (Статусы заявок на партнерство)

enum CounterpartyRequestStatus {
  PENDING # Отправлена, ждет ответа
  ACCEPTED # Принята - партнерство активно
  REJECTED # Отклонена
  CANCELLED # Отменена отправителем
}

MarketplaceType (Поддерживаемые маркетплейсы)

enum MarketplaceType {
  WILDBERRIES # WB API интеграция
  OZON # Ozon API интеграция
}

🔍 ПРАВИЛА QUERY ОПЕРАЦИЙ

1. ПОИСК И ФИЛЬТРАЦИЯ

# ✅ Правильная структура поиска
searchOrganizations(
  type: OrganizationType    # Фильтр по типу организации
  search: String           # Текстовый поиск по имени/ИНН
): [Organization!]!

# ✅ Пагинация для больших списков
messages(
  counterpartyId: ID!
  limit: Int               # Лимит записей
  offset: Int             # Смещение
): [Message!]!

Реальная реализация поиска организаций:

// Из src/graphql/resolvers.ts
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 where: Prisma.OrganizationWhereInput = {
    id: { not: currentUser.organization.id }, // Исключаем себя из результатов
  }

  // Фильтр по типу организации
  if (args.type) {
    where.type = args.type as OrganizationType
  }

  // Текстовый поиск по имени/ИНН
  if (args.search) {
    where.OR = [
      { name: { contains: args.search, mode: 'insensitive' } },
      { fullName: { contains: args.search, mode: 'insensitive' } },
      { inn: { contains: args.search, mode: 'insensitive' } },
    ]
  }

  return await prisma.organization.findMany({
    where,
    take: 20, // Лимит результатов для производительности
    orderBy: { createdAt: 'desc' },
  })
}

2. ПРАВА ДОСТУПА В QUERIES

# ✅ Доступ к своим данным
mySupplies: [Supply!]!              # Только мои расходники
myServices: [Service!]!             # Только мои услуги
myCounterparties: [Organization!]!  # Только мои контрагенты

# ✅ Доступ к данным контрагентов (с проверкой партнерства)
counterpartyServices(organizationId: ID!): [Service!]!
organizationProducts(organizationId: ID!): [Product!]!

# ✅ Публичные данные (без ограничений)
allProducts: [Product!]!
categories: [Category!]!

3. АГРЕГИРОВАННЫЕ ДАННЫЕ

# ✅ Счетчики для dashboard
type PendingSuppliesCount {
  incomingSupplierOrders: Int! # Для поставщиков
  logisticsOrders: Int! # Для логистики
  ourSupplyOrders: Int! # Для фулфилмента
  sellerSupplyOrders: Int! # Заказы от селлеров
}

# ✅ Иерархические данные
type WarehouseDataResponse {
  partners: [WarehousePartner!]! # 3-уровневая структура
}

Реальная реализация счетчиков (из pendingSuppliesCount):

// Динамические счетчики в зависимости от типа организации
pendingSuppliesCount: async (_: unknown, __: unknown, context: Context) => {
  const currentUser = await prisma.user.findUnique({
    where: { id: context.user.id },
    include: { organization: true },
  })

  // 🔔 ВХОДЯЩИЕ ЗАКАЗЫ ДЛЯ ПОСТАВЩИКОВ (WHOLESALE)
  const incomingSupplierOrders = await prisma.supplyOrder.count({
    where: {
      partnerId: currentUser.organization.id, // Мы - поставщик
      status: 'PENDING', // Ожидает подтверждения от поставщика
    },
  })

  // 🚚 ЛОГИСТИЧЕСКИЕ ЗАЯВКИ ДЛЯ ЛОГИСТИКИ (LOGIST)
  const logisticsOrders = await prisma.supplyOrder.count({
    where: {
      logisticsPartnerId: currentUser.organization.id, // Мы - назначенная логистика
      status: {
        in: [
          'CONFIRMED', // Legacy: подтверждено ФФ
          'SUPPLIER_APPROVED', // Подтверждено поставщиком
          'LOGISTICS_CONFIRMED', // Подтверждено логистикой - нужно забрать товар
        ],
      },
    },
  })

  // 🏭 ЗАКАЗЫ ДЛЯ ФУЛФИЛМЕНТА
  const ourSupplyOrders = await prisma.supplyOrder.count({
    where: {
      organizationId: currentUser.organization.id, // Наши собственные заказы
      status: { notIn: ['DELIVERED', 'CANCELLED'] },
    },
  })

  const sellerSupplyOrders = await prisma.supplyOrder.count({
    where: {
      fulfillmentCenterId: currentUser.organization.id, // Мы - получатели
      status: { notIn: ['DELIVERED', 'CANCELLED'] },
    },
  })

  // Определяем приоритетный счетчик по типу организации
  let pendingSupplyOrders = 0
  if (currentUser.organization.type === 'FULFILLMENT') {
    pendingSupplyOrders = ourSupplyOrders + sellerSupplyOrders
  } else if (currentUser.organization.type === 'WHOLESALE') {
    pendingSupplyOrders = incomingSupplierOrders
  } else if (currentUser.organization.type === 'LOGIST') {
    pendingSupplyOrders = logisticsOrders
  }

  return {
    incomingSupplierOrders,
    logisticsOrders,
    ourSupplyOrders,
    sellerSupplyOrders,
    pendingSupplyOrders, // Главный счетчик для UI
  }
}

🔄 ПРАВИЛА MUTATION ОПЕРАЦИЙ

1. СТРУКТУРА INPUT ТИПОВ

# ✅ Консистентное именование Input типов
input CreateEmployeeInput {
  name: String!
  position: String!
  salary: Float
  # Обязательные поля с !, опциональные без
}

input UpdateEmployeeInput {
  name: String # В Update все поля опциональны
  position: String
  salary: Float
}

2. RESPONSE ТИПЫ ДЛЯ МУТАЦИЙ

# ✅ Стандартная структура Response
type EmployeeResponse {
  success: Boolean!
  message: String
  employee: Employee # Данные при успехе
  errors: [String!] # Ошибки валидации
}

3. ПРАВИЛА АВТОРИЗАЦИИ В МУТАЦИЯХ

# ✅ Мутации требующие авторизации
createSupplyOrder(input: SupplyOrderInput!): SupplyOrderResponse!

# ✅ Мутации для конкретных ролей
updateProductInWarehouse(...): Product!  # Только FULFILLMENT
approveSupplyOrder(...): SupplyOrder!    # Только WHOLESALE

# ✅ Административные мутации
adminLogin(username: String!, password: String!): AdminAuthResponse!

Реальная реализация createSupplyOrder с валидацией:

// Из src/graphql/resolvers.ts
createSupplyOrder: async (
  _: unknown,
  args: {
    input: {
      partnerId: string
      deliveryDate: string
      fulfillmentCenterId?: string // ID фулфилмент-центра для доставки
      logisticsPartnerId?: string // ID логистической компании
      items: Array<{
        productId: string
        quantity: number
        recipe?: {
          services?: string[]
          fulfillmentConsumables?: string[]
          sellerConsumables?: string[]
          marketplaceCardId?: 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 allowedTypes = ['FULFILLMENT', 'SELLER', 'LOGIST']
  if (!allowedTypes.includes(currentUser.organization.type)) {
    throw new GraphQLError('Заказы поставок недоступны для данного типа организации')
  }

  // Проверка существования поставщика
  const partner = await prisma.organization.findUnique({
    where: { id: args.input.partnerId },
  })

  if (!partner || partner.type !== 'WHOLESALE') {
    throw new GraphQLError('Поставщик не найден или некорректный тип')
  }

  // Вычисляем общую стоимость и количество товаров
  let totalAmount = 0
  let totalItems = 0

  for (const item of args.input.items) {
    const product = await prisma.product.findUnique({
      where: { id: item.productId },
    })

    if (!product) {
      throw new GraphQLError(`Товар с ID ${item.productId} не найден`)
    }

    totalAmount += Number(product.price) * item.quantity
    totalItems += item.quantity
  }

  // Создаем заказ поставки
  const supplyOrder = await prisma.supplyOrder.create({
    data: {
      organizationId: currentUser.organization.id,
      partnerId: args.input.partnerId,
      fulfillmentCenterId: args.input.fulfillmentCenterId,
      logisticsPartnerId: args.input.logisticsPartnerId,
      deliveryDate: new Date(args.input.deliveryDate),
      totalAmount: totalAmount,
      totalItems: totalItems,
      status: 'PENDING', // Начальный статус

      // Создаем позиции заказа
      items: {
        create: args.input.items.map((item) => ({
          productId: item.productId,
          quantity: item.quantity,
          price: product.price,
          totalPrice: Number(product.price) * item.quantity,
          // Сохраняем рецептуру если есть
          services: item.recipe?.services || [],
          fulfillmentConsumables: item.recipe?.fulfillmentConsumables || [],
          sellerConsumables: item.recipe?.sellerConsumables || [],
          marketplaceCardId: item.recipe?.marketplaceCardId,
        })),
      },
    },
    include: {
      partner: true,
      items: true,
    },
  })

  return {
    success: true,
    message: 'Заказ поставки успешно создан',
    order: supplyOrder,
  }
}

📊 ПРАВИЛА ТИПОВ ДАННЫХ

1. ОСНОВНЫЕ СКАЛЯРЫ

scalar DateTime # ISO 8601 формат
scalar JSON # Гибкие данные (phones, emails, etc.)
# ✅ Использование
type Organization {
  createdAt: DateTime! # Всегда обязательно
  phones: JSON # Массив телефонов
  validationData: JSON # Данные валидации API
}

Реальная реализация custom скаляров:

// DateTime скаляр для работы с датами (из src/graphql/resolvers.ts)
const DateTimeScalar = new GraphQLScalarType({
  name: 'DateTime',
  description: 'DateTime custom scalar type',

  // Сериализация: Date → ISO string (для клиента)
  serialize(value: unknown) {
    if (value instanceof Date) {
      return value.toISOString() // 2025-08-21T15:30:00.000Z
    }
    return value
  },

  // Парсинг: ISO string → Date (от клиента)
  parseValue(value: unknown) {
    if (typeof value === 'string') {
      return new Date(value) // Парсим ISO строку в Date
    }
    throw new GraphQLError('Invalid DateTime format')
  },

  // Парсинг литералов в запросах
  parseLiteral(ast) {
    if (ast.kind === Kind.STRING) {
      return new Date(ast.value)
    }
    throw new GraphQLError('Invalid DateTime literal')
  },
})

// JSON скаляр для гибких данных
const JSONScalar = new GraphQLScalarType({
  name: 'JSON',
  description: 'JSON custom scalar type',

  serialize(value: unknown) {
    return value // JSON как есть
  },

  parseValue(value: unknown) {
    return value // Принимаем любые JSON данные
  },

  parseLiteral(ast) {
    switch (ast.kind) {
      case Kind.STRING:
      case Kind.BOOLEAN:
        return ast.value
      case Kind.INT:
      case Kind.FLOAT:
        return Number(ast.value)
      case Kind.OBJECT:
        // Рекурсивный парсинг объектов
        const obj: Record<string, unknown> = {}
        ast.fields.forEach((field) => {
          obj[field.name.value] = this.parseLiteral(field.value)
        })
        return obj
      case Kind.LIST:
        return ast.values.map((value) => this.parseLiteral(value))
      case Kind.NULL:
        return null
      default:
        throw new GraphQLError(`Unexpected kind: ${ast.kind}`)
    }
  },
})

2. СТРУКТУРА ОСНОВНЫХ ТИПОВ

User (Пользователь)

type User {
  id: ID!
  phone: String! # Уникальный идентификатор
  avatar: String # Аватар (опционально)
  managerName: String # Имя менеджера
  organization: Organization # Связь с организацией
  createdAt: DateTime!
  updatedAt: DateTime!
}

Organization (Организация)

type Organization {
  # Обязательные поля
  id: ID!
  inn: String!
  type: OrganizationType!

  # Реквизиты (могут быть пустыми)
  name: String
  fullName: String
  address: String

  # Связанные данные
  users: [User!]!
  apiKeys: [ApiKey!]!
  services: [Service!]!
  supplies: [Supply!]!

  # Партнерство
  isCounterparty: Boolean
  hasOutgoingRequest: Boolean
  hasIncomingRequest: Boolean

  # Реферальная система
  referralCode: String
  referralPoints: Int!

  # Marketplace данные
  market: String # Физический рынок (для WHOLESALE)
  # Временные метки
  createdAt: DateTime!
  updatedAt: DateTime!
}

🏪 СПЕЦИФИЧНЫЕ ПРАВИЛА ДЛЯ ПОСТАВЩИКОВ (WHOLESALE)

ЗАПРОСЫ ПОСТАВЩИКОВ:

# Получение товаров поставщика
query GetMyProducts {
  myProducts {
    id
    name
    article
    price
    quantity
    organization {
      id
      name
      market # Физический рынок поставщика
    }
  }
}

# Получение входящих заказов поставщика
query GetSupplierOrders($status: SupplyOrderStatus) {
  supplyOrders(where: { partnerId: $myOrgId, status: $status }) {
    id
    status
    totalAmount
    deliveryDate
    organization {
      name
      inn
    } # Заказчик
    fulfillmentCenter {
      name
      address
    } # Получатель
    items {
      id
      quantity
      price
      totalPrice
      product {
        id
        name
        article
      }
    }
  }
}

# Получение партнеров поставщика
query GetMyCounterparties($type: OrganizationType) {
  myCounterparties(type: $type) {
    id
    name
    type
    market
    fullName
    inn
    isCounterparty
    hasOutgoingRequest
    hasIncomingRequest
  }
}

МУТАЦИИ ПОСТАВЩИКОВ:

# Одобрение заказа поставщиком с дополнительными параметрами поставки
mutation SupplierApproveOrder(
  $orderId: ID!
  $packagesCount: Int
  $volume: Float
  $readyDate: DateTime
  $notes: String
) {
  supplierApproveOrder(
    id: $orderId
    packagesCount: $packagesCount # Параметр поставки: количество грузовых мест
    volume: $volume # Параметр поставки: объем груза
    readyDate: $readyDate # Параметр поставки: дата готовности к отгрузке
    notes: $notes # Параметр поставки: дополнительная информация
  ) {
    success
    message
    order {
      id
      status # PENDING → SUPPLIER_APPROVED
      deliveryDate # Основной параметр поставки
      totalAmount # Ключевой параметр поставки - общая стоимость
      totalItems # Параметр поставки - количество товаров
      organization {
        id
        name
      }
      packagesCount # Параметр поставки (опционально)
      volume # Параметр поставки (опционально)
      readyDate # null если не указано
      notes # null если не указано
    }
  }
}

# Отклонение заказа поставщиком
mutation SupplierRejectOrder($orderId: ID!, $reason: String) {
  supplierRejectOrder(id: $orderId, reason: $reason) {
    success
    message
    order {
      id
      status # PENDING → CANCELLED
    }
  }
}

# Отгрузка товара поставщиком
mutation SupplierShipOrder($orderId: ID!) {
  supplierShipOrder(id: $orderId) {
    success
    message
    order {
      id
      status # LOGISTICS_CONFIRMED → SHIPPED
      organization {
        id
        name
      }
      logisticsPartner {
        id
        name
      }
    }
  }
}

# Создание товара поставщиком
mutation CreateProduct($input: ProductInput!) {
  createProduct(input: $input) {
    success
    message
    product {
      id
      article
      name
      price
      organization {
        id
        name
      }
    }
  }
}

ПРАВИЛА АВТОРИЗАЦИИ ПОСТАВЩИКОВ:

// Resolver-level security для поставщиков
const wholesaleResolvers = {
  // Проверка что пользователь - поставщик
  validateWholesaleAccess: (context) => {
    if (context.user.organization.type !== 'WHOLESALE') {
      throw new GraphQLError('Access denied: Wholesale access required')
    }
  },

  // Фильтрация заказов для поставщика
  getSupplierOrders: async (parent, args, context) => {
    // Поставщик видит только заказы где он является поставщиком
    return await prisma.supplyOrder.findMany({
      where: {
        partnerId: context.user.organization.id, // Мы - поставщик
        ...args.where,
      },
    })
  },

  // Проверка доступа к товарам
  validateProductAccess: async (productId, context) => {
    const product = await prisma.product.findFirst({
      where: {
        id: productId,
        organizationId: context.user.organizationId, // Только свои товары
      },
    })

    if (!product) {
      throw new GraphQLError('Product not found or access denied')
    }
    return product
  },
}

КРИТИЧЕСКИЕ ПРАВИЛА ПАРТНЕРСТВА:

// ✅ ПРАВИЛЬНО: Поставщики берутся ТОЛЬКО из партнеров
const getWholesalePartners = `
  query GetMyCounterparties {
    myCounterparties(type: WHOLESALE) {
      id, name, fullName, inn, market
      isCounterparty  # Должно быть true
    }
  }
`;

// ❌ НЕПРАВИЛЬНО: Прямой запрос всех поставщиков
const wrongSupplierQuery = `
  query GetAllSuppliers {
    organizations(type: WHOLESALE) {  # Неправильно - нет проверки партнерства
      id, name
    }
  }
`;

// Правильная фильтрация в резолвере:
const correctPartnershipFilter = `
  // Показываем только организации-партнеры
  const counterparties = await prisma.counterparty.findMany({
    where: {
      initiatorId: currentUser.organization.id,
      status: 'ACCEPTED',
      partner: { type: 'WHOLESALE' }
    },
    include: { partner: true }
  })
`;

  # Временные метки (обязательно)
  createdAt: DateTime!
  updatedAt: DateTime!
}

🔐 ПРАВИЛА БЕЗОПАСНОСТИ В СХЕМЕ

1. КОНТРОЛЬ ДОСТУПА К ДАННЫМ

# ✅ Поля требующие проверки принадлежности
type Organization {
  apiKeys: [ApiKey!]! # Только владелец
  users: [User!]! # Только владелец
  # Публичная информация
  name: String
  type: OrganizationType!
}

2. ВАЛИДАЦИЯ ВХОДНЫХ ДАННЫХ

# ✅ Обязательные поля для критических операций
input SellerRegistrationInput {
  phone: String! # Обязательно
  wbApiKey: String # Опционально при регистрации
  referralCode: String # Опционально
}

input FulfillmentRegistrationInput {
  phone: String! # Обязательно
  inn: String! # Обязательно для бизнеса
  type: OrganizationType! # Обязательно
}

3. ЗАЩИТА ОТ РАСКРЫТИЯ ИНФОРМАЦИИ

# ✅ API ключи не возвращаются полностью
type ApiKey {
  id: ID!
  marketplace: MarketplaceType!
  isActive: Boolean!
  # apiKey: String - НЕ ВОЗВРАЩАЕТСЯ в queries
}

🎯 ПРАВИЛА РАСШИРЕНИЯ СХЕМЫ

1. ДОБАВЛЕНИЕ НОВЫХ ТИПОВ ОРГАНИЗАЦИЙ

# При добавлении нового типа в OrganizationType:
enum OrganizationType {
  FULFILLMENT
  SELLER
  LOGIST
  WHOLESALE
  # NEW_TYPE  # Добавлять в конец для совместимости
}

# Обязательно добавить:
# 1. Соответствующие queries для нового типа
# 2. Мutations регистрации
# 3. Права доступа в resolvers
# 4. UI компоненты

2. НОВЫЕ СТАТУСЫ В WORKFLOW

# При изменении workflow:
enum SupplyOrderStatus {
  # Существующие статусы НЕ УДАЛЯТЬ
  # Новые добавлять в конец
  # Обновлять правила переходов в resolvers
}

3. ВЕРСИОНИРОВАНИЕ API

# ✅ Добавление новых полей (обратно совместимо)
type Organization {
  # Существующие поля
  name: String

  # Новые поля (nullable для совместимости)
  newField: String
}

# ❌ Изменение существующих полей (ломает совместимость)
type Organization {
  # Было: name: String
  # Стало: name: String! - ЛОМАЕТ старые клиенты
}

📈 ПРАВИЛА ПРОИЗВОДИТЕЛЬНОСТИ

1. ИЗБЕГАТЬ N+1 ПРОБЛЕМ

# ✅ Использовать включения в одном запросе
type Organization {
  users: [User!]! # Загружается через include в Prisma
  services: [Service!]! # Загружается через include
}

# ❌ Отдельные запросы для связанных данных
query {
  organizations {
    id
  }
}
# Затем отдельно для каждой организации запрос users

2. ОГРАНИЧЕНИЯ НА МАССОВЫЕ ОПЕРАЦИИ

# ✅ Лимиты по умолчанию
messages(
  counterpartyId: ID!
  limit: Int = 50        # Разумный лимит по умолчанию
  offset: Int = 0
): [Message!]!

# ✅ Максимальные лимиты в resolvers
# limit: Math.min(args.limit || 50, 1000)

Извлечено из анализа: GraphQL typedefs, resolvers, patterns
Дата создания: 2025-08-21
Основано на коде: src/graphql/typedefs.ts, src/graphql/resolvers.ts