import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { randomBytes } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions'; import { InviteStaffDto } from './dto/invite-staff.dto'; import { UpdateStaffMemberDto } from './dto/update-staff-member.dto'; @Injectable() export class StaffService { constructor(private readonly prisma: PrismaService) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { throw new BadRequestException('Organization is not selected'); } return user.organizationId; } async list(userId: string, organizationId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canViewStaff(actor)) { throw new ForbiddenException('You do not have access to staff management'); } const org = await this.prisma.organization.findUnique({ where: { id: organizationId }, include: { plan: true }, }); if (!org) { throw new NotFoundException('Organization not found'); } const [members, seatsUsed] = await Promise.all([ this.prisma.membership.findMany({ where: { organizationId }, include: { user: { select: { id: true, email: true, name: true } }, permissions: { include: { permission: true } }, }, orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }], }), this.prisma.membership.count({ where: { organizationId } }), ]); const maxUsers = org.plan.maxUsers; const unlimited = isUnlimitedSeats(maxUsers); return { success: true, data: { members: members.map((m) => ({ id: m.id, userId: m.user.id, email: m.user.email, name: m.user.name, isOwner: m.isOwner, permissions: m.isOwner ? null : m.permissions.map((p) => p.permission.name), })), seats: { used: seatsUsed, limit: unlimited ? null : maxUsers, unlimited, }, }, }; } async invite(userId: string, organizationId: string, dto: InviteStaffDto) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditStaff(actor)) { throw new ForbiddenException('You cannot invite or manage staff'); } const email = dto.email.trim().toLowerCase(); const normalizedPerms = normalizeTabPermissions(dto.permissionNames); const permissionRows = await this.prisma.permission.findMany({ where: { name: { in: normalizedPerms } }, select: { id: true, name: true }, }); if (permissionRows.length !== normalizedPerms.length) { const ok = new Set(permissionRows.map((p) => p.name)); const missing = normalizedPerms.filter((n) => !ok.has(n)); throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`); } let temporaryPassword: string | null = null; const result = await this.prisma.$transaction(async (tx) => { const org = await tx.organization.findUnique({ where: { id: organizationId }, include: { plan: true }, }); if (!org) { throw new NotFoundException('Organization not found'); } const maxUsers = org.plan.maxUsers; const seatsUsed = await tx.membership.count({ where: { organizationId } }); if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) { throw new BadRequestException( `Your plan allows ${maxUsers} team members. Remove a member or upgrade to add more.`, ); } const existingUser = await tx.user.findUnique({ where: { email } }); let targetUserId: string; if (existingUser) { if (existingUser.id === org.ownerId) { throw new BadRequestException('Organization owner is already a member'); } const dup = await tx.membership.findUnique({ where: { userId_organizationId: { userId: existingUser.id, organizationId, }, }, }); if (dup) { throw new ConflictException('This user is already a member of this organization'); } targetUserId = existingUser.id; } else { temporaryPassword = randomBytes(18).toString('base64url').slice(0, 20); const passwordHash = await bcrypt.hash(temporaryPassword, 10); const created = await tx.user.create({ data: { email, name: dto.name.trim(), passwordHash, }, }); targetUserId = created.id; } const membership = await tx.membership.create({ data: { userId: targetUserId, organizationId, isOwner: false, }, }); if (permissionRows.length > 0) { await tx.membershipPermission.createMany({ data: permissionRows.map((p) => ({ membershipId: membership.id, permissionId: p.id, })), }); } return { membershipId: membership.id, userId: targetUserId }; }); return { success: true, data: { membershipId: result.membershipId, userId: result.userId, email, temporaryPassword, }, }; } async updateMember( actorUserId: string, organizationId: string, membershipId: string, dto: UpdateStaffMemberDto, ) { const actor = await this.getActorMembership(actorUserId, organizationId); if (!actor || !this.canEditStaff(actor)) { throw new ForbiddenException('You cannot edit staff'); } const target = await this.prisma.membership.findFirst({ where: { id: membershipId, organizationId }, include: { user: true, permissions: { include: { permission: true } }, }, }); if (!target) { throw new NotFoundException('Member not found'); } if (target.isOwner) { throw new ForbiddenException('Owner membership cannot be edited here'); } if (dto.name !== undefined) { await this.prisma.user.update({ where: { id: target.userId }, data: { name: dto.name.trim() }, }); } if (dto.permissionNames !== undefined) { const normalizedPerms = normalizeTabPermissions(dto.permissionNames); const permissionRows = await this.prisma.permission.findMany({ where: { name: { in: normalizedPerms } }, select: { id: true, name: true }, }); if (permissionRows.length !== normalizedPerms.length) { const ok = new Set(permissionRows.map((p) => p.name)); const missing = normalizedPerms.filter((n) => !ok.has(n)); throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`); } await this.prisma.$transaction([ this.prisma.membershipPermission.deleteMany({ where: { membershipId: target.id } }), ...(permissionRows.length ? [ this.prisma.membershipPermission.createMany({ data: permissionRows.map((p) => ({ membershipId: target.id, permissionId: p.id, })), }), ] : []), ]); } return { success: true, message: 'Member updated' }; } async removeMember(actorUserId: string, organizationId: string, membershipId: string) { const actor = await this.getActorMembership(actorUserId, organizationId); if (!actor || !this.canEditStaff(actor)) { throw new ForbiddenException('You cannot remove staff'); } const target = await this.prisma.membership.findFirst({ where: { id: membershipId, organizationId }, }); if (!target) { throw new NotFoundException('Member not found'); } if (target.isOwner) { throw new ForbiddenException('Cannot remove the organization owner'); } await this.prisma.membership.delete({ where: { id: membershipId } }); return { success: true, message: 'Member removed' }; } private async getActorMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ where: { userId, organizationId }, include: { permissions: { include: { permission: true } } }, }); } private canViewStaff(m: { isOwner: boolean; permissions: { permission: { name: string } }[]; }): boolean { if (m.isOwner) return true; return m.permissions.some( (p) => p.permission.name === 'TAB_STAFF_READ' || p.permission.name === 'TAB_STAFF_EDIT', ); } private canEditStaff(m: { isOwner: boolean; permissions: { permission: { name: string } }[]; }): boolean { if (m.isOwner) return true; return m.permissions.some((p) => p.permission.name === 'TAB_STAFF_EDIT'); } }