feature: a minimal implementation of staff management is done.

This commit is contained in:
2026-04-30 00:52:05 +03:30
parent d19da80d8e
commit c39bd872bd
26 changed files with 1361 additions and 26 deletions

View File

@@ -7,6 +7,7 @@ import { AppService } from './app.service';
import { AdminModule } from './admin/admin.module';
import { PrismaModule } from '../prisma/prisma.module'; // ✅
import { PatientsModule } from './modules/patients/patients.module';
import { StaffModule } from './modules/staff/staff.module';
@Module({
imports: [
@@ -17,6 +18,7 @@ import { PatientsModule } from './modules/patients/patients.module';
PrismaModule, // ✅ ADD THIS
AuthModule,
PatientsModule,
StaffModule,
AdminModule.forRoot(),
],
controllers: [AppController],

View File

@@ -0,0 +1,37 @@
import { isUnlimitedSeats, normalizeTabPermissions, SEAT_UNLIMITED_THRESHOLD } from './permissions';
describe('normalizeTabPermissions', () => {
it('adds READ when EDIT is present', () => {
expect(normalizeTabPermissions(['TAB_PATIENTS_EDIT'])).toEqual([
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
]);
});
it('dedupes and sorts', () => {
expect(
normalizeTabPermissions([
'TAB_TODAY_READ',
'TAB_TODAY_EDIT',
'TAB_TODAY_READ',
'bogus',
]),
).toEqual(['TAB_TODAY_READ', 'TAB_TODAY_EDIT']);
});
it('accepts empty array', () => {
expect(normalizeTabPermissions([])).toEqual([]);
});
});
describe('isUnlimitedSeats', () => {
it('treats sentinel as unlimited', () => {
expect(isUnlimitedSeats(SEAT_UNLIMITED_THRESHOLD)).toBe(true);
expect(isUnlimitedSeats(SEAT_UNLIMITED_THRESHOLD + 1)).toBe(true);
});
it('treats normal caps as limited', () => {
expect(isUnlimitedSeats(5)).toBe(false);
expect(isUnlimitedSeats(15)).toBe(false);
});
});

View File

@@ -0,0 +1,57 @@
/** Tab permissions — keep in sync with prisma seed and AuthService ALL_PERMISSIONS */
export const ALL_TAB_PERMISSIONS = [
'TAB_TODAY_READ',
'TAB_TODAY_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_STAFF_READ',
'TAB_STAFF_EDIT',
'TAB_LAB_READ',
'TAB_LAB_EDIT',
'TAB_BILLING_READ',
'TAB_BILLING_EDIT',
'TAB_REPORTS_READ',
'TAB_REPORTS_EDIT',
] as const;
export type TabPermission = (typeof ALL_TAB_PERMISSIONS)[number];
const ALL_TAB_SET = new Set<string>(ALL_TAB_PERMISSIONS);
const TAB_ORDER_INDEX = new Map<string, number>(
ALL_TAB_PERMISSIONS.map((p, i) => [p, i]),
);
/** Enterprise / unlimited seat plans use this sentinel in seed data */
export const SEAT_UNLIMITED_THRESHOLD = 999999;
export function isUnlimitedSeats(maxUsers: number): boolean {
return maxUsers >= SEAT_UNLIMITED_THRESHOLD;
}
/** EDIT implies READ for the same feature tab */
const EDIT_TO_READ: Record<string, string> = {
TAB_TODAY_EDIT: 'TAB_TODAY_READ',
TAB_PATIENTS_EDIT: 'TAB_PATIENTS_READ',
TAB_APPOINTMENTS_EDIT: 'TAB_APPOINTMENTS_READ',
TAB_STAFF_EDIT: 'TAB_STAFF_READ',
TAB_LAB_EDIT: 'TAB_LAB_READ',
TAB_BILLING_EDIT: 'TAB_BILLING_READ',
TAB_REPORTS_EDIT: 'TAB_REPORTS_READ',
};
/**
* Dedupe, drop unknown strings, and add implied READ permissions for each EDIT.
*/
export function normalizeTabPermissions(names: string[]): string[] {
const out = new Set<string>();
for (const raw of names) {
const n = typeof raw === 'string' ? raw.trim() : '';
if (!n || !ALL_TAB_SET.has(n)) continue;
out.add(n);
const read = EDIT_TO_READ[n];
if (read) out.add(read);
}
return [...out].sort((a, b) => (TAB_ORDER_INDEX.get(a) ?? 0) - (TAB_ORDER_INDEX.get(b) ?? 0));
}

View File

@@ -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,
},
};
}

View 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[];
}

View 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[];
}

View 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);
}
}

View 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 {}

View 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');
}
}