feature: a minimal implementation of staff management is done.
This commit is contained in:
@@ -751,6 +751,7 @@ export class AuthService {
|
||||
name: membership.organization.name,
|
||||
type: membership.organization.type.name,
|
||||
isOwner: membership.isOwner,
|
||||
permissions,
|
||||
plan: membership.organization.plan
|
||||
? {
|
||||
name: membership.organization.plan.name,
|
||||
@@ -758,7 +759,6 @@ export class AuthService {
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
permissions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
15
backend/src/modules/staff/dto/invite-staff.dto.ts
Normal file
15
backend/src/modules/staff/dto/invite-staff.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { IsArray, IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class InviteStaffDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name: string;
|
||||
|
||||
/** TAB_* permission names; EDIT implies READ after normalization. */
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
permissionNames: string[];
|
||||
}
|
||||
13
backend/src/modules/staff/dto/update-staff-member.dto.ts
Normal file
13
backend/src/modules/staff/dto/update-staff-member.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsArray, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateStaffMemberDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
permissionNames?: string[];
|
||||
}
|
||||
62
backend/src/modules/staff/staff.controller.ts
Normal file
62
backend/src/modules/staff/staff.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { InviteStaffDto } from './dto/invite-staff.dto';
|
||||
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
|
||||
import { StaffService } from './staff.service';
|
||||
|
||||
@ApiTags('staff')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('staff')
|
||||
export class StaffController {
|
||||
constructor(private readonly staffService: StaffService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List organization members (requires TAB_STAFF_READ or owner)' })
|
||||
list(@Req() req: { user: { id: string; organizationId?: string } }) {
|
||||
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
|
||||
return this.staffService.list(req.user.id, organizationId);
|
||||
}
|
||||
|
||||
@Post('invite')
|
||||
@ApiOperation({ summary: 'Invite staff (requires TAB_STAFF_EDIT or owner)' })
|
||||
invite(
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Body() dto: InviteStaffDto,
|
||||
) {
|
||||
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
|
||||
return this.staffService.invite(req.user.id, organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch('members/:membershipId')
|
||||
@ApiOperation({ summary: 'Update staff member name and/or permissions' })
|
||||
updateMember(
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Param('membershipId') membershipId: string,
|
||||
@Body() dto: UpdateStaffMemberDto,
|
||||
) {
|
||||
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
|
||||
return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto);
|
||||
}
|
||||
|
||||
@Delete('members/:membershipId')
|
||||
@ApiOperation({ summary: 'Remove staff member from organization' })
|
||||
removeMember(
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Param('membershipId') membershipId: string,
|
||||
) {
|
||||
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
|
||||
return this.staffService.removeMember(req.user.id, organizationId, membershipId);
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/staff/staff.module.ts
Normal file
10
backend/src/modules/staff/staff.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { StaffController } from './staff.controller';
|
||||
import { StaffService } from './staff.service';
|
||||
|
||||
@Module({
|
||||
controllers: [StaffController],
|
||||
providers: [StaffService, PrismaService],
|
||||
})
|
||||
export class StaffModule {}
|
||||
288
backend/src/modules/staff/staff.service.ts
Normal file
288
backend/src/modules/staff/staff.service.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
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 } },
|
||||
},
|
||||
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
|
||||
}),
|
||||
this.prisma.membership.count({ where: { organizationId } }),
|
||||
]);
|
||||
|
||||
const maxUsers = org.plan.maxUsers;
|
||||
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,
|
||||
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(', ')}`);
|
||||
}
|
||||
|
||||
let temporaryPassword: string | null = null;
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const maxUsers = org.plan.maxUsers;
|
||||
const seatsUsed = await tx.membership.count({ where: { organizationId } });
|
||||
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 {
|
||||
temporaryPassword = randomBytes(18).toString('base64url').slice(0, 20);
|
||||
const passwordHash = await bcrypt.hash(temporaryPassword, 10);
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: dto.name.trim(),
|
||||
passwordHash,
|
||||
},
|
||||
});
|
||||
targetUserId = created.id;
|
||||
}
|
||||
|
||||
const membership = await tx.membership.create({
|
||||
data: {
|
||||
userId: targetUserId,
|
||||
organizationId,
|
||||
isOwner: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (permissionRows.length > 0) {
|
||||
await tx.membershipPermission.createMany({
|
||||
data: permissionRows.map((p) => ({
|
||||
membershipId: membership.id,
|
||||
permissionId: p.id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return { membershipId: membership.id, userId: targetUserId };
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
membershipId: result.membershipId,
|
||||
userId: result.userId,
|
||||
email,
|
||||
temporaryPassword,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 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 } } },
|
||||
});
|
||||
}
|
||||
|
||||
private canViewStaff(m: {
|
||||
isOwner: boolean;
|
||||
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;
|
||||
permissions: { permission: { name: string } }[];
|
||||
}): boolean {
|
||||
if (m.isOwner) return true;
|
||||
return m.permissions.some((p) => p.permission.name === 'TAB_STAFF_EDIT');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user