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 { UpdateTeamMemberDto } from './dto/update-team-member.dto'; @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); 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(), }, })); 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, ), }, }; } 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'); } 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 }, }); 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.prisma.businessUser.delete({ where: { id: memberId } }); 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'); } } 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(', ')}`, ); } } }