import crypto from 'node:crypto'; import { consumeTemporaryLoginToken, createLoginChallenge, getStaticAuthCode, issueAccessToken, maskAuthDestination, verifyLoginChallengeCode, } from './auth.js'; import { sendLoginCodeEmail } from './mailer.js'; import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js'; import { dateTimeScalar, jsonScalar } from './scalars.js'; const ACTIVE_ORDER_STATUSES = ['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS']; function toFloat(value) { return value == null ? null : Number(value); } function requireUser(context) { if (!context.user) { throw new Error('Authentication required.'); } return context.user; } function requireRole(context, role) { const user = requireUser(context); if (user.role !== role) { throw new Error(`Only ${role} can perform this operation.`); } return user; } async function appendOrderEvent(prisma, orderId, status, actorUserId, note = null) { return prisma.orderStatusEvent.create({ data: { orderId, status, actorUserId, note, }, }); } function orderCode() { return `FR-${Date.now()}-${crypto.randomInt(1000, 9999)}`; } function invitationToken() { return crypto.randomBytes(24).toString('hex'); } function formatOrderStatusMessage(order, status, note) { const suffix = note ? `\nКомментарий: ${note}` : ''; return `Заказ ${order.code} изменил статус: ${status}.${suffix}`; } 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)]; await Promise.allSettled( uniqueRecipients.map((userId) => dispatchToUserConnections(context.prisma, userId, message)), ); } 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, Query: { healthcheck: () => 'ok', me: (_, __, context) => context.user, myMessengerConnections: async (_, __, context) => { const user = requireUser(context); return context.prisma.messengerConnection.findMany({ where: { userId: user.id }, orderBy: { createdAt: 'desc' }, }); }, 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) => { requireRole(context, 'MANAGER'); 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' }, }), myOrders: (_, __, context) => { const user = requireUser(context); return context.prisma.order.findMany({ where: user.role === 'MANAGER' ? { managerId: user.id } : { 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: { ...(user.role === 'MANAGER' ? { managerId: user.id } : { customerId: user.id }), status: { in: ACTIVE_ORDER_STATUSES }, }, include: { items: true, history: { orderBy: { createdAt: 'desc' } }, }, orderBy: { createdAt: 'desc' }, }); }, managerOrders: (_, { status }, context) => { const manager = requireRole(context, 'MANAGER'); return context.prisma.order.findMany({ where: { managerId: manager.id, ...(status ? { status } : {}), }, include: { items: true, history: { orderBy: { createdAt: 'desc' } }, }, orderBy: { createdAt: 'desc' }, }); }, registrationRequests: (_, { status }, context) => { requireRole(context, 'MANAGER'); 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(); if (!destination) { throw new Error('Destination is required.'); } const user = await context.prisma.user.findFirst({ where: { email: { equals: destination, mode: 'insensitive', }, }, }); if (!user) { throw new Error('User for this destination was not found.'); } const challenge = createLoginChallenge({ userId: user.id, 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, }); const user = await context.prisma.user.findUnique({ where: { id: challenge.userId }, }); 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.'); } 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 = requireRole(context, 'MANAGER'); 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 = requireRole(context, 'MANAGER'); 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; }, 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 = requireRole(context, 'CLIENT'); if (!input.items.length) { throw new Error('Order must contain at least one item.'); } 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, 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 = requireRole(context, 'CLIENT'); const order = await context.prisma.order.create({ data: { code: orderCode(), kind: 'CALCULATION', customerId: customer.id, 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 = requireRole(context, 'MANAGER'); const order = await context.prisma.order.update({ where: { id: input.orderId }, data: { managerId: manager.id, status: 'WAITING_DOUBLE_CONFIRM', deliveryTerms: input.deliveryTerms, deliveryFee: input.deliveryFee, totalPrice: input.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 = requireRole(context, 'CLIENT'); 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 = requireRole(context, 'MANAGER'); const order = await context.prisma.order.findUnique({ where: { id: orderId } }); if (!order) { throw new Error('Order was not found.'); } 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 = requireRole(context, 'MANAGER'); 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 = requireRole(context, 'MANAGER'); const order = await context.prisma.order.findUnique({ where: { id: orderId } }); if (!order || order.status !== 'CONFIRMED') { throw new Error('Only confirmed 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 = requireRole(context, 'MANAGER'); const order = await context.prisma.order.findUnique({ where: { id: orderId } }); if (!order || order.status !== 'IN_PROGRESS') { throw new Error('Only in-progress order can be completed.'); } const updated = await context.prisma.order.update({ where: { id: orderId }, data: { managerId: manager.id, status: 'COMPLETED', }, }); await appendOrderEvent(context.prisma, updated.id, 'COMPLETED', manager.id, 'Order completed'); await notifyOrderStakeholders(context, updated, 'COMPLETED', 'Order completed'); return context.prisma.order.findUnique({ where: { id: updated.id }, include: { items: true, history: { orderBy: { createdAt: 'desc' } } }, }); }, createReferral: (_, { input }, context) => { const manager = requireRole(context, 'MANAGER'); return context.prisma.referralLink.create({ data: { referrerId: manager.id, refereeId: input.refereeUserId, }, }); }, addBonusTransaction: async (_, { input }, context) => { requireRole(context, 'MANAGER'); 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 = requireRole(context, 'CLIENT'); 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 = requireRole(context, 'MANAGER'); 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), }, BonusTransaction: { amount: (tx) => toFloat(tx.amount), }, RewardWithdrawalRequest: { amount: (tx) => toFloat(tx.amount), }, };