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

169
src/auth.js Normal file
View 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);
}

View File

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

View File

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

View File

@@ -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!