import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { createHash, randomBytes } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto'; 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 } }, invitations: { orderBy: { createdAt: 'desc' }, take: 1, }, }, orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }], }), this.prisma.membership.count({ where: { organizationId, OR: [{ isOwner: true }, { isActive: true }], }, }), ]); const maxUsers = org.plan?.maxUsers ?? 0; 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, isActive: m.isOwner ? true : m.isActive, invitationStatus: this.getInvitationStatus(m), invitedAt: m.invitations[0]?.createdAt?.toISOString() || null, acceptedAt: m.invitations[0]?.acceptedAt?.toISOString() || null, 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(', ')}`); } const plainToken = this.generateInviteToken(); const tokenHash = this.hashInviteToken(plainToken); 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'); } if (!org.plan) { throw new BadRequestException( 'This organization has no active subscription. Please choose a plan before inviting staff.', ); } const maxUsers = org.plan.maxUsers; const seatsUsed = await tx.membership.count({ where: { organizationId, OR: [{ isOwner: true }, { isActive: true }], }, }); 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 { const created = await tx.user.create({ data: { email, name: dto.name.trim(), passwordHash: null, }, }); targetUserId = created.id; } const membership = await tx.membership.create({ data: { userId: targetUserId, organizationId, isOwner: false, isActive: existingUser ? true : false, }, }); if (permissionRows.length > 0) { await tx.membershipPermission.createMany({ data: permissionRows.map((p) => ({ membershipId: membership.id, permissionId: p.id, })), }); } let inviteUrl: string | null = null; let invitationId: string | null = null; if (!existingUser) { const invitation = await tx.staffInvitation.create({ data: { membershipId: membership.id, invitedById: userId, tokenHash, expiresAt: this.getInviteExpiryDate(), }, }); invitationId = invitation.id; inviteUrl = this.buildInviteUrl(plainToken); } return { membershipId: membership.id, userId: targetUserId, invitationId, inviteUrl, isPending: !existingUser, }; }); return { success: true, data: { membershipId: result.membershipId, userId: result.userId, email, invitationId: result.invitationId, invitationUrl: result.inviteUrl, invitationStatus: result.isPending ? 'PENDING' : 'ACCEPTED', }, }; } async getInvitationLink(userId: string, organizationId: string, membershipId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditStaff(actor)) { throw new ForbiddenException('You cannot invite or manage staff'); } const membership = await this.prisma.membership.findFirst({ where: { id: membershipId, organizationId }, include: { user: { select: { email: true } }, invitations: { orderBy: { createdAt: 'desc' }, take: 1 }, }, }); if (!membership) { throw new NotFoundException('Member not found'); } if (membership.isOwner) { throw new BadRequestException('Owner does not use an invitation link'); } if (membership.isActive) { throw new BadRequestException('This member has already accepted their invitation'); } const invitation = membership.invitations[0]; if (!invitation) { throw new BadRequestException('No invitation found for this member'); } if (invitation.acceptedAt) { throw new BadRequestException('This invitation has already been accepted'); } if (invitation.revokedAt) { throw new BadRequestException('This invitation is no longer valid'); } const plainToken = this.generateInviteToken(); const tokenHash = this.hashInviteToken(plainToken); await this.prisma.staffInvitation.update({ where: { id: invitation.id }, data: { tokenHash, expiresAt: this.getInviteExpiryDate(), }, }); return { success: true, data: { membershipId: membership.id, invitationId: invitation.id, email: membership.user.email, invitationUrl: this.buildInviteUrl(plainToken), }, }; } async previewInvite(token: string) { const invitation = await this.findValidInvitation(token); const org = invitation.membership.organization; const user = invitation.membership.user; return { success: true, data: { email: user.email, name: user.name, organizationName: org.name, expiresAt: invitation.expiresAt.toISOString(), status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING', }, }; } async acceptInvite(dto: AcceptStaffInviteDto) { const invitation = await this.findValidInvitation(dto.token); if (invitation.acceptedAt) { throw new BadRequestException('This invitation has already been accepted'); } const passwordHash = await bcrypt.hash(dto.password, 10); const now = new Date(); await this.prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: invitation.membership.userId }, data: { passwordHash, name: dto.name.trim(), }, }); await tx.membership.update({ where: { id: invitation.membershipId }, data: { isActive: true }, }); await tx.staffInvitation.update({ where: { id: invitation.id }, data: { acceptedAt: now }, }); }); return { success: true, data: { email: invitation.membership.user.email, }, message: 'Invitation accepted. You can now log in.', }; } 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 disableMember(actorUserId: string, organizationId: string, membershipId: string) { const actor = await this.getActorMembership(actorUserId, organizationId); if (!actor || !this.canEditStaff(actor)) { throw new ForbiddenException('You cannot manage 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 disable the organization owner'); } if (actorUserId === target.userId) { throw new BadRequestException('You cannot disable your own access'); } if (!target.isActive) { throw new BadRequestException('This member is already disabled or pending activation'); } await this.prisma.membership.update({ where: { id: membershipId }, data: { isActive: false }, }); await this.prisma.session.deleteMany({ where: { userId: target.userId }, }); return { success: true, message: 'Member disabled. Their seat is now available for another invite.', }; } 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 } }, organization: { select: { planId: true } }, }, }); } private getInvitationStatus(m: { isOwner: boolean; isActive: boolean; invitations: { acceptedAt: Date | null; revokedAt: Date | null; expiresAt: Date }[]; }): 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED' { if (m.isOwner) return 'ACTIVE'; if (m.isActive) return 'ACTIVE'; const invitation = m.invitations[0]; if (invitation?.acceptedAt) return 'DISABLED'; if (!invitation) return 'DISABLED'; if (invitation.revokedAt) return 'EXPIRED'; return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED'; } private generateInviteToken(): string { return randomBytes(32).toString('hex'); } private hashInviteToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } private getInviteExpiryDate(): Date { const d = new Date(); d.setDate(d.getDate() + 7); return d; } private buildInviteUrl(token: string): string { const appUrl = process.env.FRONTEND_URL || 'http://localhost:3001'; return `${appUrl}/accept-invite?token=${encodeURIComponent(token)}`; } private async findValidInvitation(token: string) { const invitation = await this.prisma.staffInvitation.findUnique({ where: { tokenHash: this.hashInviteToken(token) }, include: { membership: { include: { user: { select: { id: true, email: true, name: true } }, organization: { select: { id: true, name: true } }, }, }, }, }); if (!invitation) { throw new NotFoundException('Invitation not found'); } if (invitation.revokedAt) { throw new BadRequestException('Invitation has been revoked'); } if (invitation.expiresAt.getTime() <= Date.now()) { throw new BadRequestException('Invitation has expired'); } return invitation; } private canViewStaff(m: { isOwner: boolean; organization?: { planId: string | null }; 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; organization?: { planId: string | null }; permissions: { permission: { name: string } }[]; }): boolean { if (m.isOwner) return Boolean(m.organization?.planId); return m.permissions.some((p) => p.permission.name === 'TAB_STAFF_EDIT'); } }