bugfix: a new flow added to re-enable disabled staffs.

This commit is contained in:
2026-05-18 19:03:52 +03:30
parent 10d8fac3f8
commit 3a0f5cbc6e
5 changed files with 216 additions and 4 deletions

View File

@@ -80,6 +80,19 @@ export class StaffController {
return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto);
}
@Patch('members/:membershipId/enable')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Re-enable a disabled staff member (uses one plan seat; no new invitation)',
})
enableMember(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('membershipId') membershipId: string,
) {
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
return this.staffService.enableMember(req.user.id, organizationId, membershipId);
}
@Patch('members/:membershipId/disable')
@UseGuards(JwtAuthGuard)
@ApiOperation({

View File

@@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
@@ -399,6 +400,50 @@ export class StaffService {
return { success: true, message: 'Member updated' };
}
async enableMember(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 },
include: {
invitations: { orderBy: { createdAt: 'desc' }, take: 1 },
},
});
if (!target) {
throw new NotFoundException('Member not found');
}
if (target.isOwner) {
throw new ForbiddenException('Cannot enable the organization owner');
}
if (target.isActive) {
throw new BadRequestException('This member is already active');
}
const invitation = target.invitations[0];
if (invitation && !invitation.acceptedAt) {
throw new BadRequestException(
'This member has not completed their invitation yet. Share the invite link instead.',
);
}
await this.prisma.$transaction(async (tx) => {
await this.assertOrganizationHasAvailableSeat(organizationId, tx);
await tx.membership.update({
where: { id: membershipId },
data: { isActive: true },
});
});
return {
success: true,
message: 'Member enabled. They can sign in to this organization again.',
};
}
async disableMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
@@ -459,6 +504,37 @@ export class StaffService {
return { success: true, message: 'Member removed' };
}
private async assertOrganizationHasAvailableSeat(
organizationId: string,
db: Prisma.TransactionClient | PrismaService = this.prisma,
) {
const org = await db.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 adding staff.',
);
}
const maxUsers = org.plan.maxUsers;
const seatsUsed = await db.membership.count({
where: {
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
});
if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) {
throw new BadRequestException(
`Your plan allows ${maxUsers} team members. Free a seat by disabling another member or upgrade your plan.`,
);
}
}
private async getActorMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId },