Files
sfera-new/docs/api-layer/GRAPHQL_SCHEMA_RULES.md
Veronika Smirnova 6e3201f491 feat: Phase 1 - Implementation of Data Security Infrastructure
Implemented comprehensive data security infrastructure for SFERA platform:

## Security Classes Created:
- `SupplyDataFilter`: Role-based data filtering for supply orders
- `ParticipantIsolation`: Data isolation between competing organizations
- `RecipeAccessControl`: Protection of production recipes and trade secrets
- `CommercialDataAudit`: Audit logging and suspicious activity detection
- `SecurityLogger`: Centralized security event logging system

## Infrastructure Components:
- Feature flags system for gradual security rollout
- Database migrations for audit logging (AuditLog, SecurityAlert models)
- Secure resolver wrapper for automatic GraphQL security
- TypeScript interfaces and type safety throughout

## Security Features:
- Role-based access control (SELLER, WHOLESALE, FULFILLMENT, LOGIST)
- Commercial data protection between competitors
- Production recipe confidentiality
- Audit trail for all data access
- Real-time security monitoring and alerts
- Rate limiting and suspicious activity detection

## Implementation Notes:
- All console logging replaced with centralized security logger
- Comprehensive TypeScript typing with no explicit 'any' types
- Modular architecture following SFERA coding standards
- Feature flag controlled rollout for safe deployment

This completes Phase 1 of the security implementation plan.
Next phases will integrate these classes into existing GraphQL resolvers.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 17:51:02 +03:00

