From 6b966c763e5799eb845fc84fe4311498165fd79b Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev Date: Sat, 4 Apr 2026 09:41:36 +0700 Subject: [PATCH] Add super manager role --- .../0007_super_manager/migration.sql | 1 + prisma/schema.prisma | 1 + scripts/set-user-role.js | 4 +- src/resolvers.js | 278 ++++++++++++++++-- src/schema.graphql | 16 + 5 files changed, 267 insertions(+), 33 deletions(-) create mode 100644 prisma/migrations/0007_super_manager/migration.sql diff --git a/prisma/migrations/0007_super_manager/migration.sql b/prisma/migrations/0007_super_manager/migration.sql new file mode 100644 index 0000000..2cd6b2a --- /dev/null +++ b/prisma/migrations/0007_super_manager/migration.sql @@ -0,0 +1 @@ +ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'SUPER_MANAGER'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a98e47..24f0127 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,6 +9,7 @@ datasource db { enum UserRole { CLIENT MANAGER + SUPER_MANAGER } enum RegistrationStatus { diff --git a/scripts/set-user-role.js b/scripts/set-user-role.js index dddd4a8..8abcd5f 100644 --- a/scripts/set-user-role.js +++ b/scripts/set-user-role.js @@ -6,10 +6,10 @@ const [, , emailArg, roleArg = 'MANAGER'] = process.argv; const email = String(emailArg || '').trim().toLowerCase(); const role = String(roleArg || '').trim().toUpperCase(); -const allowedRoles = new Set(['CLIENT', 'MANAGER']); +const allowedRoles = new Set(['CLIENT', 'MANAGER', 'SUPER_MANAGER']); if (!email) { - throw new Error('Usage: node scripts/set-user-role.js [CLIENT|MANAGER]'); + throw new Error('Usage: node scripts/set-user-role.js [CLIENT|MANAGER|SUPER_MANAGER]'); } if (!allowedRoles.has(role)) { diff --git a/src/resolvers.js b/src/resolvers.js index faf2e00..8d0764f 100644 --- a/src/resolvers.js +++ b/src/resolvers.js @@ -14,6 +14,8 @@ import { dateTimeScalar, jsonScalar } from './scalars.js'; import { fetchTelegramConnectionProfile } from './telegram.js'; 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) { return value == null ? null : Number(value); @@ -26,14 +28,131 @@ function requireUser(context) { return context.user; } -function requireRole(context, role) { +function requireAnyRole(context, roles) { const user = requireUser(context); - if (user.role !== role) { - throw new Error(`Only ${role} can perform this operation.`); + if (!roles.includes(user.role)) { + throw new Error(`Only ${roles.join(', ')} can perform this operation.`); } 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) { return prisma.orderStatusEvent.create({ data: { @@ -425,7 +544,8 @@ export const resolvers = { }, 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); return collectNotificationHistory(context, userId, channel, normalizedLimit); }, @@ -469,9 +589,10 @@ export const resolvers = { }, managerUsers: async (_, __, context) => { - requireRole(context, 'MANAGER'); + const manager = requireManagerAccess(context); + const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager); const users = await context.prisma.user.findMany({ - where: { role: 'CLIENT' }, + where: managedUsersWhere, include: { counterpartyProfile: { select: { @@ -509,10 +630,10 @@ export const resolvers = { }, managerOrders: (_, { status }, context) => { - const manager = requireRole(context, 'MANAGER'); + const manager = requireManagerAccess(context); return context.prisma.order.findMany({ where: { - managerId: manager.id, + ...(isSuperManager(manager) ? {} : { managerId: manager.id }), ...(status ? { status } : {}), }, include: { @@ -524,11 +645,12 @@ export const resolvers = { }, managerBonusBalances: async (_, __, context) => { - requireRole(context, 'MANAGER'); + const manager = requireManagerAccess(context); + const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager); const [users, transactionsAgg, pendingWithdrawalsAgg] = await Promise.all([ context.prisma.user.findMany({ - where: { role: 'CLIENT' }, + where: managedUsersWhere, include: { counterpartyProfile: { 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) => { - requireRole(context, 'MANAGER'); + const manager = requireManagerAccess(context); return context.prisma.registrationRequest.findMany({ - where: status ? { status } : undefined, + where: { + ...(status ? { status } : {}), + ...(isSuperManager(manager) + ? {} + : { + OR: [ + { reviewedById: manager.id }, + { reviewedById: null }, + ], + }), + }, orderBy: { createdAt: 'desc' }, }); }, @@ -763,7 +946,19 @@ export const resolvers = { }), 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({ where: { id: input.requestId }, data: { @@ -775,7 +970,7 @@ export const resolvers = { }, createInvitation: async (_, { input }, context) => { - const manager = requireRole(context, 'MANAGER'); + const manager = requireManagerAccess(context); const expiresInDays = input.expiresInDays > 0 ? input.expiresInDays : 7; const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000); @@ -1199,7 +1394,7 @@ export const resolvers = { code: orderCode(), kind: 'READY', customerId: customer.id, - managerId: customer.role === 'MANAGER' ? customer.id : null, + managerId: isManagerRole(customer.role) ? customer.id : null, deliveryAddressId: selectedAddress.id, deliveryAddress: presentDeliveryAddress(selectedAddress), status: 'NEW', @@ -1237,7 +1432,7 @@ export const resolvers = { code: orderCode(), kind: 'CALCULATION', customerId: customer.id, - managerId: customer.role === 'MANAGER' ? customer.id : null, + managerId: isManagerRole(customer.role) ? customer.id : null, deliveryAddressId: selectedAddress.id, deliveryAddress: presentDeliveryAddress(selectedAddress), status: 'NEW', @@ -1267,7 +1462,12 @@ export const resolvers = { }, 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({ where: { id: input.orderId }, data: { @@ -1289,7 +1489,7 @@ export const resolvers = { }, clientReviewOrder: async (_, { orderId, decision }, context) => { - const customer = requireRole(context, 'CLIENT'); + const customer = requireUser(context); const order = await context.prisma.order.findUnique({ where: { id: orderId } }); if (!order || order.customerId !== customer.id) { @@ -1331,11 +1531,9 @@ export const resolvers = { }, managerFinalizeOrder: async (_, { orderId, decision }, context) => { - const manager = requireRole(context, 'MANAGER'); + const manager = requireManagerAccess(context); const order = await context.prisma.order.findUnique({ where: { id: orderId } }); - if (!order) { - throw new Error('Order was not found.'); - } + assertManagerCanAccessOrder(order, manager); const status = decision === 'REJECT' ? 'MANAGER_REJECTED' @@ -1373,7 +1571,12 @@ export const resolvers = { }, 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({ where: { id: input.orderId }, data: { @@ -1393,9 +1596,10 @@ export const resolvers = { }, startOrderWork: async (_, { orderId }, context) => { - const manager = requireRole(context, 'MANAGER'); + const manager = requireManagerAccess(context); 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.'); } @@ -1417,9 +1621,10 @@ export const resolvers = { }, completeOrder: async (_, { orderId }, context) => { - const manager = requireRole(context, 'MANAGER'); + const manager = requireManagerAccess(context); 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.'); } @@ -1441,7 +1646,7 @@ export const resolvers = { }, createReferral: (_, { input }, context) => { - const manager = requireRole(context, 'MANAGER'); + const manager = requireManagerAccess(context); return context.prisma.referralLink.create({ data: { referrerId: manager.id, @@ -1451,7 +1656,8 @@ export const resolvers = { }, 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({ data: { userId: input.userId, @@ -1471,7 +1677,7 @@ export const resolvers = { }, requestRewardWithdrawal: (_, { input }, context) => { - const client = requireRole(context, 'CLIENT'); + const client = requireUser(context); if (input.amount < 100) { throw new Error('Minimum withdrawal amount is 100.'); } @@ -1485,7 +1691,17 @@ export const resolvers = { }, 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({ where: { id: input.withdrawalId }, data: { diff --git a/src/schema.graphql b/src/schema.graphql index f8a8e87..957163f 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -4,6 +4,7 @@ scalar JSON enum UserRole { CLIENT MANAGER + SUPER_MANAGER } enum MessengerType { @@ -306,6 +307,20 @@ type ManagerBonusBalance { 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 { healthcheck: String! me: User @@ -321,6 +336,7 @@ type Query { managerUsers: [ManagerUser!]! managerOrders(status: OrderStatus): [Order!]! managerBonusBalances: [ManagerBonusBalance!]! + managerWithdrawalRequests(status: WithdrawalStatus): [ManagerWithdrawalRequest!]! registrationRequests(status: RegistrationStatus): [RegistrationRequest!]! referralStats: ReferralStats! }