Add one-time code login flow with token sessions
This commit is contained in:
169
src/auth.js
Normal file
169
src/auth.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME || 'fregat_auth_token';
|
||||||
|
const AUTH_TOKEN_SECRET = process.env.AUTH_TOKEN_SECRET || 'fregat-auth-dev-secret';
|
||||||
|
const AUTH_TOKEN_TTL_SECONDS = Number(process.env.AUTH_TOKEN_TTL_SECONDS ?? 60 * 60 * 24 * 30);
|
||||||
|
const AUTH_LOGIN_CHALLENGE_TTL_SECONDS = Number(process.env.AUTH_LOGIN_CHALLENGE_TTL_SECONDS ?? 10 * 60);
|
||||||
|
const AUTH_STATIC_CODE = process.env.AUTH_STATIC_CODE || '123456';
|
||||||
|
|
||||||
|
const activeChallenges = new Map();
|
||||||
|
|
||||||
|
function sign(data) {
|
||||||
|
return crypto.createHmac('sha256', AUTH_TOKEN_SECRET).update(data).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookies(cookieHeaderValue) {
|
||||||
|
if (!cookieHeaderValue) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return cookieHeaderValue
|
||||||
|
.split(';')
|
||||||
|
.map((chunk) => chunk.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.reduce((acc, pair) => {
|
||||||
|
const separatorIndex = pair.indexOf('=');
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
const key = pair.slice(0, separatorIndex);
|
||||||
|
const value = pair.slice(separatorIndex + 1);
|
||||||
|
acc[key] = decodeURIComponent(value);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function purgeExpiredChallenges() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [challengeToken, challenge] of activeChallenges.entries()) {
|
||||||
|
if (challenge.expiresAt <= now) {
|
||||||
|
activeChallenges.delete(challengeToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskEmail(email) {
|
||||||
|
const [local, domain] = email.split('@');
|
||||||
|
if (!domain) {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visiblePart = local.length <= 2 ? local[0] ?? '*' : `${local[0]}${local[1]}`;
|
||||||
|
return `${visiblePart}${'*'.repeat(Math.max(local.length - visiblePart.length, 1))}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskChannelId(value) {
|
||||||
|
if (value.length <= 4) {
|
||||||
|
return '*'.repeat(value.length);
|
||||||
|
}
|
||||||
|
return `${value.slice(0, 2)}${'*'.repeat(Math.max(value.length - 4, 1))}${value.slice(-2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAuthTokenFromRequest(req) {
|
||||||
|
const authorizationHeader = Array.isArray(req.headers.authorization)
|
||||||
|
? req.headers.authorization[0]
|
||||||
|
: req.headers.authorization;
|
||||||
|
|
||||||
|
if (authorizationHeader?.startsWith('Bearer ')) {
|
||||||
|
return authorizationHeader.slice('Bearer '.length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieHeader = Array.isArray(req.headers.cookie) ? req.headers.cookie.join(';') : req.headers.cookie;
|
||||||
|
const cookies = parseCookies(cookieHeader ?? '');
|
||||||
|
return cookies[AUTH_COOKIE_NAME] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issueAccessToken(userId) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const exp = now + AUTH_TOKEN_TTL_SECONDS;
|
||||||
|
const payload = {
|
||||||
|
sub: userId,
|
||||||
|
iat: now,
|
||||||
|
exp,
|
||||||
|
jti: crypto.randomUUID(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
const signature = sign(payloadBase64);
|
||||||
|
return {
|
||||||
|
accessToken: `${payloadBase64}.${signature}`,
|
||||||
|
expiresAt: new Date(exp * 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyAccessToken(token) {
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [payloadBase64, signature] = parts;
|
||||||
|
const expectedSignature = sign(payloadBase64);
|
||||||
|
if (expectedSignature !== signature) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadJson = Buffer.from(payloadBase64, 'base64url').toString('utf8');
|
||||||
|
const payload = JSON.parse(payloadJson);
|
||||||
|
const exp = Number(payload.exp);
|
||||||
|
if (!Number.isFinite(exp) || exp <= Math.floor(Date.now() / 1000)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: String(payload.sub),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLoginChallenge({ userId, channel, destination }) {
|
||||||
|
purgeExpiredChallenges();
|
||||||
|
|
||||||
|
const challengeToken = crypto.randomBytes(24).toString('hex');
|
||||||
|
const expiresAt = Date.now() + AUTH_LOGIN_CHALLENGE_TTL_SECONDS * 1000;
|
||||||
|
activeChallenges.set(challengeToken, {
|
||||||
|
userId,
|
||||||
|
channel,
|
||||||
|
destination,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
challengeToken,
|
||||||
|
expiresAt: new Date(expiresAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyLoginChallengeCode({ challengeToken, code }) {
|
||||||
|
purgeExpiredChallenges();
|
||||||
|
|
||||||
|
const challenge = activeChallenges.get(challengeToken);
|
||||||
|
if (!challenge) {
|
||||||
|
throw new Error('Login challenge is invalid or expired.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(code).trim() !== AUTH_STATIC_CODE) {
|
||||||
|
throw new Error('Invalid login code.');
|
||||||
|
}
|
||||||
|
|
||||||
|
activeChallenges.delete(challengeToken);
|
||||||
|
return {
|
||||||
|
userId: challenge.userId,
|
||||||
|
channel: challenge.channel,
|
||||||
|
destination: challenge.destination,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStaticAuthCode() {
|
||||||
|
return AUTH_STATIC_CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskAuthDestination(channel, destination) {
|
||||||
|
if (channel === 'EMAIL') {
|
||||||
|
return maskEmail(destination);
|
||||||
|
}
|
||||||
|
return maskChannelId(destination);
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import { prisma } from './prisma-client.js';
|
import { prisma } from './prisma-client.js';
|
||||||
|
import { extractAuthTokenFromRequest, verifyAccessToken } from './auth.js';
|
||||||
|
|
||||||
export async function buildContext(req) {
|
export async function buildContext(req) {
|
||||||
const userId = req.headers['x-user-id'];
|
const token = extractAuthTokenFromRequest(req);
|
||||||
|
const tokenPayload = verifyAccessToken(token);
|
||||||
|
const legacyUserIdHeader = Array.isArray(req.headers['x-user-id'])
|
||||||
|
? req.headers['x-user-id'][0]
|
||||||
|
: req.headers['x-user-id'];
|
||||||
|
const userId = tokenPayload?.userId ?? legacyUserIdHeader;
|
||||||
|
|
||||||
const user = userId
|
const user = userId
|
||||||
? await prisma.user.findUnique({ where: { id: String(userId) } })
|
? await prisma.user.findUnique({ where: { id: String(userId) } })
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
132
src/messenger.js
Normal file
132
src/messenger.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
function maskChannel(channelId) {
|
||||||
|
const text = String(channelId);
|
||||||
|
if (text.length <= 6) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return `${text.slice(0, 3)}***${text.slice(-3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTelegramMessage(channelId, message) {
|
||||||
|
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
detail: 'TELEGRAM_BOT_TOKEN is not configured.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
chat_id: channelId,
|
||||||
|
text: message,
|
||||||
|
disable_web_page_preview: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
detail: `Telegram API error (${response.status}): ${body.slice(0, 240)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
detail: `Telegram message sent to ${maskChannel(channelId)}.`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
detail: `Telegram transport failed: ${error.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMaxMessage(channelId, message) {
|
||||||
|
const webhookUrl = process.env.MAX_BOT_WEBHOOK_URL;
|
||||||
|
if (!webhookUrl) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
detail: 'MAX_BOT_WEBHOOK_URL is not configured.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
channelId,
|
||||||
|
text: message,
|
||||||
|
source: 'fregat-apollo-backend',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
detail: `Max webhook error (${response.status}): ${body.slice(0, 240)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
detail: `Max message sent to ${maskChannel(channelId)}.`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
detail: `Max transport failed: ${error.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessengerMessage({ type, channelId, message }) {
|
||||||
|
if (type === 'TELEGRAM') {
|
||||||
|
return sendTelegramMessage(channelId, message);
|
||||||
|
}
|
||||||
|
if (type === 'MAX') {
|
||||||
|
return sendMaxMessage(channelId, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
detail: `Unsupported messenger type: ${type}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchToUserConnections(prisma, userId, message) {
|
||||||
|
const connections = await prisma.messengerConnection.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connections.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const connection of connections) {
|
||||||
|
const result = await sendMessengerMessage({
|
||||||
|
type: connection.type,
|
||||||
|
channelId: connection.channelId,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
type: connection.type,
|
||||||
|
channelId: connection.channelId,
|
||||||
|
...result,
|
||||||
|
sentAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
277
src/resolvers.js
277
src/resolvers.js
@@ -1,5 +1,13 @@
|
|||||||
import crypto from 'node:crypto';
|
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';
|
import { dateTimeScalar, jsonScalar } from './scalars.js';
|
||||||
|
|
||||||
const ACTIVE_ORDER_STATUSES = ['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS'];
|
const ACTIVE_ORDER_STATUSES = ['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS'];
|
||||||
@@ -10,7 +18,7 @@ function toFloat(value) {
|
|||||||
|
|
||||||
function requireUser(context) {
|
function requireUser(context) {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new Error('Authentication required. Pass x-user-id header.');
|
throw new Error('Authentication required.');
|
||||||
}
|
}
|
||||||
return context.user;
|
return context.user;
|
||||||
}
|
}
|
||||||
@@ -42,6 +50,91 @@ function invitationToken() {
|
|||||||
return crypto.randomBytes(24).toString('hex');
|
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 = {
|
export const resolvers = {
|
||||||
DateTime: dateTimeScalar,
|
DateTime: dateTimeScalar,
|
||||||
JSON: jsonScalar,
|
JSON: jsonScalar,
|
||||||
@@ -51,6 +144,26 @@ export const resolvers = {
|
|||||||
|
|
||||||
me: (_, __, context) => context.user,
|
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) =>
|
clientProducts: (_, __, context) =>
|
||||||
context.prisma.product.findMany({
|
context.prisma.product.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
@@ -143,6 +256,91 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Mutation: {
|
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) =>
|
registerSelf: (_, { input }, context) =>
|
||||||
context.prisma.registrationRequest.create({
|
context.prisma.registrationRequest.create({
|
||||||
data: {
|
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) => {
|
submitReadyOrder: async (_, { input }, context) => {
|
||||||
const customer = requireRole(context, 'CLIENT');
|
const customer = requireRole(context, 'CLIENT');
|
||||||
if (!input.items.length) {
|
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 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({
|
return context.prisma.order.findUnique({
|
||||||
where: { id: order.id },
|
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 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({
|
return context.prisma.order.findUnique({
|
||||||
where: { id: order.id },
|
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 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({
|
return context.prisma.order.findUnique({
|
||||||
where: { id: order.id },
|
where: { id: order.id },
|
||||||
@@ -369,6 +605,12 @@ export const resolvers = {
|
|||||||
customer.id,
|
customer.id,
|
||||||
decision === 'APPROVE' ? 'Client approved offer' : 'Client rejected offer',
|
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({
|
return context.prisma.order.findUnique({
|
||||||
where: { id: updated.id },
|
where: { id: updated.id },
|
||||||
@@ -405,6 +647,12 @@ export const resolvers = {
|
|||||||
manager.id,
|
manager.id,
|
||||||
decision === 'APPROVE' ? 'Manager approved order' : 'Manager rejected order',
|
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({
|
return context.prisma.order.findUnique({
|
||||||
where: { id: updated.id },
|
where: { id: updated.id },
|
||||||
@@ -424,6 +672,7 @@ export const resolvers = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await appendOrderEvent(context.prisma, updated.id, 'MANAGER_BLOCKED', manager.id, 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({
|
return context.prisma.order.findUnique({
|
||||||
where: { id: updated.id },
|
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 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({
|
return context.prisma.order.findUnique({
|
||||||
where: { id: updated.id },
|
where: { id: updated.id },
|
||||||
@@ -470,6 +720,7 @@ export const resolvers = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await appendOrderEvent(context.prisma, updated.id, 'COMPLETED', manager.id, 'Order completed');
|
await appendOrderEvent(context.prisma, updated.id, 'COMPLETED', manager.id, 'Order completed');
|
||||||
|
await notifyOrderStakeholders(context, updated, 'COMPLETED', 'Order completed');
|
||||||
|
|
||||||
return context.prisma.order.findUnique({
|
return context.prisma.order.findUnique({
|
||||||
where: { id: updated.id },
|
where: { id: updated.id },
|
||||||
@@ -487,9 +738,9 @@ export const resolvers = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addBonusTransaction: (_, { input }, context) => {
|
addBonusTransaction: async (_, { input }, context) => {
|
||||||
requireRole(context, 'MANAGER');
|
requireRole(context, 'MANAGER');
|
||||||
return context.prisma.bonusTransaction.create({
|
const transaction = await context.prisma.bonusTransaction.create({
|
||||||
data: {
|
data: {
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
@@ -497,6 +748,14 @@ export const resolvers = {
|
|||||||
orderId: input.orderId,
|
orderId: input.orderId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await dispatchToUserConnections(
|
||||||
|
context.prisma,
|
||||||
|
transaction.userId,
|
||||||
|
`Начислен бонус: ${toFloat(transaction.amount)}. Причина: ${transaction.reason}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return transaction;
|
||||||
},
|
},
|
||||||
|
|
||||||
requestRewardWithdrawal: (_, { input }, context) => {
|
requestRewardWithdrawal: (_, { input }, context) => {
|
||||||
@@ -513,9 +772,9 @@ export const resolvers = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
reviewRewardWithdrawal: (_, { input }, context) => {
|
reviewRewardWithdrawal: async (_, { input }, context) => {
|
||||||
const manager = requireRole(context, 'MANAGER');
|
const manager = requireRole(context, 'MANAGER');
|
||||||
return context.prisma.rewardWithdrawalRequest.update({
|
const withdrawal = await context.prisma.rewardWithdrawalRequest.update({
|
||||||
where: { id: input.withdrawalId },
|
where: { id: input.withdrawalId },
|
||||||
data: {
|
data: {
|
||||||
reviewedById: manager.id,
|
reviewedById: manager.id,
|
||||||
@@ -523,6 +782,14 @@ export const resolvers = {
|
|||||||
reviewComment: input.reviewComment,
|
reviewComment: input.reviewComment,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await dispatchToUserConnections(
|
||||||
|
context.prisma,
|
||||||
|
withdrawal.requesterId,
|
||||||
|
`Заявка на вывод вознаграждения обновлена: ${withdrawal.status}.${withdrawal.reviewComment ? ` Комментарий: ${withdrawal.reviewComment}` : ''}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return withdrawal;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ enum MessengerType {
|
|||||||
MAX
|
MAX
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum LoginChannel {
|
||||||
|
EMAIL
|
||||||
|
TELEGRAM
|
||||||
|
MAX
|
||||||
|
}
|
||||||
|
|
||||||
enum RegistrationStatus {
|
enum RegistrationStatus {
|
||||||
PENDING
|
PENDING
|
||||||
APPROVED
|
APPROVED
|
||||||
@@ -59,6 +65,19 @@ type User {
|
|||||||
company: Company
|
company: Company
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthCodeRequestResult {
|
||||||
|
challengeToken: String!
|
||||||
|
channel: LoginChannel!
|
||||||
|
destination: String!
|
||||||
|
expiresAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthSession {
|
||||||
|
accessToken: String!
|
||||||
|
expiresAt: DateTime!
|
||||||
|
user: User!
|
||||||
|
}
|
||||||
|
|
||||||
type Invitation {
|
type Invitation {
|
||||||
id: ID!
|
id: ID!
|
||||||
token: String!
|
token: String!
|
||||||
@@ -92,6 +111,23 @@ type MessengerConnection {
|
|||||||
isActive: Boolean!
|
isActive: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MessengerDispatchResult {
|
||||||
|
type: MessengerType!
|
||||||
|
channelId: String!
|
||||||
|
success: Boolean!
|
||||||
|
detail: String!
|
||||||
|
sentAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationHistoryItem {
|
||||||
|
id: ID!
|
||||||
|
channel: MessengerType!
|
||||||
|
title: String!
|
||||||
|
message: String!
|
||||||
|
createdAt: DateTime!
|
||||||
|
orderId: ID
|
||||||
|
}
|
||||||
|
|
||||||
type Warehouse {
|
type Warehouse {
|
||||||
id: ID!
|
id: ID!
|
||||||
code: String!
|
code: String!
|
||||||
@@ -186,6 +222,9 @@ type ReferralStats {
|
|||||||
type Query {
|
type Query {
|
||||||
healthcheck: String!
|
healthcheck: String!
|
||||||
me: User
|
me: User
|
||||||
|
myMessengerConnections: [MessengerConnection!]!
|
||||||
|
myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
||||||
|
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
||||||
clientProducts: [Product!]!
|
clientProducts: [Product!]!
|
||||||
myOrders: [Order!]!
|
myOrders: [Order!]!
|
||||||
myCurrentOrders: [Order!]!
|
myCurrentOrders: [Order!]!
|
||||||
@@ -194,6 +233,16 @@ type Query {
|
|||||||
referralStats: ReferralStats!
|
referralStats: ReferralStats!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input RequestLoginCodeInput {
|
||||||
|
channel: LoginChannel!
|
||||||
|
destination: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input VerifyLoginCodeInput {
|
||||||
|
challengeToken: String!
|
||||||
|
code: String!
|
||||||
|
}
|
||||||
|
|
||||||
input RegisterSelfInput {
|
input RegisterSelfInput {
|
||||||
companyName: String!
|
companyName: String!
|
||||||
inn: String
|
inn: String
|
||||||
@@ -272,11 +321,14 @@ input ReviewRewardWithdrawalInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
|
requestLoginCode(input: RequestLoginCodeInput!): AuthCodeRequestResult!
|
||||||
|
verifyLoginCode(input: VerifyLoginCodeInput!): AuthSession!
|
||||||
registerSelf(input: RegisterSelfInput!): RegistrationRequest!
|
registerSelf(input: RegisterSelfInput!): RegistrationRequest!
|
||||||
reviewRegistrationRequest(input: ReviewRegistrationRequestInput!): RegistrationRequest!
|
reviewRegistrationRequest(input: ReviewRegistrationRequestInput!): RegistrationRequest!
|
||||||
createInvitation(input: CreateInvitationInput!): Invitation!
|
createInvitation(input: CreateInvitationInput!): Invitation!
|
||||||
acceptInvitation(input: AcceptInvitationInput!): User!
|
acceptInvitation(input: AcceptInvitationInput!): User!
|
||||||
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
|
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
|
||||||
|
sendTestMessengerMessage(type: MessengerType!, channelId: String, message: String): MessengerDispatchResult!
|
||||||
|
|
||||||
submitReadyOrder(input: SubmitReadyOrderInput!): Order!
|
submitReadyOrder(input: SubmitReadyOrderInput!): Order!
|
||||||
submitCalculationOrder(input: SubmitCalculationOrderInput!): Order!
|
submitCalculationOrder(input: SubmitCalculationOrderInput!): Order!
|
||||||
|
|||||||
Reference in New Issue
Block a user