Add one-time code login flow with token sessions

This commit is contained in:
Ruslan Bakiev
2026-04-01 19:09:59 +07:00
parent f135c12785
commit 8c8689a877
5 changed files with 633 additions and 6 deletions

View File

@@ -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;
},
},