820 lines
24 KiB
JavaScript
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),
|
|
},
|
|
};
|