Compare commits

2 Commits
Author SHA1 Message Date
Ruslan Bakiev fcc2eb7450 Add client bonus access flag 2026-05-16 17:16:31 +07:00
Ruslan Bakiev c641a3dd23 Fix login code delivery mode 2026-05-16 09:23:40 +07:00
7 changed files with 176 additions and 81 deletions
+1
View File
@@ -14,3 +14,4 @@ SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=
AUTH_LOGIN_CODE_DELIVERY=email
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "bonusProgramEnabled" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -60,6 +60,7 @@ model User {
email String @unique
fullName String
role UserRole
bonusProgramEnabled Boolean @default(false)
companyId String?
company Company? @relation(fields: [companyId], references: [id])
counterpartyProfile CounterpartyProfile?
+2
View File
@@ -161,12 +161,14 @@ async function upsertClient(index) {
update: {
fullName: fullNameForIndex(index),
role: 'CLIENT',
bonusProgramEnabled: index % 2 === 1,
companyId: company.id,
},
create: {
email: buildClientEmail(index),
fullName: fullNameForIndex(index),
role: 'CLIENT',
bonusProgramEnabled: index % 2 === 1,
companyId: company.id,
},
});
+6 -1
View File
@@ -104,11 +104,16 @@ const manager = await prisma.user.upsert({
await prisma.user.upsert({
where: { email: clientEmail },
update: { fullName: 'Demo Client', companyId: company.id },
update: {
fullName: 'Demo Client',
companyId: company.id,
bonusProgramEnabled: true,
},
create: {
email: clientEmail,
fullName: 'Demo Client',
role: 'CLIENT',
bonusProgramEnabled: true,
companyId: company.id,
},
});
+159 -80
View File
@@ -30,6 +30,16 @@ import { fetchTelegramConnectionProfile } from './telegram.js';
const ACTIVE_ORDER_STATUSES = ['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS'];
function getLoginCodeDeliveryMode() {
const mode = String(process.env.AUTH_LOGIN_CODE_DELIVERY ?? 'email')
.trim()
.toLowerCase();
if (mode !== 'email' && mode !== 'static') {
throw new Error('AUTH_LOGIN_CODE_DELIVERY must be either "email" or "static".');
}
return mode;
}
function toFloat(value) {
return value == null ? null : Number(value);
}
@@ -138,10 +148,61 @@ function mapManagerReferralLink(link) {
};
}
const managerUserInclude = {
messengerConnections: {
where: {
type: 'TELEGRAM',
isActive: true,
},
orderBy: { createdAt: 'desc' },
take: 1,
},
counterpartyProfile: {
select: {
companyName: true,
inn: true,
},
},
clientOrders: {
select: {
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 1,
},
_count: {
select: {
clientOrders: true,
},
},
};
function mapManagerUser(user) {
return {
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role,
bonusProgramEnabled: user.bonusProgramEnabled,
companyName: user.counterpartyProfile?.companyName ?? null,
inn: user.counterpartyProfile?.inn ?? null,
createdAt: user.createdAt,
orderCount: user._count.clientOrders,
lastOrderAt: user.clientOrders[0]?.createdAt ?? null,
telegramConnection: user.messengerConnections[0] ?? null,
};
}
async function createReferralBonusTransaction(prisma, order) {
const referralLink = await prisma.referralLink.findFirst({
where: {
refereeId: order.customerId,
referrer: {
bonusProgramEnabled: true,
},
referee: {
bonusProgramEnabled: true,
},
},
include: {
referrer: {
@@ -320,20 +381,6 @@ function invitationToken() {
return crypto.randomBytes(24).toString('hex');
}
function buildDefaultFullName(email) {
const localPart = email.split('@')[0]?.trim();
if (!localPart) {
return 'Новый пользователь';
}
return localPart
.replace(/[._-]+/g, ' ')
.split(' ')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function normalizeText(value) {
return String(value ?? '').trim();
}
@@ -1103,49 +1150,11 @@ export const resolvers = {
const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager);
const users = await context.prisma.user.findMany({
where: managedUsersWhere,
include: {
messengerConnections: {
where: {
type: 'TELEGRAM',
isActive: true,
},
orderBy: { createdAt: 'desc' },
take: 1,
},
counterpartyProfile: {
select: {
companyName: true,
inn: true,
},
},
clientOrders: {
select: {
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 1,
},
_count: {
select: {
clientOrders: true,
},
},
},
include: managerUserInclude,
orderBy: { createdAt: 'desc' },
});
return users.map((user) => ({
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role,
companyName: user.counterpartyProfile?.companyName ?? null,
inn: user.counterpartyProfile?.inn ?? null,
createdAt: user.createdAt,
orderCount: user._count.clientOrders,
lastOrderAt: user.clientOrders[0]?.createdAt ?? null,
telegramConnection: user.messengerConnections[0] ?? null,
}));
return users.map(mapManagerUser);
},
managerOrders: async (_, { status, customerId }, context) => {
@@ -1201,7 +1210,11 @@ export const resolvers = {
const [users, transactionsAgg, pendingWithdrawalsAgg] = await Promise.all([
context.prisma.user.findMany({
where: managedUsersWhere,
where: {
...managedUsersWhere,
role: 'CLIENT',
bonusProgramEnabled: true,
},
include: {
counterpartyProfile: {
select: {
@@ -1245,6 +1258,7 @@ export const resolvers = {
email: user.email,
fullName: user.fullName,
companyName: user.counterpartyProfile?.companyName ?? null,
bonusProgramEnabled: user.bonusProgramEnabled,
balance: (tx?.balance ?? 0) - pendingWithdrawalAmount,
pendingWithdrawalAmount,
transactionsCount: tx?.transactionsCount ?? 0,
@@ -1332,7 +1346,11 @@ export const resolvers = {
const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager);
const users = await context.prisma.user.findMany({
where: managedUsersWhere,
where: {
...managedUsersWhere,
role: 'CLIENT',
bonusProgramEnabled: true,
},
select: {
id: true,
email: true,
@@ -1435,19 +1453,24 @@ export const resolvers = {
},
},
});
if (!user) {
throw new Error('User is not invited to the cabinet.');
}
const challenge = createLoginChallenge({
userId: user?.id ?? null,
userId: user.id,
channel: input.channel,
destination,
});
const code = getStaticAuthCode();
await sendLoginCodeEmail({
to: destination,
code,
expiresAt: challenge.expiresAt,
});
if (getLoginCodeDeliveryMode() === 'email') {
await sendLoginCodeEmail({
to: destination,
code,
expiresAt: challenge.expiresAt,
});
}
return {
challengeToken: challenge.challengeToken,
@@ -1463,24 +1486,9 @@ export const resolvers = {
code: input.code,
});
let user = challenge.userId
? await context.prisma.user.findUnique({
where: { id: challenge.userId },
})
: null;
if (!user && challenge.channel === 'EMAIL') {
const email = String(challenge.destination).trim().toLowerCase();
user = await context.prisma.user.upsert({
where: { email },
update: {},
create: {
email,
fullName: buildDefaultFullName(email),
role: 'CLIENT',
},
});
}
const user = await context.prisma.user.findUnique({
where: { id: challenge.userId },
});
if (!user) {
throw new Error('User is not available for this login challenge.');
@@ -2238,6 +2246,7 @@ export const resolvers = {
select: {
id: true,
role: true,
bonusProgramEnabled: true,
},
});
@@ -2249,6 +2258,10 @@ export const resolvers = {
throw new Error('Referral links can only be created between client accounts.');
}
if (users.some((user) => !user.bonusProgramEnabled)) {
throw new Error('Bonus program must be enabled for both selected clients.');
}
const existingRefereeLink = await context.prisma.referralLink.findFirst({
where: {
refereeId: refereeUserId,
@@ -2273,10 +2286,56 @@ export const resolvers = {
});
},
setClientBonusProgramEnabled: async (_, { userId, enabled }, context) => {
const manager = requireManagerAccess(context);
await assertManagerCanAccessUser(context.prisma, manager, userId);
const existingUser = await context.prisma.user.findUnique({
where: { id: userId },
select: {
role: true,
},
});
if (!existingUser) {
throw new Error('User was not found.');
}
if (existingUser.role !== 'CLIENT') {
throw new Error('Bonus program can only be configured for client accounts.');
}
const user = await context.prisma.user.update({
where: { id: userId },
data: {
bonusProgramEnabled: enabled,
},
include: managerUserInclude,
});
return mapManagerUser(user);
},
createBonusProgramLink: async (_, { userId }, context) => {
const manager = requireManagerAccess(context);
await assertManagerCanAccessUser(context.prisma, manager, userId);
const user = await context.prisma.user.findUnique({
where: { id: userId },
select: {
role: true,
bonusProgramEnabled: true,
},
});
if (!user) {
throw new Error('User was not found.');
}
if (user.role !== 'CLIENT' || !user.bonusProgramEnabled) {
throw new Error('Bonus program is not enabled for this client.');
}
const issued = issueBonusProgramLinkToken({ userId });
return {
@@ -2290,6 +2349,22 @@ export const resolvers = {
addBonusTransaction: async (_, { input }, context) => {
const manager = requireManagerAccess(context);
await assertManagerCanAccessUser(context.prisma, manager, input.userId);
const bonusUser = await context.prisma.user.findUnique({
where: { id: input.userId },
select: {
role: true,
bonusProgramEnabled: true,
},
});
if (!bonusUser) {
throw new Error('User was not found.');
}
if (bonusUser.role !== 'CLIENT' || !bonusUser.bonusProgramEnabled) {
throw new Error('Bonus program is not enabled for this client.');
}
const transaction = await context.prisma.bonusTransaction.create({
data: {
userId: input.userId,
@@ -2333,6 +2408,10 @@ export const resolvers = {
requestRewardWithdrawal: (_, { input }, context) => {
const client = requireUser(context);
if (!client.bonusProgramEnabled) {
throw new Error('Bonus program is not enabled for this client.');
}
if (input.amount < 100) {
throw new Error('Minimum withdrawal amount is 100.');
}
+4
View File
@@ -63,6 +63,7 @@ type User {
email: String!
fullName: String!
role: UserRole!
bonusProgramEnabled: Boolean!
company: Company
}
@@ -142,6 +143,7 @@ type ManagerUser {
email: String!
fullName: String!
role: UserRole!
bonusProgramEnabled: Boolean!
companyName: String
inn: String
createdAt: DateTime!
@@ -382,6 +384,7 @@ type ManagerBonusBalance {
email: String!
fullName: String!
companyName: String
bonusProgramEnabled: Boolean!
balance: Float!
pendingWithdrawalAmount: Float!
transactionsCount: Int!
@@ -607,6 +610,7 @@ type Mutation {
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
createReferral(input: CreateReferralInput!): ReferralLink!
setClientBonusProgramEnabled(userId: ID!, enabled: Boolean!): ManagerUser!
createBonusProgramLink(userId: ID!): BonusProgramLink!
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!