diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 860aade..1e68517 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -126,7 +126,11 @@ export class AuthController { @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create organization for current user' }) async createOrganization(@Req() req, @Body() dto: CreateOrganizationDto) { - return this.authService.createOrganization(req.user.id, dto); + return this.authService.createOrganization( + req.user.id, + req.user.organizationId, + dto, + ); } // ========================= diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 7e3a4a1..102e59c 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -4,6 +4,7 @@ import { UnauthorizedException, BadRequestException, ConflictException, + ForbiddenException, InternalServerErrorException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; @@ -270,7 +271,11 @@ export class AuthService { return this.login({ email, password } as any, validatedUser); } - async createOrganization(userId: string, dto: CreateOrganizationDto) { + async createOrganization( + userId: string, + currentOrganizationId: string | undefined, + dto: CreateOrganizationDto, + ) { const owner = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true }, @@ -280,6 +285,28 @@ export class AuthService { throw new UnauthorizedException('User not found'); } + if (!currentOrganizationId) { + throw new ForbiddenException( + 'Select an organization before creating a new one.', + ); + } + + const currentMembership = await this.prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId, + organizationId: currentOrganizationId, + }, + }, + select: { isOwner: true }, + }); + + if (!currentMembership?.isOwner) { + throw new ForbiddenException( + 'Only owners of the current organization can create new organizations.', + ); + } + const organization = await this.prisma.$transaction(async (tx) => { const createdOrganization = await tx.organization.create({ data: { diff --git a/backend/src/modules/staff/staff.controller.ts b/backend/src/modules/staff/staff.controller.ts index e4732ea..8183f32 100644 --- a/backend/src/modules/staff/staff.controller.ts +++ b/backend/src/modules/staff/staff.controller.ts @@ -80,6 +80,32 @@ export class StaffController { return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto); } + @Patch('members/:membershipId/enable') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Re-enable a disabled staff member (uses one plan seat; no new invitation)', + }) + enableMember( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('membershipId') membershipId: string, + ) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffService.enableMember(req.user.id, organizationId, membershipId); + } + + @Patch('members/:membershipId/disable') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Disable staff member (frees a seat; member cannot access this organization)', + }) + disableMember( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('membershipId') membershipId: string, + ) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffService.disableMember(req.user.id, organizationId, membershipId); + } + @Delete('members/:membershipId') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Remove staff member from organization' }) diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index 2dcd3b2..60f4b23 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { createHash, randomBytes } from 'crypto'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto'; import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions'; @@ -399,6 +400,88 @@ export class StaffService { return { success: true, message: 'Member updated' }; } + async enableMember(actorUserId: string, organizationId: string, membershipId: string) { + const actor = await this.getActorMembership(actorUserId, organizationId); + if (!actor || !this.canEditStaff(actor)) { + throw new ForbiddenException('You cannot manage staff'); + } + + const target = await this.prisma.membership.findFirst({ + where: { id: membershipId, organizationId }, + include: { + invitations: { orderBy: { createdAt: 'desc' }, take: 1 }, + }, + }); + + if (!target) { + throw new NotFoundException('Member not found'); + } + if (target.isOwner) { + throw new ForbiddenException('Cannot enable the organization owner'); + } + if (target.isActive) { + throw new BadRequestException('This member is already active'); + } + + const invitation = target.invitations[0]; + if (invitation && !invitation.acceptedAt) { + throw new BadRequestException( + 'This member has not completed their invitation yet. Share the invite link instead.', + ); + } + + await this.prisma.$transaction(async (tx) => { + await this.assertOrganizationHasAvailableSeat(organizationId, tx); + await tx.membership.update({ + where: { id: membershipId }, + data: { isActive: true }, + }); + }); + + return { + success: true, + message: 'Member enabled. They can sign in to this organization again.', + }; + } + + async disableMember(actorUserId: string, organizationId: string, membershipId: string) { + const actor = await this.getActorMembership(actorUserId, organizationId); + if (!actor || !this.canEditStaff(actor)) { + throw new ForbiddenException('You cannot manage 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 disable the organization owner'); + } + if (actorUserId === target.userId) { + throw new BadRequestException('You cannot disable your own access'); + } + if (!target.isActive) { + throw new BadRequestException('This member is already disabled or pending activation'); + } + + await this.prisma.membership.update({ + where: { id: membershipId }, + data: { isActive: false }, + }); + + await this.prisma.session.deleteMany({ + where: { userId: target.userId }, + }); + + return { + success: true, + message: 'Member disabled. Their seat is now available for another invite.', + }; + } + async removeMember(actorUserId: string, organizationId: string, membershipId: string) { const actor = await this.getActorMembership(actorUserId, organizationId); if (!actor || !this.canEditStaff(actor)) { @@ -421,6 +504,37 @@ export class StaffService { return { success: true, message: 'Member removed' }; } + private async assertOrganizationHasAvailableSeat( + organizationId: string, + db: Prisma.TransactionClient | PrismaService = this.prisma, + ) { + const org = await db.organization.findUnique({ + where: { id: organizationId }, + include: { plan: true }, + }); + if (!org) { + throw new NotFoundException('Organization not found'); + } + if (!org.plan) { + throw new BadRequestException( + 'This organization has no active subscription. Please choose a plan before adding staff.', + ); + } + + const maxUsers = org.plan.maxUsers; + const seatsUsed = await db.membership.count({ + where: { + organizationId, + OR: [{ isOwner: true }, { isActive: true }], + }, + }); + if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) { + throw new BadRequestException( + `Your plan allows ${maxUsers} team members. Free a seat by disabling another member or upgrade your plan.`, + ); + } + } + private async getActorMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ where: { userId, organizationId }, @@ -435,11 +549,12 @@ export class StaffService { isOwner: boolean; isActive: boolean; invitations: { acceptedAt: Date | null; revokedAt: Date | null; expiresAt: Date }[]; - }): 'ACTIVE' | 'PENDING' | 'EXPIRED' { - if (m.isOwner || m.isActive) return 'ACTIVE'; + }): 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED' { + if (m.isOwner) return 'ACTIVE'; + if (m.isActive) return 'ACTIVE'; const invitation = m.invitations[0]; - if (!invitation) return 'EXPIRED'; - if (invitation.acceptedAt) return 'ACTIVE'; + if (invitation?.acceptedAt) return 'DISABLED'; + if (!invitation) return 'DISABLED'; if (invitation.revokedAt) return 'EXPIRED'; return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED'; } diff --git a/frontend/src/app/(dashboard)/appointments/page.tsx b/frontend/src/app/(dashboard)/appointments/page.tsx index 45513c8..ef0152d 100644 --- a/frontend/src/app/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/(dashboard)/appointments/page.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { appointmentsApi } from '@/lib/api/appointments'; import { patientsApi } from '@/lib/api/patients'; import { useAuth } from '@/lib/hooks/useAuth'; -import { canEditAppointments, hasPermission } from '@/shared/permissions'; +import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; import type { CreatePatientInput, Patient } from '@/types/patient'; import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; @@ -13,12 +13,12 @@ import { AppointmentBookingModal } from '@/components/ui/appointments/Appointmen import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid'; import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch'; import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; -import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker'; -import { ToastStack } from '@/components/ui/common/Toast'; +import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; +import { ToastStack } from '@/components/ui/shared/Toast'; import { useToast } from '@/lib/hooks/useToast'; import type { AppointmentPurpose } from '@/types/appointment'; -import { formatApiErrorMessage } from '@/lib/formatApiError'; -import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; const EMPTY_PATIENT_FORM: CreatePatientInput = { firstName: '', @@ -273,6 +273,7 @@ export default function AppointmentsPage() { if (!canEditPatients) { return; } + setPatientForm(EMPTY_PATIENT_FORM); setIsCreateOpen(true); }} /> @@ -285,7 +286,6 @@ export default function AppointmentsPage() {
Seats:{' '} @@ -400,18 +430,6 @@ export default function StaffPage() {
)} - {error && ( -