Add business team access controls and customer role filters.
Super-admins can assign Admin; owners/admins manage Editor/Viewer via team/access, and the customers list supports all/customers/managers filtering. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2c4d02bd0e
commit
e59db25814
@@ -15,6 +15,7 @@ import { BusinessPermissionGuard } from '../auth/guards/business-permission.guar
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { BusinessTeamService } from './business-team.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';
|
||||
|
||||
@Controller('businesses/:businessId/team')
|
||||
@@ -38,6 +39,17 @@ export class BusinessTeamController {
|
||||
return this.teamService.add(BigInt(businessId), user, dto);
|
||||
}
|
||||
|
||||
/** Set customer vs staff (admin/editor/viewer) for an existing user. */
|
||||
@Patch('access')
|
||||
@RequireBusinessPermission('business.team.update')
|
||||
assignAccess(
|
||||
@Param('businessId') businessId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: AssignTeamAccessDto,
|
||||
) {
|
||||
return this.teamService.assignAccess(BigInt(businessId), user, dto);
|
||||
}
|
||||
|
||||
@Patch(':memberId')
|
||||
@RequireBusinessPermission('business.team.update')
|
||||
update(
|
||||
|
||||
@@ -14,8 +14,16 @@ import {
|
||||
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(
|
||||
@@ -69,6 +77,7 @@ export class BusinessTeamService {
|
||||
}
|
||||
|
||||
this.assertAssignableRole(dto.roleSlug);
|
||||
await this.assertCanAssignTeamRole(actor, businessId, null, dto.roleSlug);
|
||||
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
@@ -175,6 +184,193 @@ export class BusinessTeamService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -205,6 +401,8 @@ export class BusinessTeamService {
|
||||
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 },
|
||||
});
|
||||
@@ -244,6 +442,7 @@ export class BusinessTeamService {
|
||||
|
||||
const member = await this.prisma.businessUser.findFirst({
|
||||
where: { id: memberId, businessId },
|
||||
include: { role: true },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
@@ -258,8 +457,24 @@ export class BusinessTeamService {
|
||||
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' };
|
||||
}
|
||||
|
||||
@@ -274,6 +489,29 @@ export class BusinessTeamService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IsIn, IsOptional, IsString, Matches, ValidateIf } from 'class-validator';
|
||||
import { ASSIGNABLE_TEAM_ROLES } from '../../auth/auth.types';
|
||||
|
||||
export class AssignTeamAccessDto {
|
||||
@IsString()
|
||||
@Matches(/^\d+$/)
|
||||
userId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['customer', 'staff'])
|
||||
access!: 'customer' | 'staff';
|
||||
|
||||
@ValidateIf((dto: AssignTeamAccessDto) => dto.access === 'staff')
|
||||
@IsString()
|
||||
@IsIn([...ASSIGNABLE_TEAM_ROLES])
|
||||
roleSlug?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user