diff --git a/.cursor/rules/business-rbac.mdc b/.cursor/rules/business-rbac.mdc index 671e712..442a412 100644 --- a/.cursor/rules/business-rbac.mdc +++ b/.cursor/rules/business-rbac.mdc @@ -17,6 +17,10 @@ Content resources: `products.*`, `categories.*`, `media.*`, `business.team.*` - `super_admin` → all permissions (bypasses business guard) - Business owner (`isOwner=true`) → `business_owner` role permissions - Team member → permissions from `business_users.role_id` (admin/editor/viewer) +- Team **admin** may invite/update/remove **non-admin** staff only (cannot change or assign other admins) +- Team **editor** / **viewer** have no `business.team.*` manage permissions +- Only **super_admin** may assign or change the `admin` team role (owners/admins may assign editor/viewer) +- Owners cannot be changed via team APIs; use `PATCH .../team/access` for customer vs manager (staff) ## Adding a business-scoped endpoint diff --git a/database/migrations/057_admin_team_manage.sql b/database/migrations/057_admin_team_manage.sql new file mode 100644 index 0000000..5ca8fe5 --- /dev/null +++ b/database/migrations/057_admin_team_manage.sql @@ -0,0 +1,13 @@ +-- Business team admins can manage non-admin staff (invite / update / remove). +-- Owners keep full team.* ; editors/viewers stay without team management. + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ( + 'business.team.invite', + 'business.team.update', + 'business.team.remove' +) +WHERE r.slug = 'admin' +ON CONFLICT DO NOTHING; diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 3659352..7f154a5 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -369,11 +369,13 @@ Assigned via `business_users.role_id`: | Role | Slug | Typical access | |------|------|----------------| -| Owner | `isOwner=true` | All `business_owner` permissions | -| Admin | `admin` | Full content + team read | -| Editor | `editor` | Create/edit/publish content | +| Owner | `isOwner=true` | All `business_owner` permissions (incl. full team manage for editor/viewer) | +| Admin | `admin` | Full content + team manage for **non-admins**; only **super_admin** may assign this role | +| Editor | `editor` | Create/edit/publish content (no team manage) | | Viewer | `viewer` | Read-only | +Super-admin Users (business filter) and business Customers: change access is **Customer** vs **Manager**, then Admin/Editor/Viewer (Admin option super-admin only). Business owners are locked. API: `PATCH /businesses/:businessId/team/access`. + ### Permission groups (seeded) `business.*`, `domains.*`, `products.*`, `blogs.*`, `portfolios.*`, `media.*`, `categories.*`, `users.*`, `roles.manage`, `business.team.*`, `business_categories.*` diff --git a/src/business-team/business-team.controller.ts b/src/business-team/business-team.controller.ts index 76c3e61..aef24b7 100644 --- a/src/business-team/business-team.controller.ts +++ b/src/business-team/business-team.controller.ts @@ -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( diff --git a/src/business-team/business-team.service.ts b/src/business-team/business-team.service.ts index e855f5c..f83312a 100644 --- a/src/business-team/business-team.service.ts +++ b/src/business-team/business-team.service.ts @@ -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( diff --git a/src/business-team/dto/assign-team-access.dto.ts b/src/business-team/dto/assign-team-access.dto.ts new file mode 100644 index 0000000..8d68ed1 --- /dev/null +++ b/src/business-team/dto/assign-team-access.dto.ts @@ -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; +} diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts index 433f964..04542a5 100644 --- a/src/customers/customers.service.ts +++ b/src/customers/customers.service.ts @@ -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, diff --git a/src/customers/dto/list-customers.dto.ts b/src/customers/dto/list-customers.dto.ts index 3fcfd82..d4fd150 100644 --- a/src/customers/dto/list-customers.dto.ts +++ b/src/customers/dto/list-customers.dto.ts @@ -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; }