Add one-time code login flow with token sessions
This commit is contained in:
277
src/resolvers.js
277
src/resolvers.js
@@ -1,5 +1,13 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import {
|
||||
createLoginChallenge,
|
||||
getStaticAuthCode,
|
||||
issueAccessToken,
|
||||
maskAuthDestination,
|
||||
verifyLoginChallengeCode,
|
||||
} from './auth.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'];
|
||||
@@ -10,7 +18,7 @@ function toFloat(value) {
|
||||
|
||||
function requireUser(context) {
|
||||
if (!context.user) {
|
||||
throw new Error('Authentication required. Pass x-user-id header.');
|
||||
throw new Error('Authentication required.');
|
||||
}
|
||||
return context.user;
|
||||
}
|
||||
@@ -42,6 +50,91 @@ 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,
|
||||
@@ -51,6 +144,26 @@ export const resolvers = {
|
||||
|
||||
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 },
|
||||
@@ -143,6 +256,91 @@ export const resolvers = {
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
requestLoginCode: async (_, { input }, context) => {
|
||||
const destination = input.destination.trim();
|
||||
if (!destination) {
|
||||
throw new Error('Destination is required.');
|
||||
}
|
||||
|
||||
let user = null;
|
||||
|
||||
if (input.channel === 'EMAIL') {
|
||||
user = await context.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: destination,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const connection = await context.prisma.messengerConnection.findFirst({
|
||||
where: {
|
||||
type: input.channel,
|
||||
channelId: destination,
|
||||
isActive: true,
|
||||
},
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
user = connection?.user ?? null;
|
||||
}
|
||||
|
||||
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();
|
||||
const authMessage = `Код входа в Fregat: ${code}`;
|
||||
|
||||
if (input.channel === 'EMAIL') {
|
||||
console.info(`[auth] login code for ${destination}: ${code}`);
|
||||
} else {
|
||||
const dispatch = await sendMessengerMessage({
|
||||
type: input.channel,
|
||||
channelId: destination,
|
||||
message: authMessage,
|
||||
});
|
||||
if (!dispatch.success) {
|
||||
throw new Error(`Unable to send login code: ${dispatch.detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
|
||||
registerSelf: (_, { input }, context) =>
|
||||
context.prisma.registrationRequest.create({
|
||||
data: {
|
||||
@@ -239,6 +437,41 @@ export const resolvers = {
|
||||
});
|
||||
},
|
||||
|
||||
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) {
|
||||
@@ -280,6 +513,7 @@ export const resolvers = {
|
||||
});
|
||||
|
||||
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 },
|
||||
@@ -312,6 +546,7 @@ export const resolvers = {
|
||||
});
|
||||
|
||||
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 },
|
||||
@@ -333,6 +568,7 @@ export const resolvers = {
|
||||
});
|
||||
|
||||
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 },
|
||||
@@ -369,6 +605,12 @@ export const resolvers = {
|
||||
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 },
|
||||
@@ -405,6 +647,12 @@ export const resolvers = {
|
||||
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 },
|
||||
@@ -424,6 +672,7 @@ export const resolvers = {
|
||||
});
|
||||
|
||||
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 },
|
||||
@@ -447,6 +696,7 @@ export const resolvers = {
|
||||
});
|
||||
|
||||
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 },
|
||||
@@ -470,6 +720,7 @@ export const resolvers = {
|
||||
});
|
||||
|
||||
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 },
|
||||
@@ -487,9 +738,9 @@ export const resolvers = {
|
||||
});
|
||||
},
|
||||
|
||||
addBonusTransaction: (_, { input }, context) => {
|
||||
addBonusTransaction: async (_, { input }, context) => {
|
||||
requireRole(context, 'MANAGER');
|
||||
return context.prisma.bonusTransaction.create({
|
||||
const transaction = await context.prisma.bonusTransaction.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
amount: input.amount,
|
||||
@@ -497,6 +748,14 @@ export const resolvers = {
|
||||
orderId: input.orderId,
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchToUserConnections(
|
||||
context.prisma,
|
||||
transaction.userId,
|
||||
`Начислен бонус: ${toFloat(transaction.amount)}. Причина: ${transaction.reason}`,
|
||||
);
|
||||
|
||||
return transaction;
|
||||
},
|
||||
|
||||
requestRewardWithdrawal: (_, { input }, context) => {
|
||||
@@ -513,9 +772,9 @@ export const resolvers = {
|
||||
});
|
||||
},
|
||||
|
||||
reviewRewardWithdrawal: (_, { input }, context) => {
|
||||
reviewRewardWithdrawal: async (_, { input }, context) => {
|
||||
const manager = requireRole(context, 'MANAGER');
|
||||
return context.prisma.rewardWithdrawalRequest.update({
|
||||
const withdrawal = await context.prisma.rewardWithdrawalRequest.update({
|
||||
where: { id: input.withdrawalId },
|
||||
data: {
|
||||
reviewedById: manager.id,
|
||||
@@ -523,6 +782,14 @@ export const resolvers = {
|
||||
reviewComment: input.reviewComment,
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchToUserConnections(
|
||||
context.prisma,
|
||||
withdrawal.requesterId,
|
||||
`Заявка на вывод вознаграждения обновлена: ${withdrawal.status}.${withdrawal.reviewComment ? ` Комментарий: ${withdrawal.reviewComment}` : ''}`,
|
||||
);
|
||||
|
||||
return withdrawal;
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user