927 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ПРАВИЛА GRAPHQL СХЕМЫ СИСТЕМЫ SFERA
## 🎯 ОБЩИЕ ПРИНЦИПЫ СХЕМЫ
### 1. ТИПОБЕЗОПАСНОСТЬ
- **Строгая типизация**: Все поля должны иметь четко определенный тип
- **Обязательные поля**: Использование `!` для критичных данных
- **Nullable поля**: Явное указание опциональности без `!`
### 2. КОНСИСТЕНТНОСТЬ ИМЕНОВАНИЯ
```typescript
// ✅ Правильное именование
type Organization {
id: ID! // Всегда ID! для идентификаторов
name: String // Nullable для опциональных данных
createdAt: DateTime! // Обязательные временные метки
}
// ❌ Неправильное именование
type organization { ... } // Должно быть PascalCase
type User {
user_id: String // Должно быть camelCase: userId
}
```
## 📋 ОСНОВНЫЕ ENUMS СИСТЕМЫ
### OrganizationType (Типы организаций)
```graphql
enum OrganizationType {
FULFILLMENT # Фулфилмент-центры
SELLER # Селлеры (продавцы)
LOGIST # Логистические компании
WHOLESALE # Поставщики (оптовики)
}
```
**Правила использования:**
- ✅ Обязательное поле в модели Organization
- ✅ Определяет доступные функции в UI
- ✅ Используется для фильтрации в поиске контрагентов
- ❌ Нельзя изменить тип существующей организации
### SupplyOrderStatus (Статусы поставок)
```graphql
enum SupplyOrderStatus {
PENDING # Ожидает одобрения поставщика
SUPPLIER_APPROVED # Поставщик одобрил, ждет логистику
LOGISTICS_CONFIRMED # Логистика подтвердила, ждет отправки
SHIPPED # Отправлено поставщиком
DELIVERED # Доставлено и принято
CANCELLED # Отменено любым участником
# Legacy статусы (обратная совместимость):
CONFIRMED # → SUPPLIER_APPROVED
IN_TRANSIT # → SHIPPED
}
```
**Правила переходов:**
- ✅ Только последовательные переходы
- ✅ Любой статус → CANCELLED
- ❌ Возврат к предыдущим статусам
- ❌ Пропуск промежуточных статусов
### CounterpartyRequestStatus (Статусы заявок на партнерство)
```graphql
enum CounterpartyRequestStatus {
PENDING # Отправлена, ждет ответа
ACCEPTED # Принята - партнерство активно
REJECTED # Отклонена
CANCELLED # Отменена отправителем
}
```
### MarketplaceType (Поддерживаемые маркетплейсы)
```graphql
enum MarketplaceType {
WILDBERRIES # WB API интеграция
OZON # Ozon API интеграция
}
```
## 🔍 ПРАВИЛА QUERY ОПЕРАЦИЙ
### 1. ПОИСК И ФИЛЬТРАЦИЯ
```graphql
# ✅ Правильная структура поиска
searchOrganizations(
type: OrganizationType # Фильтр по типу организации
search: String # Текстовый поиск по имени/ИНН
): [Organization!]!
# ✅ Пагинация для больших списков
messages(
counterpartyId: ID!
limit: Int # Лимит записей
offset: Int # Смещение
): [Message!]!
```
**Реальная реализация поиска организаций:**
```typescript
// Из 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
```graphql
# ✅ Доступ к своим данным
mySupplies: [Supply!]! # Только мои расходники
myServices: [Service!]! # Только мои услуги
myCounterparties: [Organization!]! # Только мои контрагенты
# ✅ Доступ к данным контрагентов (с проверкой партнерства)
counterpartyServices(organizationId: ID!): [Service!]!
organizationProducts(organizationId: ID!): [Product!]!
# ✅ Публичные данные (без ограничений)
allProducts: [Product!]!
categories: [Category!]!
```
### 3. АГРЕГИРОВАННЫЕ ДАННЫЕ
```graphql
# ✅ Счетчики для dashboard
type PendingSuppliesCount {
incomingSupplierOrders: Int! # Для поставщиков
logisticsOrders: Int! # Для логистики
ourSupplyOrders: Int! # Для фулфилмента
sellerSupplyOrders: Int! # Заказы от селлеров
}
# ✅ Иерархические данные
type WarehouseDataResponse {
partners: [WarehousePartner!]! # 3-уровневая структура
}
```
**Реальная реализация счетчиков (из pendingSuppliesCount):**
```typescript
// Динамические счетчики в зависимости от типа организации
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 ТИПОВ
```graphql
# ✅ Консистентное именование Input типов
input CreateEmployeeInput {
name: String!
position: String!
salary: Float
# Обязательные поля с !, опциональные без
}
input UpdateEmployeeInput {
name: String # В Update все поля опциональны
position: String
salary: Float
}
```
### 2. RESPONSE ТИПЫ ДЛЯ МУТАЦИЙ
```graphql
# ✅ Стандартная структура Response
type EmployeeResponse {
success: Boolean!
message: String
employee: Employee # Данные при успехе
errors: [String!] # Ошибки валидации
}
```
### 3. ПРАВИЛА АВТОРИЗАЦИИ В МУТАЦИЯХ
```graphql
# ✅ Мутации требующие авторизации
createSupplyOrder(input: SupplyOrderInput!): SupplyOrderResponse!
# ✅ Мутации для конкретных ролей
updateProductInWarehouse(...): Product! # Только FULFILLMENT
approveSupplyOrder(...): SupplyOrder! # Только WHOLESALE
# ✅ Административные мутации
adminLogin(username: String!, password: String!): AdminAuthResponse!
```
**Реальная реализация createSupplyOrder с валидацией:**
```typescript
// Из 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. ОСНОВНЫЕ СКАЛЯРЫ
```graphql
scalar DateTime # ISO 8601 формат
scalar JSON # Гибкие данные (phones, emails, etc.)
# ✅ Использование
type Organization {
createdAt: DateTime! # Всегда обязательно
phones: JSON # Массив телефонов
validationData: JSON # Данные валидации API
}
```
**Реальная реализация custom скаляров:**
```typescript
// 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 (Пользователь)
```graphql
type User {
id: ID!
phone: String! # Уникальный идентификатор
avatar: String # Аватар (опционально)
managerName: String # Имя менеджера
organization: Organization # Связь с организацией
createdAt: DateTime!
updatedAt: DateTime!
}
```
#### Organization (Организация)
```graphql
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)
### ЗАПРОСЫ ПОСТАВЩИКОВ:
```graphql
# Получение товаров поставщика
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
}
}
```
### МУТАЦИИ ПОСТАВЩИКОВ:
```graphql
# Одобрение заказа поставщиком с опциональными полями упаковки
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
organization {
id
name
}
totalAmount
packagesCount # null если не указано
volume # null если не указано
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
}
}
}
}
```
### ПРАВИЛА АВТОРИЗАЦИИ ПОСТАВЩИКОВ:
```typescript
// 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
},
}
```
### КРИТИЧЕСКИЕ ПРАВИЛА ПАРТНЕРСТВА:
```typescript
// ✅ ПРАВИЛЬНО: Поставщики берутся ТОЛЬКО из партнеров
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. КОНТРОЛЬ ДОСТУПА К ДАННЫМ
```graphql
# ✅ Поля требующие проверки принадлежности
type Organization {
apiKeys: [ApiKey!]! # Только владелец
users: [User!]! # Только владелец
# Публичная информация
name: String
type: OrganizationType!
}
```
### 2. ВАЛИДАЦИЯ ВХОДНЫХ ДАННЫХ
```graphql
# ✅ Обязательные поля для критических операций
input SellerRegistrationInput {
phone: String! # Обязательно
wbApiKey: String # Опционально при регистрации
referralCode: String # Опционально
}
input FulfillmentRegistrationInput {
phone: String! # Обязательно
inn: String! # Обязательно для бизнеса
type: OrganizationType! # Обязательно
}
```
### 3. ЗАЩИТА ОТ РАСКРЫТИЯ ИНФОРМАЦИИ
```graphql
# ✅ API ключи не возвращаются полностью
type ApiKey {
id: ID!
marketplace: MarketplaceType!
isActive: Boolean!
# apiKey: String - НЕ ВОЗВРАЩАЕТСЯ в queries
}
```
## 🎯 ПРАВИЛА РАСШИРЕНИЯ СХЕМЫ
### 1. ДОБАВЛЕНИЕ НОВЫХ ТИПОВ ОРГАНИЗАЦИЙ
```graphql
# При добавлении нового типа в OrganizationType:
enum OrganizationType {
FULFILLMENT
SELLER
LOGIST
WHOLESALE
# NEW_TYPE # Добавлять в конец для совместимости
}
# Обязательно добавить:
# 1. Соответствующие queries для нового типа
# 2. Мutations регистрации
# 3. Права доступа в resolvers
# 4. UI компоненты
```
### 2. НОВЫЕ СТАТУСЫ В WORKFLOW
```graphql
# При изменении workflow:
enum SupplyOrderStatus {
# Существующие статусы НЕ УДАЛЯТЬ
# Новые добавлять в конец
# Обновлять правила переходов в resolvers
}
```
### 3. ВЕРСИОНИРОВАНИЕ API
```graphql
# ✅ Добавление новых полей (обратно совместимо)
type Organization {
# Существующие поля
name: String
# Новые поля (nullable для совместимости)
newField: String
}
# ❌ Изменение существующих полей (ломает совместимость)
type Organization {
# Было: name: String
# Стало: name: String! - ЛОМАЕТ старые клиенты
}
```
## 📈 ПРАВИЛА ПРОИЗВОДИТЕЛЬНОСТИ
### 1. ИЗБЕГАТЬ N+1 ПРОБЛЕМ
```graphql
# ✅ Использовать включения в одном запросе
type Organization {
users: [User!]! # Загружается через include в Prisma
services: [Service!]! # Загружается через include
}
# ❌ Отдельные запросы для связанных данных
query {
organizations {
id
}
}
# Затем отдельно для каждой организации запрос users
```
### 2. ОГРАНИЧЕНИЯ НА МАССОВЫЕ ОПЕРАЦИИ
```graphql
# ✅ Лимиты по умолчанию
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_