Add super manager role

This commit is contained in:
Ruslan Bakiev
2026-04-04 09:41:36 +07:00
parent da7cad207c
commit 6b966c763e
5 changed files with 267 additions and 33 deletions

View File

@@ -0,0 +1 @@
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'SUPER_MANAGER';

View File

@@ -9,6 +9,7 @@ datasource db {
enum UserRole { enum UserRole {
CLIENT CLIENT
MANAGER MANAGER
SUPER_MANAGER
} }
enum RegistrationStatus { enum RegistrationStatus {

View File

@@ -6,10 +6,10 @@ const [, , emailArg, roleArg = 'MANAGER'] = process.argv;
const email = String(emailArg || '').trim().toLowerCase(); const email = String(emailArg || '').trim().toLowerCase();
const role = String(roleArg || '').trim().toUpperCase(); const role = String(roleArg || '').trim().toUpperCase();
const allowedRoles = new Set(['CLIENT', 'MANAGER']); const allowedRoles = new Set(['CLIENT', 'MANAGER', 'SUPER_MANAGER']);
if (!email) { if (!email) {
throw new Error('Usage: node scripts/set-user-role.js <email> [CLIENT|MANAGER]'); throw new Error('Usage: node scripts/set-user-role.js <email> [CLIENT|MANAGER|SUPER_MANAGER]');
} }
if (!allowedRoles.has(role)) { if (!allowedRoles.has(role)) {

View File

@@ -14,6 +14,8 @@ import { dateTimeScalar, jsonScalar } from './scalars.js';
import { fetchTelegramConnectionProfile } from './telegram.js'; import { fetchTelegramConnectionProfile } from './telegram.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'];
const MANAGER_ROLES = ['MANAGER', 'SUPER_MANAGER'];
const NO_CLIENT_IDS = ['__no_managed_clients__'];
function toFloat(value) { function toFloat(value) {
return value == null ? null : Number(value); return value == null ? null : Number(value);
@@ -26,14 +28,131 @@ function requireUser(context) {
return context.user; return context.user;
} }
function requireRole(context, role) { function requireAnyRole(context, roles) {
const user = requireUser(context); const user = requireUser(context);
if (user.role !== role) { if (!roles.includes(user.role)) {
throw new Error(`Only ${role} can perform this operation.`); throw new Error(`Only ${roles.join(', ')} can perform this operation.`);
} }
return user; return user;
} }
function requireManagerAccess(context) {
return requireAnyRole(context, MANAGER_ROLES);
}
function isSuperManager(user) {
return user.role === 'SUPER_MANAGER';
}
function isManagerRole(role) {
return MANAGER_ROLES.includes(role);
}
function normalizeManagedClientIds(clientIds) {
if (clientIds == null) {
return null;
}
return clientIds.length ? clientIds : NO_CLIENT_IDS;
}
async function getManagedClientIds(prisma, manager) {
if (isSuperManager(manager)) {
return null;
}
const [managedOrders, acceptedInvitations, reviewedRequests, reviewedWithdrawals] = await Promise.all([
prisma.order.findMany({
where: { managerId: manager.id },
select: { customerId: true },
}),
prisma.invitation.findMany({
where: {
managerId: manager.id,
acceptedById: { not: null },
},
select: { acceptedById: true },
}),
prisma.registrationRequest.findMany({
where: {
reviewedById: manager.id,
requesterId: { not: null },
},
select: { requesterId: true },
}),
prisma.rewardWithdrawalRequest.findMany({
where: { reviewedById: manager.id },
select: { requesterId: true },
}),
]);
const clientIds = new Set();
for (const order of managedOrders) {
if (order.customerId) {
clientIds.add(order.customerId);
}
}
for (const invitation of acceptedInvitations) {
if (invitation.acceptedById) {
clientIds.add(invitation.acceptedById);
}
}
for (const request of reviewedRequests) {
if (request.requesterId) {
clientIds.add(request.requesterId);
}
}
for (const withdrawal of reviewedWithdrawals) {
if (withdrawal.requesterId) {
clientIds.add(withdrawal.requesterId);
}
}
return [...clientIds];
}
async function getManagedClientUserWhere(prisma, manager) {
const managedClientIds = normalizeManagedClientIds(await getManagedClientIds(prisma, manager));
if (managedClientIds == null) {
return { role: 'CLIENT' };
}
return {
role: 'CLIENT',
id: { in: managedClientIds },
};
}
async function assertManagerCanAccessUser(prisma, manager, userId) {
if (isSuperManager(manager) || userId === manager.id) {
return;
}
const managedClientIds = await getManagedClientIds(prisma, manager);
if (!managedClientIds.includes(userId)) {
throw new Error('User is not available for this manager.');
}
}
function assertManagerCanAccessOrder(order, manager) {
if (!order) {
throw new Error('Order was not found.');
}
if (isSuperManager(manager)) {
return;
}
if (order.managerId && order.managerId !== manager.id) {
throw new Error('Order is assigned to another manager.');
}
}
async function appendOrderEvent(prisma, orderId, status, actorUserId, note = null) { async function appendOrderEvent(prisma, orderId, status, actorUserId, note = null) {
return prisma.orderStatusEvent.create({ return prisma.orderStatusEvent.create({
data: { data: {
@@ -425,7 +544,8 @@ export const resolvers = {
}, },
managerNotificationHistory: async (_, { userId, channel, limit }, context) => { managerNotificationHistory: async (_, { userId, channel, limit }, context) => {
requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
await assertManagerCanAccessUser(context.prisma, manager, userId);
const normalizedLimit = Math.min(Math.max(limit ?? 50, 1), 200); const normalizedLimit = Math.min(Math.max(limit ?? 50, 1), 200);
return collectNotificationHistory(context, userId, channel, normalizedLimit); return collectNotificationHistory(context, userId, channel, normalizedLimit);
}, },
@@ -469,9 +589,10 @@ export const resolvers = {
}, },
managerUsers: async (_, __, context) => { managerUsers: async (_, __, context) => {
requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager);
const users = await context.prisma.user.findMany({ const users = await context.prisma.user.findMany({
where: { role: 'CLIENT' }, where: managedUsersWhere,
include: { include: {
counterpartyProfile: { counterpartyProfile: {
select: { select: {
@@ -509,10 +630,10 @@ export const resolvers = {
}, },
managerOrders: (_, { status }, context) => { managerOrders: (_, { status }, context) => {
const manager = requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
return context.prisma.order.findMany({ return context.prisma.order.findMany({
where: { where: {
managerId: manager.id, ...(isSuperManager(manager) ? {} : { managerId: manager.id }),
...(status ? { status } : {}), ...(status ? { status } : {}),
}, },
include: { include: {
@@ -524,11 +645,12 @@ export const resolvers = {
}, },
managerBonusBalances: async (_, __, context) => { managerBonusBalances: async (_, __, context) => {
requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager);
const [users, transactionsAgg, pendingWithdrawalsAgg] = await Promise.all([ const [users, transactionsAgg, pendingWithdrawalsAgg] = await Promise.all([
context.prisma.user.findMany({ context.prisma.user.findMany({
where: { role: 'CLIENT' }, where: managedUsersWhere,
include: { include: {
counterpartyProfile: { counterpartyProfile: {
select: { select: {
@@ -579,10 +701,71 @@ export const resolvers = {
}); });
}, },
managerWithdrawalRequests: async (_, { status }, context) => {
const manager = requireManagerAccess(context);
const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager);
const users = await context.prisma.user.findMany({
where: managedUsersWhere,
select: {
id: true,
email: true,
fullName: true,
counterpartyProfile: {
select: {
companyName: true,
},
},
},
});
if (!users.length) {
return [];
}
const userMap = new Map(users.map((user) => [user.id, user]));
const withdrawals = await context.prisma.rewardWithdrawalRequest.findMany({
where: {
requesterId: { in: [...userMap.keys()] },
...(status ? { status } : {}),
},
orderBy: { createdAt: 'desc' },
});
return withdrawals.map((withdrawal) => {
const requester = userMap.get(withdrawal.requesterId);
return {
id: withdrawal.id,
requesterId: withdrawal.requesterId,
requesterEmail: requester?.email ?? 'unknown@fregat.local',
requesterFullName: requester?.fullName ?? 'Неизвестный пользователь',
companyName: requester?.counterpartyProfile?.companyName ?? null,
amount: Number(withdrawal.amount),
status: withdrawal.status,
reviewedById: withdrawal.reviewedById,
reviewComment: withdrawal.reviewComment,
createdAt: withdrawal.createdAt,
updatedAt: withdrawal.updatedAt,
};
});
},
registrationRequests: (_, { status }, context) => { registrationRequests: (_, { status }, context) => {
requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
return context.prisma.registrationRequest.findMany({ return context.prisma.registrationRequest.findMany({
where: status ? { status } : undefined, where: {
...(status ? { status } : {}),
...(isSuperManager(manager)
? {}
: {
OR: [
{ reviewedById: manager.id },
{ reviewedById: null },
],
}),
},
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
}, },
@@ -763,7 +946,19 @@ export const resolvers = {
}), }),
reviewRegistrationRequest: async (_, { input }, context) => { reviewRegistrationRequest: async (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
const request = await context.prisma.registrationRequest.findUnique({
where: { id: input.requestId },
});
if (!request) {
throw new Error('Registration request was not found.');
}
if (!isSuperManager(manager) && request.reviewedById && request.reviewedById !== manager.id) {
throw new Error('Registration request is assigned to another manager.');
}
return context.prisma.registrationRequest.update({ return context.prisma.registrationRequest.update({
where: { id: input.requestId }, where: { id: input.requestId },
data: { data: {
@@ -775,7 +970,7 @@ export const resolvers = {
}, },
createInvitation: async (_, { input }, context) => { createInvitation: async (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
const expiresInDays = input.expiresInDays > 0 ? input.expiresInDays : 7; const expiresInDays = input.expiresInDays > 0 ? input.expiresInDays : 7;
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000); const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
@@ -1199,7 +1394,7 @@ export const resolvers = {
code: orderCode(), code: orderCode(),
kind: 'READY', kind: 'READY',
customerId: customer.id, customerId: customer.id,
managerId: customer.role === 'MANAGER' ? customer.id : null, managerId: isManagerRole(customer.role) ? customer.id : null,
deliveryAddressId: selectedAddress.id, deliveryAddressId: selectedAddress.id,
deliveryAddress: presentDeliveryAddress(selectedAddress), deliveryAddress: presentDeliveryAddress(selectedAddress),
status: 'NEW', status: 'NEW',
@@ -1237,7 +1432,7 @@ export const resolvers = {
code: orderCode(), code: orderCode(),
kind: 'CALCULATION', kind: 'CALCULATION',
customerId: customer.id, customerId: customer.id,
managerId: customer.role === 'MANAGER' ? customer.id : null, managerId: isManagerRole(customer.role) ? customer.id : null,
deliveryAddressId: selectedAddress.id, deliveryAddressId: selectedAddress.id,
deliveryAddress: presentDeliveryAddress(selectedAddress), deliveryAddress: presentDeliveryAddress(selectedAddress),
status: 'NEW', status: 'NEW',
@@ -1267,7 +1462,12 @@ export const resolvers = {
}, },
managerSetOrderOffer: async (_, { input }, context) => { managerSetOrderOffer: async (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
const existingOrder = await context.prisma.order.findUnique({
where: { id: input.orderId },
});
assertManagerCanAccessOrder(existingOrder, manager);
const order = await context.prisma.order.update({ const order = await context.prisma.order.update({
where: { id: input.orderId }, where: { id: input.orderId },
data: { data: {
@@ -1289,7 +1489,7 @@ export const resolvers = {
}, },
clientReviewOrder: async (_, { orderId, decision }, context) => { clientReviewOrder: async (_, { orderId, decision }, context) => {
const customer = requireRole(context, 'CLIENT'); const customer = requireUser(context);
const order = await context.prisma.order.findUnique({ where: { id: orderId } }); const order = await context.prisma.order.findUnique({ where: { id: orderId } });
if (!order || order.customerId !== customer.id) { if (!order || order.customerId !== customer.id) {
@@ -1331,11 +1531,9 @@ export const resolvers = {
}, },
managerFinalizeOrder: async (_, { orderId, decision }, context) => { managerFinalizeOrder: async (_, { orderId, decision }, context) => {
const manager = requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
const order = await context.prisma.order.findUnique({ where: { id: orderId } }); const order = await context.prisma.order.findUnique({ where: { id: orderId } });
if (!order) { assertManagerCanAccessOrder(order, manager);
throw new Error('Order was not found.');
}
const status = decision === 'REJECT' const status = decision === 'REJECT'
? 'MANAGER_REJECTED' ? 'MANAGER_REJECTED'
@@ -1373,7 +1571,12 @@ export const resolvers = {
}, },
blockOrder: async (_, { input }, context) => { blockOrder: async (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
const order = await context.prisma.order.findUnique({
where: { id: input.orderId },
});
assertManagerCanAccessOrder(order, manager);
const updated = await context.prisma.order.update({ const updated = await context.prisma.order.update({
where: { id: input.orderId }, where: { id: input.orderId },
data: { data: {
@@ -1393,9 +1596,10 @@ export const resolvers = {
}, },
startOrderWork: async (_, { orderId }, context) => { startOrderWork: async (_, { orderId }, context) => {
const manager = requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
const order = await context.prisma.order.findUnique({ where: { id: orderId } }); const order = await context.prisma.order.findUnique({ where: { id: orderId } });
if (!order || order.status !== 'CONFIRMED') { assertManagerCanAccessOrder(order, manager);
if (order.status !== 'CONFIRMED') {
throw new Error('Only confirmed order can be started.'); throw new Error('Only confirmed order can be started.');
} }
@@ -1417,9 +1621,10 @@ export const resolvers = {
}, },
completeOrder: async (_, { orderId }, context) => { completeOrder: async (_, { orderId }, context) => {
const manager = requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
const order = await context.prisma.order.findUnique({ where: { id: orderId } }); const order = await context.prisma.order.findUnique({ where: { id: orderId } });
if (!order || order.status !== 'IN_PROGRESS') { assertManagerCanAccessOrder(order, manager);
if (order.status !== 'IN_PROGRESS') {
throw new Error('Only in-progress order can be completed.'); throw new Error('Only in-progress order can be completed.');
} }
@@ -1441,7 +1646,7 @@ export const resolvers = {
}, },
createReferral: (_, { input }, context) => { createReferral: (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
return context.prisma.referralLink.create({ return context.prisma.referralLink.create({
data: { data: {
referrerId: manager.id, referrerId: manager.id,
@@ -1451,7 +1656,8 @@ export const resolvers = {
}, },
addBonusTransaction: async (_, { input }, context) => { addBonusTransaction: async (_, { input }, context) => {
requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
await assertManagerCanAccessUser(context.prisma, manager, input.userId);
const transaction = await context.prisma.bonusTransaction.create({ const transaction = await context.prisma.bonusTransaction.create({
data: { data: {
userId: input.userId, userId: input.userId,
@@ -1471,7 +1677,7 @@ export const resolvers = {
}, },
requestRewardWithdrawal: (_, { input }, context) => { requestRewardWithdrawal: (_, { input }, context) => {
const client = requireRole(context, 'CLIENT'); const client = requireUser(context);
if (input.amount < 100) { if (input.amount < 100) {
throw new Error('Minimum withdrawal amount is 100.'); throw new Error('Minimum withdrawal amount is 100.');
} }
@@ -1485,7 +1691,17 @@ export const resolvers = {
}, },
reviewRewardWithdrawal: async (_, { input }, context) => { reviewRewardWithdrawal: async (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER'); const manager = requireManagerAccess(context);
const existingWithdrawal = await context.prisma.rewardWithdrawalRequest.findUnique({
where: { id: input.withdrawalId },
});
if (!existingWithdrawal) {
throw new Error('Withdrawal request was not found.');
}
await assertManagerCanAccessUser(context.prisma, manager, existingWithdrawal.requesterId);
const withdrawal = await context.prisma.rewardWithdrawalRequest.update({ const withdrawal = await context.prisma.rewardWithdrawalRequest.update({
where: { id: input.withdrawalId }, where: { id: input.withdrawalId },
data: { data: {

View File

@@ -4,6 +4,7 @@ scalar JSON
enum UserRole { enum UserRole {
CLIENT CLIENT
MANAGER MANAGER
SUPER_MANAGER
} }
enum MessengerType { enum MessengerType {
@@ -306,6 +307,20 @@ type ManagerBonusBalance {
transactionsCount: Int! transactionsCount: Int!
} }
type ManagerWithdrawalRequest {
id: ID!
requesterId: ID!
requesterEmail: String!
requesterFullName: String!
companyName: String
amount: Float!
status: WithdrawalStatus!
reviewedById: ID
reviewComment: String
createdAt: DateTime!
updatedAt: DateTime!
}
type Query { type Query {
healthcheck: String! healthcheck: String!
me: User me: User
@@ -321,6 +336,7 @@ type Query {
managerUsers: [ManagerUser!]! managerUsers: [ManagerUser!]!
managerOrders(status: OrderStatus): [Order!]! managerOrders(status: OrderStatus): [Order!]!
managerBonusBalances: [ManagerBonusBalance!]! managerBonusBalances: [ManagerBonusBalance!]!
managerWithdrawalRequests(status: WithdrawalStatus): [ManagerWithdrawalRequest!]!
registrationRequests(status: RegistrationStatus): [RegistrationRequest!]! registrationRequests(status: RegistrationStatus): [RegistrationRequest!]!
referralStats: ReferralStats! referralStats: ReferralStats!
} }