Files
apollo-backend/src/resolvers.js

820 lines
24 KiB
JavaScript

import crypto from 'node:crypto';
import {
consumeTemporaryLoginToken,
createLoginChallenge,
getStaticAuthCode,
issueAccessToken,
maskAuthDestination,
verifyLoginChallengeCode,
} from './auth.js';
import { sendLoginCodeEmail } from './mailer.js';
import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js';
import { dateTimeScalar, jsonScalar } from './scalars.js';
const ACTIVE_ORDER_STATUSES = ['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS'];
function toFloat(value) {
return value == null ? null : Number(value);
}
function requireUser(context) {
if (!context.user) {
throw new Error('Authentication required.');
}
return context.user;
}
function requireRole(context, role) {
const user = requireUser(context);
if (user.role !== role) {
throw new Error(`Only ${role} can perform this operation.`);
}
return user;
}
async function appendOrderEvent(prisma, orderId, status, actorUserId, note = null) {
return prisma.orderStatusEvent.create({
data: {
orderId,
status,
actorUserId,
note,
},
});
}
function orderCode() {
return `FR-${Date.now()}-${crypto.randomInt(1000, 9999)}`;
}
function invitationToken() {
return crypto.randomBytes(24).toString('hex');
}
function formatOrderStatusMessage(order, status, note) {
const suffix = note ? `\nКомментарий: ${note}` : '';
return `Заказ ${order.code} изменил статус: ${status}.${suffix}`;
}
async function notifyOrderStakeholders(context, order, status, note) {
const recipients = [order.customerId, order.managerId].filter(Boolean);
if (!recipients.length) {
return;
}
const message = formatOrderStatusMessage(order, status, note);
const uniqueRecipients = [...new Set(recipients)];
await Promise.allSettled(
uniqueRecipients.map((userId) => dispatchToUserConnections(context.prisma, userId, message)),
);
}
function byCreatedAtDesc(a, b) {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}
async function collectNotificationHistory(context, userId, channel, limit) {
const [events, bonuses, withdrawals] = await Promise.all([
context.prisma.orderStatusEvent.findMany({
where: {
order: {
OR: [{ customerId: userId }, { managerId: userId }],
},
},
include: {
order: {
select: { id: true, code: true },
},
},
orderBy: { createdAt: 'desc' },
take: limit * 2,
}),
context.prisma.bonusTransaction.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: limit,
}),
context.prisma.rewardWithdrawalRequest.findMany({
where: {
requesterId: userId,
reviewedById: { not: null },
},
orderBy: { updatedAt: 'desc' },
take: limit,
}),
]);
const eventHistory = events.map((event) => ({
id: `ORDER_${event.id}_${channel}`,
channel,
title: `Статус заказа ${event.order.code}`,
message: formatOrderStatusMessage(event.order, event.status, event.note),
createdAt: event.createdAt,
orderId: event.orderId,
}));
const bonusHistory = bonuses.map((bonus) => ({
id: `BONUS_${bonus.id}_${channel}`,
channel,
title: 'Реферальный бонус',
message: `Начисление ${toFloat(bonus.amount)}. Причина: ${bonus.reason}`,
createdAt: bonus.createdAt,
orderId: bonus.orderId,
}));
const withdrawalHistory = withdrawals.map((withdrawal) => ({
id: `WITHDRAW_${withdrawal.id}_${channel}`,
channel,
title: 'Заявка на вывод вознаграждения',
message: `Статус: ${withdrawal.status}.${withdrawal.reviewComment ? ` Комментарий: ${withdrawal.reviewComment}` : ''}`,
createdAt: withdrawal.updatedAt,
orderId: null,
}));
return [...eventHistory, ...bonusHistory, ...withdrawalHistory]
.sort(byCreatedAtDesc)
.slice(0, limit);
}
export const resolvers = {
DateTime: dateTimeScalar,
JSON: jsonScalar,
Query: {
healthcheck: () => 'ok',
me: (_, __, context) => context.user,
myMessengerConnections: async (_, __, context) => {
const user = requireUser(context);
return context.prisma.messengerConnection.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
});
},
myNotificationHistory: async (_, { channel, limit }, context) => {
const user = requireUser(context);
const normalizedLimit = Math.min(Math.max(limit ?? 50, 1), 200);
return collectNotificationHistory(context, user.id, channel, normalizedLimit);
},
managerNotificationHistory: async (_, { userId, channel, limit }, context) => {
requireRole(context, 'MANAGER');
const normalizedLimit = Math.min(Math.max(limit ?? 50, 1), 200);
return collectNotificationHistory(context, userId, channel, normalizedLimit);
},
clientProducts: (_, __, context) =>
context.prisma.product.findMany({
where: { isActive: true },
include: {
inventory: {
include: { warehouse: true },
},
},
orderBy: { name: 'asc' },
}),
myOrders: (_, __, context) => {
const user = requireUser(context);
return context.prisma.order.findMany({
where: user.role === 'MANAGER' ? { managerId: user.id } : { customerId: user.id },
include: {
items: true,
history: { orderBy: { createdAt: 'desc' } },
},
orderBy: { createdAt: 'desc' },
});
},
myCurrentOrders: (_, __, context) => {
const user = requireUser(context);
return context.prisma.order.findMany({
where: {
...(user.role === 'MANAGER' ? { managerId: user.id } : { customerId: user.id }),
status: { in: ACTIVE_ORDER_STATUSES },
},
include: {
items: true,
history: { orderBy: { createdAt: 'desc' } },
},
orderBy: { createdAt: 'desc' },
});
},
managerOrders: (_, { status }, context) => {
const manager = requireRole(context, 'MANAGER');
return context.prisma.order.findMany({
where: {
managerId: manager.id,
...(status ? { status } : {}),
},
include: {
items: true,
history: { orderBy: { createdAt: 'desc' } },
},
orderBy: { createdAt: 'desc' },
});
},
registrationRequests: (_, { status }, context) => {
requireRole(context, 'MANAGER');
return context.prisma.registrationRequest.findMany({
where: status ? { status } : undefined,
orderBy: { createdAt: 'desc' },
});
},
referralStats: async (_, __, context) => {
const user = requireUser(context);
const [links, transactions, pendingWithdrawals] = await Promise.all([
context.prisma.referralLink.count({ where: { referrerId: user.id } }),
context.prisma.bonusTransaction.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
}),
context.prisma.rewardWithdrawalRequest.findMany({
where: {
requesterId: user.id,
status: 'PENDING',
},
orderBy: { createdAt: 'desc' },
}),
]);
const txSum = transactions.reduce((acc, tx) => acc + Number(tx.amount), 0);
const pendingSum = pendingWithdrawals.reduce((acc, tx) => acc + Number(tx.amount), 0);
return {
referrerId: user.id,
availableBalance: txSum - pendingSum,
referralsCount: links,
transactions,
pendingWithdrawals,
};
},
},
Mutation: {
requestLoginCode: async (_, { input }, context) => {
if (input.channel !== 'EMAIL') {
throw new Error('Code login is supported only for EMAIL channel.');
}
const destination = input.destination.trim();
if (!destination) {
throw new Error('Destination is required.');
}
const user = await context.prisma.user.findFirst({
where: {
email: {
equals: destination,
mode: 'insensitive',
},
},
});
if (!user) {
throw new Error('User for this destination was not found.');
}
const challenge = createLoginChallenge({
userId: user.id,
channel: input.channel,
destination,
});
const code = getStaticAuthCode();
await sendLoginCodeEmail({
to: destination,
code,
expiresAt: challenge.expiresAt,
});
return {
challengeToken: challenge.challengeToken,
channel: input.channel,
destination: maskAuthDestination(input.channel, destination),
expiresAt: challenge.expiresAt,
};
},
verifyLoginCode: async (_, { input }, context) => {
const challenge = verifyLoginChallengeCode({
challengeToken: input.challengeToken,
code: input.code,
});
const user = await context.prisma.user.findUnique({
where: { id: challenge.userId },
});
if (!user) {
throw new Error('User is not available for this login challenge.');
}
const session = issueAccessToken(user.id);
return {
accessToken: session.accessToken,
expiresAt: session.expiresAt,
user,
};
},
consumeLoginToken: async (_, { token }, context) => {
const login = consumeTemporaryLoginToken(token);
const user = await context.prisma.user.findUnique({
where: { id: login.userId },
});
if (!user) {
throw new Error('User for this login token was not found.');
}
const session = issueAccessToken(user.id);
return {
accessToken: session.accessToken,
expiresAt: session.expiresAt,
user,
};
},
registerSelf: (_, { input }, context) =>
context.prisma.registrationRequest.create({
data: {
companyName: input.companyName,
inn: input.inn,
contactName: input.contactName,
email: input.email,
status: 'PENDING',
},
}),
reviewRegistrationRequest: async (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER');
return context.prisma.registrationRequest.update({
where: { id: input.requestId },
data: {
status: input.decision === 'APPROVE' ? 'APPROVED' : 'REJECTED',
rejectionReason: input.decision === 'REJECT' ? input.rejectionReason ?? 'Rejected by manager' : null,
reviewedById: manager.id,
},
});
},
createInvitation: async (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER');
const expiresInDays = input.expiresInDays > 0 ? input.expiresInDays : 7;
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
return context.prisma.invitation.create({
data: {
token: invitationToken(),
email: input.email,
companyName: input.companyName,
managerId: manager.id,
expiresAt,
},
});
},
acceptInvitation: async (_, { input }, context) => {
const invitation = await context.prisma.invitation.findUnique({ where: { token: input.token } });
if (!invitation) {
throw new Error('Invitation token is invalid.');
}
if (invitation.acceptedAt) {
throw new Error('Invitation has already been used.');
}
if (invitation.expiresAt < new Date()) {
throw new Error('Invitation has expired.');
}
const company = await context.prisma.company.upsert({
where: { name: invitation.companyName },
update: {},
create: { name: invitation.companyName },
});
const user = await context.prisma.user.create({
data: {
email: invitation.email,
fullName: input.fullName,
role: 'CLIENT',
companyId: company.id,
},
});
await context.prisma.invitation.update({
where: { id: invitation.id },
data: {
acceptedById: user.id,
acceptedAt: new Date(),
},
});
return user;
},
connectMessenger: (_, { input }, context) => {
const user = requireUser(context);
return context.prisma.messengerConnection.upsert({
where: {
userId_type_channelId: {
userId: user.id,
type: input.type,
channelId: input.channelId,
},
},
update: { isActive: true },
create: {
userId: user.id,
type: input.type,
channelId: input.channelId,
},
});
},
sendTestMessengerMessage: async (_, { type, channelId, message }, context) => {
const user = requireUser(context);
let targetChannelId = channelId;
if (!targetChannelId) {
const connection = await context.prisma.messengerConnection.findFirst({
where: {
userId: user.id,
type,
isActive: true,
},
orderBy: { createdAt: 'desc' },
});
targetChannelId = connection?.channelId ?? null;
}
if (!targetChannelId) {
throw new Error(`No active ${type} channel is connected for this user.`);
}
const dispatch = await sendMessengerMessage({
type,
channelId: targetChannelId,
message: message ?? `Тестовое уведомление Fregat (${type})`,
});
return {
type,
channelId: targetChannelId,
success: dispatch.success,
detail: dispatch.detail,
sentAt: new Date(),
};
},
submitReadyOrder: async (_, { input }, context) => {
const customer = requireRole(context, 'CLIENT');
if (!input.items.length) {
throw new Error('Order must contain at least one item.');
}
const productIds = input.items.map((item) => item.productId);
const products = await context.prisma.product.findMany({
where: { id: { in: productIds }, isActive: true },
});
if (products.length !== input.items.length) {
throw new Error('Some products are invalid or inactive.');
}
const productMap = new Map(products.map((product) => [product.id, product]));
const order = await context.prisma.order.create({
data: {
code: orderCode(),
kind: 'READY',
customerId: customer.id,
status: 'NEW',
items: {
create: input.items.map((item) => {
const product = productMap.get(item.productId);
return {
productId: item.productId,
productName: product.name,
quantity: item.quantity,
};
}),
},
},
include: {
items: true,
history: true,
},
});
await appendOrderEvent(context.prisma, order.id, 'NEW', customer.id, 'Ready order created by client');
await notifyOrderStakeholders(context, order, 'NEW', 'Ready order created by client');
return context.prisma.order.findUnique({
where: { id: order.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
submitCalculationOrder: async (_, { input }, context) => {
const customer = requireRole(context, 'CLIENT');
const order = await context.prisma.order.create({
data: {
code: orderCode(),
kind: 'CALCULATION',
customerId: customer.id,
status: 'NEW',
calculationPayload: input.parameters,
items: {
create: [
{
productName: input.productName,
quantity: input.quantity,
},
],
},
},
include: {
items: true,
history: true,
},
});
await appendOrderEvent(context.prisma, order.id, 'NEW', customer.id, 'Calculation request created by client');
await notifyOrderStakeholders(context, order, 'NEW', 'Calculation request created by client');
return context.prisma.order.findUnique({
where: { id: order.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
managerSetOrderOffer: async (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER');
const order = await context.prisma.order.update({
where: { id: input.orderId },
data: {
managerId: manager.id,
status: 'WAITING_DOUBLE_CONFIRM',
deliveryTerms: input.deliveryTerms,
deliveryFee: input.deliveryFee,
totalPrice: input.totalPrice,
},
});
await appendOrderEvent(context.prisma, order.id, 'WAITING_DOUBLE_CONFIRM', manager.id, 'Offer is published by manager');
await notifyOrderStakeholders(context, order, 'WAITING_DOUBLE_CONFIRM', 'Offer is published by manager');
return context.prisma.order.findUnique({
where: { id: order.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
clientReviewOrder: async (_, { orderId, decision }, context) => {
const customer = requireRole(context, 'CLIENT');
const order = await context.prisma.order.findUnique({ where: { id: orderId } });
if (!order || order.customerId !== customer.id) {
throw new Error('Order is not available for this client.');
}
const status = decision === 'REJECT'
? 'CLIENT_REJECTED'
: order.managerApproved
? 'CONFIRMED'
: 'WAITING_DOUBLE_CONFIRM';
const updated = await context.prisma.order.update({
where: { id: orderId },
data: {
clientApproved: decision === 'APPROVE',
status,
},
});
await appendOrderEvent(
context.prisma,
updated.id,
status,
customer.id,
decision === 'APPROVE' ? 'Client approved offer' : 'Client rejected offer',
);
await notifyOrderStakeholders(
context,
updated,
status,
decision === 'APPROVE' ? 'Client approved offer' : 'Client rejected offer',
);
return context.prisma.order.findUnique({
where: { id: updated.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
managerFinalizeOrder: async (_, { orderId, decision }, context) => {
const manager = requireRole(context, 'MANAGER');
const order = await context.prisma.order.findUnique({ where: { id: orderId } });
if (!order) {
throw new Error('Order was not found.');
}
const status = decision === 'REJECT'
? 'MANAGER_REJECTED'
: order.clientApproved
? 'CONFIRMED'
: 'WAITING_DOUBLE_CONFIRM';
const updated = await context.prisma.order.update({
where: { id: orderId },
data: {
managerId: manager.id,
managerApproved: decision === 'APPROVE',
status,
},
});
await appendOrderEvent(
context.prisma,
updated.id,
status,
manager.id,
decision === 'APPROVE' ? 'Manager approved order' : 'Manager rejected order',
);
await notifyOrderStakeholders(
context,
updated,
status,
decision === 'APPROVE' ? 'Manager approved order' : 'Manager rejected order',
);
return context.prisma.order.findUnique({
where: { id: updated.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
blockOrder: async (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER');
const updated = await context.prisma.order.update({
where: { id: input.orderId },
data: {
managerId: manager.id,
status: 'MANAGER_BLOCKED',
blockReason: input.reason,
},
});
await appendOrderEvent(context.prisma, updated.id, 'MANAGER_BLOCKED', manager.id, input.reason);
await notifyOrderStakeholders(context, updated, 'MANAGER_BLOCKED', input.reason);
return context.prisma.order.findUnique({
where: { id: updated.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
startOrderWork: async (_, { orderId }, context) => {
const manager = requireRole(context, 'MANAGER');
const order = await context.prisma.order.findUnique({ where: { id: orderId } });
if (!order || order.status !== 'CONFIRMED') {
throw new Error('Only confirmed order can be started.');
}
const updated = await context.prisma.order.update({
where: { id: orderId },
data: {
managerId: manager.id,
status: 'IN_PROGRESS',
},
});
await appendOrderEvent(context.prisma, updated.id, 'IN_PROGRESS', manager.id, 'Order moved to in-progress');
await notifyOrderStakeholders(context, updated, 'IN_PROGRESS', 'Order moved to in-progress');
return context.prisma.order.findUnique({
where: { id: updated.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
completeOrder: async (_, { orderId }, context) => {
const manager = requireRole(context, 'MANAGER');
const order = await context.prisma.order.findUnique({ where: { id: orderId } });
if (!order || order.status !== 'IN_PROGRESS') {
throw new Error('Only in-progress order can be completed.');
}
const updated = await context.prisma.order.update({
where: { id: orderId },
data: {
managerId: manager.id,
status: 'COMPLETED',
},
});
await appendOrderEvent(context.prisma, updated.id, 'COMPLETED', manager.id, 'Order completed');
await notifyOrderStakeholders(context, updated, 'COMPLETED', 'Order completed');
return context.prisma.order.findUnique({
where: { id: updated.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
createReferral: (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER');
return context.prisma.referralLink.create({
data: {
referrerId: manager.id,
refereeId: input.refereeUserId,
},
});
},
addBonusTransaction: async (_, { input }, context) => {
requireRole(context, 'MANAGER');
const transaction = await context.prisma.bonusTransaction.create({
data: {
userId: input.userId,
amount: input.amount,
reason: input.reason,
orderId: input.orderId,
},
});
await dispatchToUserConnections(
context.prisma,
transaction.userId,
`Начислен бонус: ${toFloat(transaction.amount)}. Причина: ${transaction.reason}`,
);
return transaction;
},
requestRewardWithdrawal: (_, { input }, context) => {
const client = requireRole(context, 'CLIENT');
if (input.amount < 100) {
throw new Error('Minimum withdrawal amount is 100.');
}
return context.prisma.rewardWithdrawalRequest.create({
data: {
requesterId: client.id,
amount: input.amount,
},
});
},
reviewRewardWithdrawal: async (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER');
const withdrawal = await context.prisma.rewardWithdrawalRequest.update({
where: { id: input.withdrawalId },
data: {
reviewedById: manager.id,
status: input.decision === 'APPROVE' ? 'APPROVED' : 'REJECTED',
reviewComment: input.reviewComment,
},
});
await dispatchToUserConnections(
context.prisma,
withdrawal.requesterId,
`Заявка на вывод вознаграждения обновлена: ${withdrawal.status}.${withdrawal.reviewComment ? ` Комментарий: ${withdrawal.reviewComment}` : ''}`,
);
return withdrawal;
},
},
Product: {
availableInWarehouses: (product) =>
product.inventory.map((stock) => ({
warehouse: stock.warehouse,
availableQty: toFloat(stock.availableQty),
})),
},
Order: {
deliveryFee: (order) => toFloat(order.deliveryFee),
totalPrice: (order) => toFloat(order.totalPrice),
},
OrderItem: {
quantity: (item) => toFloat(item.quantity),
},
BonusTransaction: {
amount: (tx) => toFloat(tx.amount),
},
RewardWithdrawalRequest: {
amount: (tx) => toFloat(tx.amount),
},
};