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:
Alireza Hassani
2026-08-10 21:31:28 +03:30
co-authored by Cursor
parent 2c4d02bd0e
commit e59db25814
8 changed files with 334 additions and 12 deletions
@@ -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(
+238
View File
@@ -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;
}
+35 -8
View File
@@ -21,6 +21,9 @@ type CustomerListRow = {
email: string | null;
createdAt: Date;
isEnabled: boolean;
businessMemberId: bigint | null;
isBusinessOwner: boolean | null;
teamRole: string | null;
};
@Injectable()
@@ -44,9 +47,18 @@ export class CustomersService {
const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null;
const cellLike = query.cellNumber?.trim() ? `%${query.cellNumber.trim()}%` : null;
const access = query.access ?? 'all';
const accessFilter =
access === 'customers'
? Prisma.sql`AND bc.id IS NOT NULL AND bu.id IS NULL`
: access === 'managers'
? Prisma.sql`AND bu.id IS NOT NULL`
: Prisma.sql`AND (bc.id IS NOT NULL OR bu.id IS NOT NULL)`;
const where = Prisma.sql`
WHERE bc.business_id = ${businessId}
WHERE 1=1
${accessFilter}
${nameLike ? Prisma.sql`
AND (
u.first_name ILIKE ${nameLike}
@@ -65,18 +77,28 @@ export class CustomersService {
u.first_name AS "firstName",
u.last_name AS "lastName",
u.email AS "email",
bc.created_at AS "createdAt",
bc.is_enabled AS "isEnabled"
FROM business_customers bc
JOIN users u ON u.id = bc.user_id
COALESCE(bc.created_at, bu.created_at) AS "createdAt",
COALESCE(bc.is_enabled, TRUE) AS "isEnabled",
bu.id AS "businessMemberId",
bu.is_owner AS "isBusinessOwner",
r.slug AS "teamRole"
FROM users u
LEFT JOIN business_customers bc
ON bc.user_id = u.id AND bc.business_id = ${businessId}
LEFT JOIN business_users bu
ON bu.user_id = u.id AND bu.business_id = ${businessId}
LEFT JOIN roles r ON r.id = bu.role_id
${where}
ORDER BY bc.created_at DESC
ORDER BY COALESCE(bc.created_at, bu.created_at) DESC
LIMIT ${pageSize} OFFSET ${skip}
`),
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
SELECT COUNT(*)::int AS "total"
FROM business_customers bc
JOIN users u ON u.id = bc.user_id
FROM users u
LEFT JOIN business_customers bc
ON bc.user_id = u.id AND bc.business_id = ${businessId}
LEFT JOIN business_users bu
ON bu.user_id = u.id AND bu.business_id = ${businessId}
${where}
`),
]);
@@ -91,6 +113,11 @@ export class CustomersService {
label: this.formatLabel(user),
createdAt: user.createdAt.toISOString(),
isEnabled: user.isEnabled,
businessMemberId: user.businessMemberId?.toString() ?? null,
isBusinessOwner: user.isBusinessOwner ?? false,
teamRole: user.isBusinessOwner
? 'business_owner'
: user.teamRole ?? null,
})),
total: totalRow[0]?.total ?? 0,
page,
+10 -1
View File
@@ -1,5 +1,8 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Min } from 'class-validator';
import { IsIn, IsInt, IsOptional, IsString, Min } from 'class-validator';
export const CUSTOMER_ACCESS_FILTERS = ['all', 'customers', 'managers'] as const;
export type CustomerAccessFilter = (typeof CUSTOMER_ACCESS_FILTERS)[number];
export class ListCustomersDto {
@IsOptional()
@@ -21,4 +24,10 @@ export class ListCustomersDto {
@IsOptional()
@IsString()
cellNumber?: string;
/** all = customers managers; customers = customers only; managers = staff/owners */
@IsOptional()
@IsString()
@IsIn([...CUSTOMER_ACCESS_FILTERS])
access?: CustomerAccessFilter;
}