1765 lines
52 KiB
JavaScript
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 },
|
|
});
|
|
},
|
|
},
|
|
};
|