Files
apollo-backend/src/resolvers.js
2026-04-04 14:21:18 +07:00

1765 lines
52 KiB
JavaScript

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 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' },
});
},
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 = 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 = requireManagerAccess(context);
return context.prisma.referralLink.create({
data: {
referrerId: manager.id,
refereeId: input.refereeUserId,
},
});
},
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),
},
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 },
});
},
},
};