import crypto from 'node:crypto'; import { consumeTemporaryLoginToken, createLoginChallenge, getStaticAuthCode, issueAccessToken, maskAuthDestination, verifyLoginChallengeCode, } from './auth.js'; import { MANAGER_ROLES, canManagerAccessUser, getManagedClientUserWhere, isManagerRole, } from './access.js'; import { sendLoginCodeEmail } from './mailer.js'; import { upsertActiveMessengerConnection } from './messenger-connections.js'; import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js'; import { dateTimeScalar, jsonScalar } from './scalars.js'; import { fetchTelegramConnectionProfile } from './telegram.js'; const ACTIVE_ORDER_STATUSES = ['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS']; function toFloat(value) { return value == null ? null : Number(value); } function roundMoney(value) { return Math.round((Number(value) + Number.EPSILON) * 100) / 100; } function requireUser(context) { if (!context.user) { throw new Error('Authentication required.'); } return context.user; } function requireAnyRole(context, roles) { const user = requireUser(context); if (!roles.includes(user.role)) { throw new Error(`Only ${roles.join(', ')} can perform this operation.`); } return user; } function requireManagerAccess(context) { return requireAnyRole(context, MANAGER_ROLES); } async function assertManagerCanAccessUser(prisma, manager, userId) { if (!await canManagerAccessUser(prisma, manager, userId)) { throw new Error('User is not available for this manager.'); } } function assertManagerCanAccessOrder(order) { if (!order) { throw new Error('Order was not found.'); } } async function appendOrderEvent(prisma, orderId, status, actorUserId, note = null) { return prisma.orderStatusEvent.create({ data: { orderId, status, actorUserId, note, }, }); } function formatPercent(value) { return Number(value).toFixed(2).replace(/\.?0+$/, ''); } async function createReferralBonusTransaction(prisma, order) { const referralLink = await prisma.referralLink.findFirst({ where: { refereeId: order.customerId, }, include: { referrer: { select: { fullName: true, }, }, referee: { select: { fullName: true, }, }, }, orderBy: { createdAt: 'desc' }, }); if (!referralLink) { return null; } const totalPrice = Number(order.totalPrice ?? 0); const bonusPercent = Number(referralLink.bonusPercent ?? 0); if (!Number.isFinite(totalPrice) || totalPrice <= 0 || !Number.isFinite(bonusPercent) || bonusPercent <= 0) { return null; } const amount = roundMoney((totalPrice * bonusPercent) / 100); if (amount <= 0) { return null; } const existingTransaction = await prisma.bonusTransaction.findFirst({ where: { orderId: order.id, referralLinkId: referralLink.id, }, }); if (existingTransaction) { return { transaction: existingTransaction, referralLink, isNew: false, }; } const transaction = await prisma.bonusTransaction.create({ data: { userId: referralLink.referrerId, amount, reason: `Бонус ${formatPercent(bonusPercent)}% за заказ ${order.code} клиента ${referralLink.referee.fullName}`, orderId: order.id, referralLinkId: referralLink.id, }, }); return { transaction, referralLink, isNew: true, }; } function orderCode() { return `FR-${Date.now()}-${crypto.randomInt(1000, 9999)}`; } function invitationToken() { return crypto.randomBytes(24).toString('hex'); } function buildDefaultFullName(email) { const localPart = email.split('@')[0]?.trim(); if (!localPart) { return 'Новый пользователь'; } return localPart .replace(/[._-]+/g, ' ') .split(' ') .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); } function normalizeText(value) { return String(value ?? '').trim(); } function normalizeOptionalText(value) { const normalized = normalizeText(value); return normalized ? normalized : null; } function normalizeQuantityValue(value) { const normalized = Number(value); if (!Number.isFinite(normalized) || normalized <= 0) { return 0; } return Math.floor(normalized); } function isCounterpartyProfileComplete(profile) { if (!profile) { return false; } return Boolean( normalizeText(profile.companyName) && normalizeText(profile.companyFullName) && normalizeText(profile.inn) && normalizeText(profile.legalAddress) && normalizeText(profile.bankName) && normalizeText(profile.bik) && normalizeText(profile.correspondentAccount) && normalizeText(profile.checkingAccount) && normalizeText(profile.signerFullName) && normalizeText(profile.signerPosition) && normalizeText(profile.signerBasis), ); } function toCounterpartyProfileInputData(input) { return { companyName: normalizeText(input.companyName), companyFullName: normalizeText(input.companyFullName), inn: normalizeText(input.inn), kpp: normalizeOptionalText(input.kpp), ogrn: normalizeOptionalText(input.ogrn), legalAddress: normalizeText(input.legalAddress), bankName: normalizeText(input.bankName), bik: normalizeText(input.bik), correspondentAccount: normalizeText(input.correspondentAccount), checkingAccount: normalizeText(input.checkingAccount), signerFullName: normalizeText(input.signerFullName), signerPosition: normalizeText(input.signerPosition), signerBasis: normalizeText(input.signerBasis), }; } function toDeliveryAddressInputData(input) { return { label: normalizeOptionalText(input.label), address: normalizeText(input.address), unrestrictedValue: normalizeOptionalText(input.unrestrictedValue), fiasId: normalizeOptionalText(input.fiasId), }; } function presentDeliveryAddress(address) { return address.unrestrictedValue || address.address; } function withDeliveryAddressDefaultFlag(address, defaultDeliveryAddressId) { return { ...address, isDefault: address.id === defaultDeliveryAddressId, }; } function defaultCartParameters(product) { return { width: product.widthMm ?? 100, thickness: product.thicknessMicron ?? 50, color: 'прозрачный', }; } const cartInclude = { items: { orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }], }, deliveryAddress: true, }; async function getOrCreateCart(context, userId) { const account = await context.prisma.user.findUnique({ where: { id: userId }, select: { defaultDeliveryAddressId: true }, }); return context.prisma.cart.upsert({ where: { userId }, update: {}, create: { userId, deliveryAddressId: account?.defaultDeliveryAddressId ?? null, }, include: cartInclude, }); } async function enrichMessengerConnectionProfile(prisma, connection) { if ( connection.type !== 'TELEGRAM' || (connection.displayName && connection.username && connection.avatarFileId) ) { return connection; } const profile = await fetchTelegramConnectionProfile(connection.channelId); return prisma.messengerConnection.update({ where: { id: connection.id }, data: { displayName: profile.displayName, username: profile.username, avatarFileId: profile.avatarFileId, avatarFileUniqueId: profile.avatarFileUniqueId, }, }); } async function resolveSelectedDeliveryAddress(context, userId, deliveryAddressId) { const normalizedAddressId = normalizeOptionalText(deliveryAddressId); if (normalizedAddressId) { const selected = await context.prisma.deliveryAddress.findFirst({ where: { id: normalizedAddressId, userId, }, }); if (!selected) { throw new Error('Delivery address is not available for this user.'); } return selected; } const user = await context.prisma.user.findUnique({ where: { id: userId }, select: { defaultDeliveryAddressId: true }, }); if (!user?.defaultDeliveryAddressId) { throw new Error('Delivery address is not selected. Add address in profile first.'); } const fallbackAddress = await context.prisma.deliveryAddress.findFirst({ where: { id: user.defaultDeliveryAddressId, userId, }, }); if (!fallbackAddress) { throw new Error('Default delivery address was not found. Select another one in profile.'); } return fallbackAddress; } async function requireCompletedCounterpartyProfile(context, userId) { const profile = await context.prisma.counterpartyProfile.findUnique({ where: { userId }, }); if (!isCounterpartyProfileComplete(profile)) { throw new Error('Counterparty profile is incomplete. Fill profile before placing an order.'); } } async function resolveOrderRequirements(context, user, deliveryAddressId) { await requireCompletedCounterpartyProfile(context, user.id); return resolveSelectedDeliveryAddress(context, user.id, deliveryAddressId); } function formatOrderStatusMessage(order, status, note) { const suffix = note ? `\nКомментарий: ${note}` : ''; return `Заказ ${order.code} изменил статус: ${status}.${suffix}`; } function buildFrontendAppUrl(path) { const baseUrl = String( process.env.TELEGRAM_MINI_APP_URL || process.env.WEB_FRONTEND_URL || process.env.NUXT_PUBLIC_SITE_URL || '', ).trim().replace(/\/$/, ''); const normalizedPath = String(path || '').trim(); if (!baseUrl || !normalizedPath.startsWith('/')) { return null; } return `${baseUrl}${normalizedPath}`; } function buildUserOrderPath(orderId, role) { const normalizedOrderId = String(orderId || '').trim(); if (!normalizedOrderId) { return ''; } return isManagerRole(role) ? `/client-orders/${normalizedOrderId}` : `/orders/${normalizedOrderId}`; } async function notifyOrderStakeholders(context, order, status, note) { const recipients = [order.customerId, order.managerId].filter(Boolean); if (!recipients.length) { return; } const message = formatOrderStatusMessage(order, status, note); const uniqueRecipients = [...new Set(recipients)]; const users = await context.prisma.user.findMany({ where: { id: { in: uniqueRecipients }, }, select: { id: true, role: true, }, }); const userRoleMap = new Map(users.map((user) => [user.id, user.role])); await Promise.allSettled( uniqueRecipients.map((userId) => dispatchToUserConnections(context.prisma, userId, message, { buttonUrl: buildFrontendAppUrl(buildUserOrderPath(order.id, userRoleMap.get(userId))), buttonText: 'Открыть заказ', })), ); } function byCreatedAtDesc(a, b) { return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); } async function collectNotificationHistory(context, userId, channel, limit) { const [events, bonuses, withdrawals] = await Promise.all([ context.prisma.orderStatusEvent.findMany({ where: { order: { OR: [{ customerId: userId }, { managerId: userId }], }, }, include: { order: { select: { id: true, code: true }, }, }, orderBy: { createdAt: 'desc' }, take: limit * 2, }), context.prisma.bonusTransaction.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, take: limit, }), context.prisma.rewardWithdrawalRequest.findMany({ where: { requesterId: userId, reviewedById: { not: null }, }, orderBy: { updatedAt: 'desc' }, take: limit, }), ]); const eventHistory = events.map((event) => ({ id: `ORDER_${event.id}_${channel}`, channel, title: `Статус заказа ${event.order.code}`, message: formatOrderStatusMessage(event.order, event.status, event.note), createdAt: event.createdAt, orderId: event.orderId, })); const bonusHistory = bonuses.map((bonus) => ({ id: `BONUS_${bonus.id}_${channel}`, channel, title: 'Реферальный бонус', message: `Начисление ${toFloat(bonus.amount)}. Причина: ${bonus.reason}`, createdAt: bonus.createdAt, orderId: bonus.orderId, })); const withdrawalHistory = withdrawals.map((withdrawal) => ({ id: `WITHDRAW_${withdrawal.id}_${channel}`, channel, title: 'Заявка на вывод вознаграждения', message: `Статус: ${withdrawal.status}.${withdrawal.reviewComment ? ` Комментарий: ${withdrawal.reviewComment}` : ''}`, createdAt: withdrawal.updatedAt, orderId: null, })); return [...eventHistory, ...bonusHistory, ...withdrawalHistory] .sort(byCreatedAtDesc) .slice(0, limit); } export const resolvers = { DateTime: dateTimeScalar, JSON: jsonScalar, CounterpartyProfile: { isComplete: (profile) => isCounterpartyProfileComplete(profile), }, MessengerConnection: { avatarAvailable: (connection) => Boolean(connection.avatarFileId), }, DeliveryAddress: { isDefault: (address) => Boolean(address.isDefault), }, Cart: { deliveryAddress: async (cart, _, context) => { if (!cart.deliveryAddressId) { return null; } const [account, address] = await Promise.all([ context.prisma.user.findUnique({ where: { id: cart.userId }, select: { defaultDeliveryAddressId: true }, }), context.prisma.deliveryAddress.findUnique({ where: { id: cart.deliveryAddressId }, }), ]); if (!address) { return null; } return withDeliveryAddressDefaultFlag(address, account?.defaultDeliveryAddressId ?? null); }, }, CartItem: { quantity: (item) => toFloat(item.quantity), }, Query: { healthcheck: () => 'ok', me: (_, __, context) => context.user, myCounterpartyProfile: async (_, __, context) => { const user = requireUser(context); return context.prisma.counterpartyProfile.findUnique({ where: { userId: user.id }, }); }, myCart: async (_, __, context) => { const user = requireUser(context); return getOrCreateCart(context, user.id); }, myDeliveryAddresses: async (_, __, context) => { const user = requireUser(context); const [account, addresses] = await Promise.all([ context.prisma.user.findUnique({ where: { id: user.id }, select: { defaultDeliveryAddressId: true }, }), context.prisma.deliveryAddress.findMany({ where: { userId: user.id }, orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }], }), ]); return addresses.map((address) => withDeliveryAddressDefaultFlag(address, account?.defaultDeliveryAddressId ?? null)); }, myMessengerConnections: async (_, __, context) => { const user = requireUser(context); const connections = await context.prisma.messengerConnection.findMany({ where: { userId: user.id }, orderBy: { createdAt: 'desc' }, }); return Promise.all( connections.map((connection) => enrichMessengerConnectionProfile(context.prisma, connection)), ); }, myNotificationHistory: async (_, { channel, limit }, context) => { const user = requireUser(context); const normalizedLimit = Math.min(Math.max(limit ?? 50, 1), 200); return collectNotificationHistory(context, user.id, channel, normalizedLimit); }, managerNotificationHistory: async (_, { userId, channel, limit }, context) => { const manager = requireManagerAccess(context); await assertManagerCanAccessUser(context.prisma, manager, userId); const normalizedLimit = Math.min(Math.max(limit ?? 50, 1), 200); return collectNotificationHistory(context, userId, channel, normalizedLimit); }, clientProducts: (_, __, context) => context.prisma.product.findMany({ where: { isActive: true }, include: { inventory: { include: { warehouse: true }, }, }, orderBy: { name: 'asc' }, }), order: async (_, { id }, context) => { const user = requireUser(context); const order = await context.prisma.order.findUnique({ where: { id }, include: { items: true, history: { orderBy: { createdAt: 'desc' } }, }, }); if (!order) { return null; } if (isManagerRole(user.role)) { if (!await canManagerAccessUser(context.prisma, user, order.customerId)) { return null; } return order; } return order.customerId === user.id ? order : null; }, myOrders: (_, __, context) => { const user = requireUser(context); return context.prisma.order.findMany({ where: { customerId: user.id }, include: { items: true, history: { orderBy: { createdAt: 'desc' } }, }, orderBy: { createdAt: 'desc' }, }); }, myCurrentOrders: (_, __, context) => { const user = requireUser(context); return context.prisma.order.findMany({ where: { customerId: user.id, status: { in: ACTIVE_ORDER_STATUSES }, }, include: { items: true, history: { orderBy: { createdAt: 'desc' } }, }, orderBy: { createdAt: 'desc' }, }); }, managerUsers: async (_, __, context) => { const manager = requireManagerAccess(context); const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager); const users = await context.prisma.user.findMany({ where: managedUsersWhere, include: { messengerConnections: { where: { type: 'TELEGRAM', isActive: true, }, orderBy: { createdAt: 'desc' }, take: 1, }, counterpartyProfile: { select: { companyName: true, inn: true, }, }, clientOrders: { select: { createdAt: true, }, orderBy: { createdAt: 'desc' }, take: 1, }, _count: { select: { clientOrders: true, }, }, }, orderBy: { createdAt: 'desc' }, }); return users.map((user) => ({ id: user.id, email: user.email, fullName: user.fullName, role: user.role, companyName: user.counterpartyProfile?.companyName ?? null, inn: user.counterpartyProfile?.inn ?? null, createdAt: user.createdAt, orderCount: user._count.clientOrders, lastOrderAt: user.clientOrders[0]?.createdAt ?? null, telegramConnection: user.messengerConnections[0] ?? null, })); }, managerOrders: async (_, { status, customerId }, context) => { requireManagerAccess(context); const normalizedCustomerId = normalizeOptionalText(customerId); return context.prisma.order.findMany({ where: { ...(normalizedCustomerId ? { customerId: normalizedCustomerId } : {}), ...(status ? { status } : {}), }, include: { items: true, history: { orderBy: { createdAt: 'desc' } }, }, orderBy: { createdAt: 'desc' }, }); }, managerReferralLinks: async (_, __, context) => { requireManagerAccess(context); const links = await context.prisma.referralLink.findMany({ include: { referrer: { include: { counterpartyProfile: { select: { companyName: true, }, }, }, }, referee: { include: { counterpartyProfile: { select: { companyName: true, }, }, }, }, }, orderBy: { createdAt: 'desc' }, }); return links.map((link) => ({ id: link.id, referrerId: link.referrerId, referrerName: link.referrer.fullName, referrerEmail: link.referrer.email, referrerCompanyName: link.referrer.counterpartyProfile?.companyName ?? null, refereeId: link.refereeId, refereeName: link.referee.fullName, refereeEmail: link.referee.email, refereeCompanyName: link.referee.counterpartyProfile?.companyName ?? null, createdById: link.createdById, bonusPercent: Number(link.bonusPercent), createdAt: link.createdAt, })); }, managerBonusBalances: async (_, __, context) => { const manager = requireManagerAccess(context); const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager); const [users, transactionsAgg, pendingWithdrawalsAgg] = await Promise.all([ context.prisma.user.findMany({ where: managedUsersWhere, include: { counterpartyProfile: { select: { companyName: true, }, }, }, orderBy: { createdAt: 'desc' }, }), context.prisma.bonusTransaction.groupBy({ by: ['userId'], _sum: { amount: true }, _count: { _all: true }, }), context.prisma.rewardWithdrawalRequest.groupBy({ by: ['requesterId'], where: { status: 'PENDING' }, _sum: { amount: true }, }), ]); const txMap = new Map(transactionsAgg.map((item) => [ item.userId, { balance: Number(item._sum.amount ?? 0), transactionsCount: item._count._all, }, ])); const pendingMap = new Map(pendingWithdrawalsAgg.map((item) => [ item.requesterId, Number(item._sum.amount ?? 0), ])); return users.map((user) => { const tx = txMap.get(user.id); const pendingWithdrawalAmount = pendingMap.get(user.id) ?? 0; return { userId: user.id, email: user.email, fullName: user.fullName, companyName: user.counterpartyProfile?.companyName ?? null, balance: (tx?.balance ?? 0) - pendingWithdrawalAmount, pendingWithdrawalAmount, transactionsCount: tx?.transactionsCount ?? 0, }; }); }, managerWithdrawalRequests: async (_, { status }, context) => { const manager = requireManagerAccess(context); const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager); const users = await context.prisma.user.findMany({ where: managedUsersWhere, select: { id: true, email: true, fullName: true, counterpartyProfile: { select: { companyName: true, }, }, }, }); if (!users.length) { return []; } const userMap = new Map(users.map((user) => [user.id, user])); const withdrawals = await context.prisma.rewardWithdrawalRequest.findMany({ where: { requesterId: { in: [...userMap.keys()] }, ...(status ? { status } : {}), }, orderBy: { createdAt: 'desc' }, }); return withdrawals.map((withdrawal) => { const requester = userMap.get(withdrawal.requesterId); return { id: withdrawal.id, requesterId: withdrawal.requesterId, requesterEmail: requester?.email ?? 'unknown@fregat.local', requesterFullName: requester?.fullName ?? 'Неизвестный пользователь', companyName: requester?.counterpartyProfile?.companyName ?? null, amount: Number(withdrawal.amount), status: withdrawal.status, reviewedById: withdrawal.reviewedById, reviewComment: withdrawal.reviewComment, createdAt: withdrawal.createdAt, updatedAt: withdrawal.updatedAt, }; }); }, registrationRequests: (_, { status }, context) => { requireManagerAccess(context); return context.prisma.registrationRequest.findMany({ where: status ? { status } : undefined, orderBy: { createdAt: 'desc' }, }); }, referralStats: async (_, __, context) => { const user = requireUser(context); const [links, transactions, pendingWithdrawals] = await Promise.all([ context.prisma.referralLink.count({ where: { referrerId: user.id } }), context.prisma.bonusTransaction.findMany({ where: { userId: user.id }, orderBy: { createdAt: 'desc' }, }), context.prisma.rewardWithdrawalRequest.findMany({ where: { requesterId: user.id, status: 'PENDING', }, orderBy: { createdAt: 'desc' }, }), ]); const txSum = transactions.reduce((acc, tx) => acc + Number(tx.amount), 0); const pendingSum = pendingWithdrawals.reduce((acc, tx) => acc + Number(tx.amount), 0); return { referrerId: user.id, availableBalance: txSum - pendingSum, referralsCount: links, transactions, pendingWithdrawals, }; }, }, Mutation: { requestLoginCode: async (_, { input }, context) => { if (input.channel !== 'EMAIL') { throw new Error('Code login is supported only for EMAIL channel.'); } const destination = input.destination.trim().toLowerCase(); if (!destination) { throw new Error('Destination is required.'); } const user = await context.prisma.user.findFirst({ where: { email: { equals: destination, mode: 'insensitive', }, }, }); const challenge = createLoginChallenge({ userId: user?.id ?? null, channel: input.channel, destination, }); const code = getStaticAuthCode(); await sendLoginCodeEmail({ to: destination, code, expiresAt: challenge.expiresAt, }); return { challengeToken: challenge.challengeToken, channel: input.channel, destination: maskAuthDestination(input.channel, destination), expiresAt: challenge.expiresAt, }; }, verifyLoginCode: async (_, { input }, context) => { const challenge = verifyLoginChallengeCode({ challengeToken: input.challengeToken, code: input.code, }); let user = challenge.userId ? await context.prisma.user.findUnique({ where: { id: challenge.userId }, }) : null; if (!user && challenge.channel === 'EMAIL') { const email = String(challenge.destination).trim().toLowerCase(); user = await context.prisma.user.upsert({ where: { email }, update: {}, create: { email, fullName: buildDefaultFullName(email), role: 'CLIENT', }, }); } if (!user) { throw new Error('User is not available for this login challenge.'); } const session = issueAccessToken(user.id); return { accessToken: session.accessToken, expiresAt: session.expiresAt, user, }; }, consumeLoginToken: async (_, { token }, context) => { const login = consumeTemporaryLoginToken(token); const user = await context.prisma.user.findUnique({ where: { id: login.userId }, }); if (!user) { throw new Error('User for this login token was not found.'); } if (login.messengerConnection) { await upsertActiveMessengerConnection(context.prisma, { userId: user.id, type: login.messengerConnection.type, channelId: login.messengerConnection.channelId, profile: login.messengerConnection, }); } const session = issueAccessToken(user.id); return { accessToken: session.accessToken, expiresAt: session.expiresAt, user, }; }, registerSelf: (_, { input }, context) => context.prisma.registrationRequest.create({ data: { companyName: input.companyName, inn: input.inn, contactName: input.contactName, email: input.email, status: 'PENDING', }, }), reviewRegistrationRequest: async (_, { input }, context) => { const manager = requireManagerAccess(context); const request = await context.prisma.registrationRequest.findUnique({ where: { id: input.requestId }, }); if (!request) { throw new Error('Registration request was not found.'); } return context.prisma.registrationRequest.update({ where: { id: input.requestId }, data: { status: input.decision === 'APPROVE' ? 'APPROVED' : 'REJECTED', rejectionReason: input.decision === 'REJECT' ? input.rejectionReason ?? 'Rejected by manager' : null, reviewedById: manager.id, }, }); }, createInvitation: async (_, { input }, context) => { const manager = requireManagerAccess(context); const expiresInDays = input.expiresInDays > 0 ? input.expiresInDays : 7; const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000); return context.prisma.invitation.create({ data: { token: invitationToken(), email: input.email, companyName: input.companyName, managerId: manager.id, expiresAt, }, }); }, acceptInvitation: async (_, { input }, context) => { const invitation = await context.prisma.invitation.findUnique({ where: { token: input.token } }); if (!invitation) { throw new Error('Invitation token is invalid.'); } if (invitation.acceptedAt) { throw new Error('Invitation has already been used.'); } if (invitation.expiresAt < new Date()) { throw new Error('Invitation has expired.'); } const company = await context.prisma.company.upsert({ where: { name: invitation.companyName }, update: {}, create: { name: invitation.companyName }, }); const user = await context.prisma.user.create({ data: { email: invitation.email, fullName: input.fullName, role: 'CLIENT', companyId: company.id, }, }); await context.prisma.invitation.update({ where: { id: invitation.id }, data: { acceptedById: user.id, acceptedAt: new Date(), }, }); return user; }, upsertMyCounterpartyProfile: async (_, { input }, context) => { const user = requireUser(context); const payload = toCounterpartyProfileInputData(input); if (!isCounterpartyProfileComplete(payload)) { throw new Error('Counterparty profile is incomplete. Fill all required fields.'); } return context.prisma.counterpartyProfile.upsert({ where: { userId: user.id }, update: payload, create: { userId: user.id, ...payload, }, }); }, addProductToCart: async (_, { productId }, context) => { const user = requireUser(context); const normalizedProductId = normalizeText(productId); if (!normalizedProductId) { throw new Error('Product id is required.'); } const product = await context.prisma.product.findFirst({ where: { id: normalizedProductId, isActive: true, }, }); if (!product) { throw new Error('Product is not available.'); } const cart = await getOrCreateCart(context, user.id); const existingItem = await context.prisma.cartItem.findFirst({ where: { cartId: cart.id, productId: normalizedProductId, }, }); if (existingItem) { await context.prisma.cartItem.update({ where: { id: existingItem.id }, data: { quantity: Number(existingItem.quantity) + 1, productName: product.name, sku: product.sku, isCustomizable: product.isCustomizable, }, }); } else { await context.prisma.cartItem.create({ data: { cartId: cart.id, productId: product.id, productName: product.name, sku: product.sku, isCustomizable: product.isCustomizable, quantity: 1, parameters: defaultCartParameters(product), }, }); } return context.prisma.cart.findUnique({ where: { id: cart.id }, include: cartInclude, }); }, updateCartItemQuantity: async (_, { input }, context) => { const user = requireUser(context); const normalizedProductId = normalizeText(input.productId); if (!normalizedProductId) { throw new Error('Product id is required.'); } const quantity = normalizeQuantityValue(input.quantity); const cart = await getOrCreateCart(context, user.id); const existingItem = await context.prisma.cartItem.findFirst({ where: { cartId: cart.id, productId: normalizedProductId, }, }); if (!existingItem) { return cart; } if (quantity === 0) { await context.prisma.cartItem.delete({ where: { id: existingItem.id }, }); } else { await context.prisma.cartItem.update({ where: { id: existingItem.id }, data: { quantity }, }); } return context.prisma.cart.findUnique({ where: { id: cart.id }, include: cartInclude, }); }, removeCartItem: async (_, { productId }, context) => { const user = requireUser(context); const normalizedProductId = normalizeText(productId); if (!normalizedProductId) { throw new Error('Product id is required.'); } const cart = await getOrCreateCart(context, user.id); const existingItem = await context.prisma.cartItem.findFirst({ where: { cartId: cart.id, productId: normalizedProductId, }, }); if (existingItem) { await context.prisma.cartItem.delete({ where: { id: existingItem.id }, }); } return context.prisma.cart.findUnique({ where: { id: cart.id }, include: cartInclude, }); }, setCartDeliveryAddress: async (_, { addressId }, context) => { const user = requireUser(context); const cart = await getOrCreateCart(context, user.id); const normalizedAddressId = normalizeOptionalText(addressId); if (!normalizedAddressId) { return context.prisma.cart.update({ where: { id: cart.id }, data: { deliveryAddressId: null }, include: cartInclude, }); } const address = await context.prisma.deliveryAddress.findFirst({ where: { id: normalizedAddressId, userId: user.id, }, }); if (!address) { throw new Error('Delivery address is not available for this user.'); } return context.prisma.cart.update({ where: { id: cart.id }, data: { deliveryAddressId: address.id }, include: cartInclude, }); }, clearCart: async (_, __, context) => { const user = requireUser(context); const cart = await getOrCreateCart(context, user.id); await context.prisma.cartItem.deleteMany({ where: { cartId: cart.id }, }); return context.prisma.cart.findUnique({ where: { id: cart.id }, include: cartInclude, }); }, createMyDeliveryAddress: async (_, { input }, context) => { const user = requireUser(context); const payload = toDeliveryAddressInputData(input); if (!payload.address) { throw new Error('Delivery address is required.'); } const created = await context.prisma.$transaction(async (tx) => { const account = await tx.user.findUnique({ where: { id: user.id }, select: { defaultDeliveryAddressId: true }, }); const address = await tx.deliveryAddress.create({ data: { userId: user.id, ...payload, }, }); if (!account?.defaultDeliveryAddressId) { await tx.user.update({ where: { id: user.id }, data: { defaultDeliveryAddressId: address.id }, }); return withDeliveryAddressDefaultFlag(address, address.id); } return withDeliveryAddressDefaultFlag(address, account.defaultDeliveryAddressId); }); return created; }, setMyDefaultDeliveryAddress: async (_, { addressId }, context) => { const user = requireUser(context); const normalizedAddressId = normalizeText(addressId); if (!normalizedAddressId) { throw new Error('Delivery address id is required.'); } const address = await context.prisma.deliveryAddress.findFirst({ where: { id: normalizedAddressId, userId: user.id, }, }); if (!address) { throw new Error('Delivery address is not available for this user.'); } await context.prisma.user.update({ where: { id: user.id }, data: { defaultDeliveryAddressId: address.id, }, }); return withDeliveryAddressDefaultFlag(address, address.id); }, deleteMyDeliveryAddress: async (_, { addressId }, context) => { const user = requireUser(context); const normalizedAddressId = normalizeText(addressId); if (!normalizedAddressId) { throw new Error('Delivery address id is required.'); } const address = await context.prisma.deliveryAddress.findFirst({ where: { id: normalizedAddressId, userId: user.id, }, }); if (!address) { throw new Error('Delivery address is not available for this user.'); } await context.prisma.$transaction(async (tx) => { const account = await tx.user.findUnique({ where: { id: user.id }, select: { defaultDeliveryAddressId: true }, }); await tx.deliveryAddress.delete({ where: { id: address.id }, }); if (account?.defaultDeliveryAddressId !== address.id) { return; } const nextDefault = await tx.deliveryAddress.findFirst({ where: { userId: user.id }, orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }], }); await tx.user.update({ where: { id: user.id }, data: { defaultDeliveryAddressId: nextDefault?.id ?? null, }, }); }); return true; }, connectMessenger: (_, { input }, context) => { const user = requireUser(context); return context.prisma.messengerConnection.upsert({ where: { userId_type_channelId: { userId: user.id, type: input.type, channelId: input.channelId, }, }, update: { isActive: true }, create: { userId: user.id, type: input.type, channelId: input.channelId, }, }); }, sendTestMessengerMessage: async (_, { type, channelId, message }, context) => { const user = requireUser(context); let targetChannelId = channelId; if (!targetChannelId) { const connection = await context.prisma.messengerConnection.findFirst({ where: { userId: user.id, type, isActive: true, }, orderBy: { createdAt: 'desc' }, }); targetChannelId = connection?.channelId ?? null; } if (!targetChannelId) { throw new Error(`No active ${type} channel is connected for this user.`); } const dispatch = await sendMessengerMessage({ type, channelId: targetChannelId, message: message ?? `Тестовое уведомление Fregat (${type})`, }); return { type, channelId: targetChannelId, success: dispatch.success, detail: dispatch.detail, sentAt: new Date(), }; }, submitReadyOrder: async (_, { input }, context) => { const customer = requireUser(context); if (!input.items.length) { throw new Error('Order must contain at least one item.'); } const selectedAddress = await resolveOrderRequirements(context, customer, input.deliveryAddressId); const productIds = input.items.map((item) => item.productId); const products = await context.prisma.product.findMany({ where: { id: { in: productIds }, isActive: true }, }); if (products.length !== input.items.length) { throw new Error('Some products are invalid or inactive.'); } const productMap = new Map(products.map((product) => [product.id, product])); const order = await context.prisma.order.create({ data: { code: orderCode(), kind: 'READY', customerId: customer.id, managerId: isManagerRole(customer.role) ? customer.id : null, deliveryAddressId: selectedAddress.id, deliveryAddress: presentDeliveryAddress(selectedAddress), status: 'NEW', items: { create: input.items.map((item) => { const product = productMap.get(item.productId); return { productId: item.productId, productName: product.name, quantity: item.quantity, }; }), }, }, include: { items: true, history: true, }, }); await appendOrderEvent(context.prisma, order.id, 'NEW', customer.id, 'Ready order created by client'); await notifyOrderStakeholders(context, order, 'NEW', 'Ready order created by client'); return context.prisma.order.findUnique({ where: { id: order.id }, include: { items: true, history: { orderBy: { createdAt: 'desc' } } }, }); }, submitCalculationOrder: async (_, { input }, context) => { const customer = requireUser(context); const selectedAddress = await resolveOrderRequirements(context, customer, input.deliveryAddressId); const order = await context.prisma.order.create({ data: { code: orderCode(), kind: 'CALCULATION', customerId: customer.id, managerId: isManagerRole(customer.role) ? customer.id : null, deliveryAddressId: selectedAddress.id, deliveryAddress: presentDeliveryAddress(selectedAddress), status: 'NEW', calculationPayload: input.parameters, items: { create: [ { productName: input.productName, quantity: input.quantity, }, ], }, }, include: { items: true, history: true, }, }); await appendOrderEvent(context.prisma, order.id, 'NEW', customer.id, 'Calculation request created by client'); await notifyOrderStakeholders(context, order, 'NEW', 'Calculation request created by client'); return context.prisma.order.findUnique({ where: { id: order.id }, include: { items: true, history: { orderBy: { createdAt: 'desc' } } }, }); }, managerSetOrderOffer: async (_, { input }, context) => { const manager = requireManagerAccess(context); const existingOrder = await context.prisma.order.findUnique({ where: { id: input.orderId }, include: { items: true, }, }); assertManagerCanAccessOrder(existingOrder); if (!existingOrder.items.length) { throw new Error('Order has no items to price.'); } const deliveryFee = Number(input.deliveryFee); if (!Number.isFinite(deliveryFee) || deliveryFee < 0) { throw new Error('Delivery fee must be zero or greater.'); } const orderItemIds = new Set(existingOrder.items.map((item) => item.id)); const itemPriceMap = new Map(); for (const itemPrice of input.itemPrices) { if (itemPriceMap.has(itemPrice.itemId)) { throw new Error('Duplicate item pricing entries are not allowed.'); } if (!orderItemIds.has(itemPrice.itemId)) { throw new Error('Pricing can only be set for items from this order.'); } const unitPrice = Number(itemPrice.unitPrice); if (!Number.isFinite(unitPrice) || unitPrice < 0) { throw new Error('Unit price must be zero or greater.'); } itemPriceMap.set(itemPrice.itemId, roundMoney(unitPrice)); } if (itemPriceMap.size !== existingOrder.items.length) { throw new Error('Pricing must be provided for every order item.'); } const totalProductsPrice = existingOrder.items.reduce( (sum, item) => sum + (Number(item.quantity) * itemPriceMap.get(item.id)), 0, ); const totalPrice = roundMoney(totalProductsPrice + deliveryFee); const order = await context.prisma.$transaction(async (tx) => { for (const item of existingOrder.items) { await tx.orderItem.update({ where: { id: item.id }, data: { unitPrice: itemPriceMap.get(item.id), }, }); } return tx.order.update({ where: { id: input.orderId }, data: { managerId: manager.id, status: 'WAITING_DOUBLE_CONFIRM', clientApproved: null, managerApproved: null, blockReason: null, deliveryTerms: normalizeOptionalText(input.deliveryTerms), deliveryFee: roundMoney(deliveryFee), totalPrice, }, }); }); await appendOrderEvent(context.prisma, order.id, 'WAITING_DOUBLE_CONFIRM', manager.id, 'Offer is published by manager'); await notifyOrderStakeholders(context, order, 'WAITING_DOUBLE_CONFIRM', 'Offer is published by manager'); return context.prisma.order.findUnique({ where: { id: order.id }, include: { items: true, history: { orderBy: { createdAt: 'desc' } } }, }); }, clientReviewOrder: async (_, { orderId, decision }, context) => { const customer = requireUser(context); const order = await context.prisma.order.findUnique({ where: { id: orderId } }); if (!order || order.customerId !== customer.id) { throw new Error('Order is not available for this client.'); } const status = decision === 'REJECT' ? 'CLIENT_REJECTED' : order.managerApproved ? 'CONFIRMED' : 'WAITING_DOUBLE_CONFIRM'; const updated = await context.prisma.order.update({ where: { id: orderId }, data: { clientApproved: decision === 'APPROVE', status, }, }); await appendOrderEvent( context.prisma, updated.id, status, customer.id, decision === 'APPROVE' ? 'Client approved offer' : 'Client rejected offer', ); await notifyOrderStakeholders( context, updated, status, decision === 'APPROVE' ? 'Client approved offer' : 'Client rejected offer', ); return context.prisma.order.findUnique({ where: { id: updated.id }, include: { items: true, history: { orderBy: { createdAt: 'desc' } } }, }); }, managerFinalizeOrder: async (_, { orderId, decision }, context) => { const manager = requireManagerAccess(context); const order = await context.prisma.order.findUnique({ where: { id: orderId } }); assertManagerCanAccessOrder(order); const status = decision === 'REJECT' ? 'MANAGER_REJECTED' : order.clientApproved ? 'CONFIRMED' : 'WAITING_DOUBLE_CONFIRM'; const updated = await context.prisma.order.update({ where: { id: orderId }, data: { managerId: manager.id, managerApproved: decision === 'APPROVE', status, }, }); await appendOrderEvent( context.prisma, updated.id, status, manager.id, decision === 'APPROVE' ? 'Manager approved order' : 'Manager rejected order', ); await notifyOrderStakeholders( context, updated, status, decision === 'APPROVE' ? 'Manager approved order' : 'Manager rejected order', ); return context.prisma.order.findUnique({ where: { id: updated.id }, include: { items: true, history: { orderBy: { createdAt: 'desc' } } }, }); }, blockOrder: async (_, { input }, context) => { const manager = requireManagerAccess(context); const order = await context.prisma.order.findUnique({ where: { id: input.orderId }, }); assertManagerCanAccessOrder(order); const updated = await context.prisma.order.update({ where: { id: input.orderId }, data: { managerId: manager.id, status: 'MANAGER_BLOCKED', blockReason: input.reason, }, }); await appendOrderEvent(context.prisma, updated.id, 'MANAGER_BLOCKED', manager.id, input.reason); await notifyOrderStakeholders(context, updated, 'MANAGER_BLOCKED', input.reason); return context.prisma.order.findUnique({ where: { id: updated.id }, include: { items: true, history: { orderBy: { createdAt: 'desc' } } }, }); }, startOrderWork: async (_, { orderId }, context) => { const manager = requireManagerAccess(context); const order = await context.prisma.order.findUnique({ where: { id: orderId } }); assertManagerCanAccessOrder(order); if (!['WAITING_DOUBLE_CONFIRM', 'CONFIRMED'].includes(order.status)) { throw new Error('Only priced order can be started.'); } const updated = await context.prisma.order.update({ where: { id: orderId }, data: { managerId: manager.id, status: 'IN_PROGRESS', }, }); await appendOrderEvent(context.prisma, updated.id, 'IN_PROGRESS', manager.id, 'Order moved to in-progress'); await notifyOrderStakeholders(context, updated, 'IN_PROGRESS', 'Order moved to in-progress'); return context.prisma.order.findUnique({ where: { id: updated.id }, include: { items: true, history: { orderBy: { createdAt: 'desc' } } }, }); }, completeOrder: async (_, { orderId }, context) => { const manager = requireManagerAccess(context); const order = await context.prisma.order.findUnique({ where: { id: orderId } }); assertManagerCanAccessOrder(order); if (order.status !== 'IN_PROGRESS') { throw new Error('Only in-progress order can be completed.'); } const { updated, referralBonus } = await context.prisma.$transaction(async (tx) => { const updatedOrder = await tx.order.update({ where: { id: orderId }, data: { managerId: manager.id, status: 'COMPLETED', }, }); const createdReferralBonus = await createReferralBonusTransaction(tx, updatedOrder); return { updated: updatedOrder, referralBonus: createdReferralBonus, }; }); await appendOrderEvent(context.prisma, updated.id, 'COMPLETED', manager.id, 'Order completed'); await notifyOrderStakeholders(context, updated, 'COMPLETED', 'Order completed'); if (referralBonus?.isNew) { await dispatchToUserConnections( context.prisma, referralBonus.transaction.userId, `Начислен бонус: ${toFloat(referralBonus.transaction.amount)} за заказ ${updated.code}.`, ); } return context.prisma.order.findUnique({ where: { id: updated.id }, include: { items: true, history: { orderBy: { createdAt: 'desc' } } }, }); }, createReferral: async (_, { input }, context) => { const manager = requireManagerAccess(context); const referrerUserId = normalizeText(input.referrerUserId); const refereeUserId = normalizeText(input.refereeUserId); const bonusPercent = roundMoney(input.bonusPercent); if (!referrerUserId || !refereeUserId) { throw new Error('Both linked clients are required.'); } if (referrerUserId === refereeUserId) { throw new Error('A client cannot be linked to themselves.'); } if (!Number.isFinite(bonusPercent) || bonusPercent <= 0 || bonusPercent > 100) { throw new Error('Bonus percent must be greater than 0 and no more than 100.'); } await Promise.all([ assertManagerCanAccessUser(context.prisma, manager, referrerUserId), assertManagerCanAccessUser(context.prisma, manager, refereeUserId), ]); const users = await context.prisma.user.findMany({ where: { id: { in: [referrerUserId, refereeUserId] }, }, select: { id: true, role: true, }, }); if (users.length !== 2) { throw new Error('Both clients must exist.'); } if (users.some((user) => user.role !== 'CLIENT')) { throw new Error('Referral links can only be created between client accounts.'); } const existingRefereeLink = await context.prisma.referralLink.findFirst({ where: { refereeId: refereeUserId, }, }); if (existingRefereeLink) { if (existingRefereeLink.referrerId === referrerUserId) { throw new Error('These clients are already linked.'); } throw new Error('The selected client is already linked to another bonus program participant.'); } return context.prisma.referralLink.create({ data: { referrerId: referrerUserId, refereeId: refereeUserId, createdById: manager.id, bonusPercent, }, }); }, addBonusTransaction: async (_, { input }, context) => { const manager = requireManagerAccess(context); await assertManagerCanAccessUser(context.prisma, manager, input.userId); const transaction = await context.prisma.bonusTransaction.create({ data: { userId: input.userId, amount: input.amount, reason: input.reason, orderId: input.orderId, }, }); await dispatchToUserConnections( context.prisma, transaction.userId, `Начислен бонус: ${toFloat(transaction.amount)}. Причина: ${transaction.reason}`, ); return transaction; }, requestRewardWithdrawal: (_, { input }, context) => { const client = requireUser(context); if (input.amount < 100) { throw new Error('Minimum withdrawal amount is 100.'); } return context.prisma.rewardWithdrawalRequest.create({ data: { requesterId: client.id, amount: input.amount, }, }); }, reviewRewardWithdrawal: async (_, { input }, context) => { const manager = requireManagerAccess(context); const existingWithdrawal = await context.prisma.rewardWithdrawalRequest.findUnique({ where: { id: input.withdrawalId }, }); if (!existingWithdrawal) { throw new Error('Withdrawal request was not found.'); } await assertManagerCanAccessUser(context.prisma, manager, existingWithdrawal.requesterId); const withdrawal = await context.prisma.rewardWithdrawalRequest.update({ where: { id: input.withdrawalId }, data: { reviewedById: manager.id, status: input.decision === 'APPROVE' ? 'APPROVED' : 'REJECTED', reviewComment: input.reviewComment, }, }); await dispatchToUserConnections( context.prisma, withdrawal.requesterId, `Заявка на вывод вознаграждения обновлена: ${withdrawal.status}.${withdrawal.reviewComment ? ` Комментарий: ${withdrawal.reviewComment}` : ''}`, ); return withdrawal; }, }, Product: { availableInWarehouses: (product) => product.inventory.map((stock) => ({ warehouse: stock.warehouse, availableQty: toFloat(stock.availableQty), })), }, Order: { deliveryFee: (order) => toFloat(order.deliveryFee), totalPrice: (order) => toFloat(order.totalPrice), }, OrderItem: { quantity: (item) => toFloat(item.quantity), unitPrice: (item) => toFloat(item.unitPrice), lineTotal: (item) => ( item.unitPrice == null ? null : roundMoney(Number(item.quantity) * Number(item.unitPrice)) ), }, BonusTransaction: { amount: (tx) => toFloat(tx.amount), }, ReferralLink: { bonusPercent: (link) => toFloat(link.bonusPercent), }, RewardWithdrawalRequest: { amount: (tx) => toFloat(tx.amount), }, User: { company: (user, _, context) => { if (user.company) { return user.company; } if (!user.companyId) { return null; } return context.prisma.company.findUnique({ where: { id: user.companyId }, }); }, }, };