import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { ASSIGNABLE_TEAM_ROLES, AssignableTeamRole, AuthUser, } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; import { PrismaService } from '../prisma/prisma.service'; import { AddTeamMemberDto } from './dto/add-team-member.dto'; import { AssignTeamAccessDto } from './dto/assign-team-access.dto'; import { UpdateTeamMemberDto } from './dto/update-team-member.dto'; type TeamMembership = { id: bigint; userId: bigint; isOwner: boolean; role: { slug: string } | null; }; @Injectable() export class BusinessTeamService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, ) {} async list(businessId: bigint, actor: AuthUser) { await this.ensureCanRead(businessId, actor.id); const members = await this.prisma.businessUser.findMany({ where: { businessId }, include: { user: true, role: true, inviter: { select: { id: true, firstName: true, lastName: true } }, }, orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }], }); return { members: await Promise.all( members.map(async (m) => ({ id: m.id, userId: m.user.id, cellNumber: m.user.cellNumber, firstName: m.user.firstName, lastName: m.user.lastName, email: m.user.email, isOwner: m.isOwner, teamRole: m.isOwner ? 'business_owner' : m.role?.slug ?? null, permissions: await this.permissions.getPermissionsForBusiness( m.user.id, businessId, ), invitedBy: m.inviter, createdAt: m.createdAt, })), ), }; } async add(businessId: bigint, actor: AuthUser, dto: AddTeamMemberDto) { const canInvite = await this.permissions.hasBusinessPermission( actor.id, businessId, 'business.team.invite', ); if (!canInvite) { throw new ForbiddenException('You cannot invite team members'); } this.assertAssignableRole(dto.roleSlug); await this.assertCanAssignTeamRole(actor, businessId, null, dto.roleSlug); const business = await this.prisma.business.findUnique({ where: { id: businessId }, }); if (!business?.isActive) { throw new NotFoundException('Business not found'); } const staffRole = await this.prisma.role.findUnique({ where: { slug: dto.roleSlug }, }); if (!staffRole) { throw new BadRequestException('Invalid team role'); } const businessStaffRole = await this.prisma.role.findUnique({ where: { slug: 'business_staff' }, }); if (!businessStaffRole) { throw new Error('business_staff role is missing. Run migrations.'); } const existingUser = await this.prisma.user.findUnique({ where: { cellNumber: dto.cellNumber }, }); if (existingUser) { const existingMembership = await this.prisma.businessUser.findUnique({ where: { businessId_userId: { businessId, userId: existingUser.id }, }, }); if (existingMembership) { throw new ConflictException('User is already a member of this business'); } } if (!existingUser && !dto.password) { throw new BadRequestException('password is required for new users'); } const passwordHash = existingUser ? existingUser.passwordHash : await bcrypt.hash(dto.password!, 10); const member = await this.prisma.$transaction(async (tx) => { const user = existingUser ?? (await tx.user.create({ data: { cellNumber: dto.cellNumber, passwordHash, email: dto.email, firstName: dto.firstName, lastName: dto.lastName, cellVerifiedAt: new Date(), }, })); if (existingUser && !existingUser.cellVerifiedAt) { await tx.user.update({ where: { id: user.id }, data: { cellVerifiedAt: new Date() }, }); } const businessUser = await tx.businessUser.create({ data: { businessId, userId: user.id, isOwner: false, roleId: staffRole.id, invitedBy: actor.id, }, include: { user: true, role: true }, }); const hasStaffGlobalRole = await tx.userRole.findUnique({ where: { userId_roleId: { userId: user.id, roleId: businessStaffRole.id, }, }, }); if (!hasStaffGlobalRole) { await tx.userRole.create({ data: { userId: user.id, roleId: businessStaffRole.id }, }); } return businessUser; }); return { message: 'Team member added', member: { id: member.id, userId: member.user.id, cellNumber: member.user.cellNumber, firstName: member.user.firstName, lastName: member.user.lastName, isOwner: false, teamRole: member.role?.slug ?? dto.roleSlug, permissions: await this.permissions.getPermissionsForBusiness( member.user.id, businessId, ), }, }; } /** * Super-admin / owner path: set a user as customer or staff (admin/editor/viewer) * for one business. Owners cannot be changed. */ async assignAccess( businessId: bigint, actor: AuthUser, dto: AssignTeamAccessDto, ) { const userId = BigInt(dto.userId); const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user || !user.isActive) { throw new NotFoundException('User not found'); } const business = await this.prisma.business.findUnique({ where: { id: businessId }, }); if (!business?.isActive) { throw new NotFoundException('Business not found'); } const membership = await this.prisma.businessUser.findUnique({ where: { businessId_userId: { businessId, userId } }, include: { role: true }, }); if (membership?.isOwner) { throw new ForbiddenException('Cannot change the role of a business owner'); } if (dto.access === 'staff') { if (!dto.roleSlug) { throw new BadRequestException('roleSlug is required for staff access'); } this.assertAssignableRole(dto.roleSlug); const requiredPermission = membership ? 'business.team.update' : 'business.team.invite'; const allowed = await this.permissions.hasBusinessPermission( actor.id, businessId, requiredPermission, ); if (!allowed) { throw new ForbiddenException('You cannot assign team access'); } await this.assertCanAssignTeamRole( actor, businessId, membership, dto.roleSlug, ); const staffRole = await this.prisma.role.findUnique({ where: { slug: dto.roleSlug }, }); if (!staffRole) { throw new BadRequestException('Invalid team role'); } const businessStaffRole = await this.prisma.role.findUnique({ where: { slug: 'business_staff' }, }); if (!businessStaffRole) { throw new Error('business_staff role is missing. Run migrations.'); } const member = await this.prisma.$transaction(async (tx) => { const businessUser = membership ? await tx.businessUser.update({ where: { id: membership.id }, data: { roleId: staffRole.id }, include: { user: true, role: true }, }) : await tx.businessUser.create({ data: { businessId, userId, isOwner: false, roleId: staffRole.id, invitedBy: actor.id, }, include: { user: true, role: true }, }); const hasStaffGlobalRole = await tx.userRole.findUnique({ where: { userId_roleId: { userId, roleId: businessStaffRole.id, }, }, }); if (!hasStaffGlobalRole) { await tx.userRole.create({ data: { userId, roleId: businessStaffRole.id }, }); } return businessUser; }); return { message: 'Team access updated', access: 'staff' as const, member: { id: member.id, userId: member.user.id, isOwner: false, teamRole: member.role?.slug ?? dto.roleSlug, permissions: await this.permissions.getPermissionsForBusiness( member.user.id, businessId, ), }, }; } const canRemove = await this.permissions.hasBusinessPermission( actor.id, businessId, membership ? 'business.team.remove' : 'business.team.update', ); if (!canRemove) { throw new ForbiddenException('You cannot demote team members to customer'); } await this.assertCanAssignTeamRole(actor, businessId, membership, null); const customerRole = await this.prisma.role.findUnique({ where: { slug: 'customer' }, }); if (!customerRole) { throw new Error('customer role is missing. Run migrations.'); } const businessStaffRole = await this.prisma.role.findUnique({ where: { slug: 'business_staff' }, }); await this.prisma.$transaction(async (tx) => { if (membership) { await tx.businessUser.delete({ where: { id: membership.id } }); } const existingCustomer = await tx.businessCustomer.findUnique({ where: { businessId_userId: { businessId, userId } }, }); if (!existingCustomer) { await tx.businessCustomer.create({ data: { businessId, userId }, }); } const hasCustomerRole = await tx.userRole.findUnique({ where: { userId_roleId: { userId, roleId: customerRole.id }, }, }); if (!hasCustomerRole) { await tx.userRole.create({ data: { userId, roleId: customerRole.id }, }); } if (businessStaffRole) { const remainingStaff = await tx.businessUser.count({ where: { userId, isOwner: false }, }); if (remainingStaff === 0) { await tx.userRole.deleteMany({ where: { userId, roleId: businessStaffRole.id }, }); } } }); return { message: 'User set as customer for this business', access: 'customer' as const, member: null, }; } async update( businessId: bigint, memberId: bigint, actor: AuthUser, dto: UpdateTeamMemberDto, ) { const canUpdate = await this.permissions.hasBusinessPermission( actor.id, businessId, 'business.team.update', ); if (!canUpdate) { throw new ForbiddenException('You cannot update team members'); } this.assertAssignableRole(dto.roleSlug); const member = await this.prisma.businessUser.findFirst({ where: { id: memberId, businessId }, include: { user: true, role: true }, }); if (!member) { throw new NotFoundException('Team member not found'); } if (member.isOwner) { throw new ForbiddenException('Cannot change the role of a business owner'); } await this.assertCanAssignTeamRole(actor, businessId, member, dto.roleSlug); const staffRole = await this.prisma.role.findUnique({ where: { slug: dto.roleSlug }, }); if (!staffRole) { throw new BadRequestException('Invalid team role'); } const updated = await this.prisma.businessUser.update({ where: { id: memberId }, data: { roleId: staffRole.id }, include: { user: true, role: true }, }); return { message: 'Team member role updated', member: { id: updated.id, userId: updated.user.id, teamRole: updated.role?.slug ?? dto.roleSlug, permissions: await this.permissions.getPermissionsForBusiness( updated.user.id, businessId, ), }, }; } async remove(businessId: bigint, memberId: bigint, actor: AuthUser) { const canRemove = await this.permissions.hasBusinessPermission( actor.id, businessId, 'business.team.remove', ); if (!canRemove) { throw new ForbiddenException('You cannot remove team members'); } const member = await this.prisma.businessUser.findFirst({ where: { id: memberId, businessId }, include: { role: true }, }); if (!member) { throw new NotFoundException('Team member not found'); } if (member.isOwner) { throw new ForbiddenException('Cannot remove a business owner'); } if (member.userId === actor.id) { throw new ForbiddenException('You cannot remove yourself'); } await this.assertCanAssignTeamRole(actor, businessId, member, null); await this.prisma.businessUser.delete({ where: { id: memberId } }); const businessStaffRole = await this.prisma.role.findUnique({ where: { slug: 'business_staff' }, }); if (businessStaffRole) { const remainingStaff = await this.prisma.businessUser.count({ where: { userId: member.userId, isOwner: false }, }); if (remainingStaff === 0) { await this.prisma.userRole.deleteMany({ where: { userId: member.userId, roleId: businessStaffRole.id }, }); } } return { message: 'Team member removed' }; } private async ensureCanRead(businessId: bigint, userId: bigint) { const canRead = await this.permissions.hasBusinessPermission( userId, businessId, 'business.team.read', ); if (!canRead) { throw new ForbiddenException('You cannot view this business team'); } } /** * Only platform super admins may assign or change the `admin` team role. * Owners and team admins may manage editor/viewer (and demote those to customer). */ private async assertCanAssignTeamRole( actor: AuthUser, businessId: bigint, target: TeamMembership | null, nextRoleSlug: string | null, ) { if (await this.permissions.isSuperAdmin(actor.id)) { return; } if (target?.role?.slug === 'admin') { throw new ForbiddenException('Cannot change the role of an admin'); } if (nextRoleSlug === 'admin') { throw new ForbiddenException('Only a super admin can assign the admin role'); } } private assertAssignableRole(roleSlug: string): asserts roleSlug is AssignableTeamRole { if (!ASSIGNABLE_TEAM_ROLES.includes(roleSlug as AssignableTeamRole)) { throw new BadRequestException( `roleSlug must be one of: ${ASSIGNABLE_TEAM_ROLES.join(', ')}`, ); } } }