feature: a minimal implementation of the appointment feature done.
This commit is contained in:
@@ -9,6 +9,7 @@ import { PrismaModule } from '../prisma/prisma.module'; // ✅
|
||||
import { PatientsModule } from './modules/patients/patients.module';
|
||||
import { StaffModule } from './modules/staff/staff.module';
|
||||
import { OrganizationModule } from './modules/organization/organization.module';
|
||||
import { AppointmentsModule } from './modules/appointments/appointments.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -19,6 +20,7 @@ import { OrganizationModule } from './modules/organization/organization.module';
|
||||
PrismaModule, // ✅ ADD THIS
|
||||
AuthModule,
|
||||
PatientsModule,
|
||||
AppointmentsModule,
|
||||
StaffModule,
|
||||
OrganizationModule,
|
||||
AdminModule.forRoot(),
|
||||
|
||||
51
backend/src/modules/appointments/appointments.controller.ts
Normal file
51
backend/src/modules/appointments/appointments.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AppointmentsService } from './appointments.service';
|
||||
import { CreateAppointmentDto } from './dto/create-appointment.dto';
|
||||
import { ListAppointmentsDto } from './dto/list-appointments.dto';
|
||||
|
||||
@ApiTags('appointments')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('appointments')
|
||||
export class AppointmentsController {
|
||||
constructor(private readonly appointmentsService: AppointmentsService) {}
|
||||
|
||||
@Get('column-providers')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Staff columns: active non-owner members with TAB_TREATMENT_EDIT. Owners are excluded. Requires TAB_APPOINTMENTS_READ or owner.',
|
||||
})
|
||||
columnProviders(@Req() req: { user: { id: string; organizationId?: string } }) {
|
||||
const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.appointmentsService.listColumnProviders(organizationId, req.user.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List appointments intersecting a time range (requires TAB_APPOINTMENTS_READ or owner)' })
|
||||
list(@Query() query: ListAppointmentsDto, @Req() req: { user: { id: string; organizationId?: string } }) {
|
||||
const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.appointmentsService.list(query, organizationId, req.user.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create appointment (requires TAB_APPOINTMENTS_EDIT or owner)' })
|
||||
create(
|
||||
@Body() dto: CreateAppointmentDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.appointmentsService.create(dto, organizationId, req.user.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete appointment (requires TAB_APPOINTMENTS_EDIT or owner)' })
|
||||
remove(
|
||||
@Param('id') id: string,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.appointmentsService.remove(id, organizationId, req.user.id);
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/appointments/appointments.module.ts
Normal file
10
backend/src/modules/appointments/appointments.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { AppointmentsController } from './appointments.controller';
|
||||
import { AppointmentsService } from './appointments.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AppointmentsController],
|
||||
providers: [AppointmentsService, PrismaService],
|
||||
})
|
||||
export class AppointmentsModule {}
|
||||
235
backend/src/modules/appointments/appointments.service.ts
Normal file
235
backend/src/modules/appointments/appointments.service.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { CreateAppointmentDto } from './dto/create-appointment.dto';
|
||||
import { ListAppointmentsDto } from './dto/list-appointments.dto';
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
@Injectable()
|
||||
export class AppointmentsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
|
||||
async listColumnProviders(organizationId: string, actorUserId: string) {
|
||||
await this.assertCanViewAppointments(actorUserId, organizationId);
|
||||
|
||||
const members = await this.prisma.membership.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
isOwner: false,
|
||||
isActive: true,
|
||||
permissions: {
|
||||
some: {
|
||||
permission: {
|
||||
name: 'TAB_TREATMENT_EDIT',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: 'asc' }],
|
||||
});
|
||||
|
||||
const data = members.map((m) => ({ userId: m.user.id, name: m.user.name }));
|
||||
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
async list(query: ListAppointmentsDto, organizationId: string, actorUserId: string) {
|
||||
await this.assertCanViewAppointments(actorUserId, organizationId);
|
||||
|
||||
const from = new Date(query.from);
|
||||
const to = new Date(query.to);
|
||||
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||
throw new BadRequestException('Invalid date range');
|
||||
}
|
||||
|
||||
if (to <= from) {
|
||||
throw new BadRequestException('Range "to" must be after "from"');
|
||||
}
|
||||
|
||||
const items = await this.prisma.appointment.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
startAt: { lt: to },
|
||||
endAt: { gt: from },
|
||||
},
|
||||
include: {
|
||||
patient: {
|
||||
select: { id: true, firstName: true, lastName: true, phone: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ startAt: 'asc' }],
|
||||
});
|
||||
|
||||
return { success: true, data: items };
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateAppointmentDto,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanEditAppointments(actorUserId, organizationId);
|
||||
|
||||
const startAt = new Date(dto.startAt);
|
||||
const endAt = new Date(dto.endAt);
|
||||
|
||||
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
|
||||
throw new BadRequestException('Invalid start or end time');
|
||||
}
|
||||
|
||||
if (endAt <= startAt) {
|
||||
throw new BadRequestException('End time must be after start time');
|
||||
}
|
||||
|
||||
if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
|
||||
throw new BadRequestException('Appointment cannot span more than 24 hours');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (startAt.getTime() < now) {
|
||||
throw new BadRequestException('Cannot schedule appointments in the past');
|
||||
}
|
||||
|
||||
await this.ensurePatientInOrg(dto.patientId, organizationId);
|
||||
await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId);
|
||||
|
||||
const overlap = await this.prisma.appointment.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
providerUserId: dto.providerUserId,
|
||||
startAt: { lt: endAt },
|
||||
endAt: { gt: startAt },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (overlap) {
|
||||
throw new BadRequestException('This time slot overlaps an existing appointment for that provider');
|
||||
}
|
||||
|
||||
const appointment = await this.prisma.appointment.create({
|
||||
data: {
|
||||
organizationId,
|
||||
patientId: dto.patientId,
|
||||
providerUserId: dto.providerUserId,
|
||||
startAt,
|
||||
endAt,
|
||||
purpose: dto.purpose,
|
||||
},
|
||||
include: {
|
||||
patient: {
|
||||
select: { id: true, firstName: true, lastName: true, phone: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: appointment };
|
||||
}
|
||||
|
||||
async remove(id: string, organizationId: string, actorUserId: string) {
|
||||
await this.assertCanEditAppointments(actorUserId, organizationId);
|
||||
|
||||
const existing = await this.prisma.appointment.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Appointment not found');
|
||||
}
|
||||
|
||||
await this.prisma.appointment.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async assertCanViewAppointments(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
if (m.isOwner) {
|
||||
return;
|
||||
}
|
||||
const names = m.permissions.map((p) => p.permission.name);
|
||||
if (names.includes('TAB_APPOINTMENTS_READ')) {
|
||||
return;
|
||||
}
|
||||
if (names.includes('TAB_TREATMENT_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You do not have access to appointments');
|
||||
}
|
||||
|
||||
private async assertCanEditAppointments(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
if (m.isOwner) {
|
||||
return;
|
||||
}
|
||||
const names = m.permissions.map((p) => p.permission.name);
|
||||
if (names.includes('TAB_APPOINTMENTS_EDIT')) {
|
||||
return;
|
||||
}
|
||||
if (names.includes('TAB_TREATMENT_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You cannot create or modify appointments');
|
||||
}
|
||||
|
||||
private async ensureProviderIsTreatmentEditor(providerUserId: string, organizationId: string) {
|
||||
const m = await this.getMembership(providerUserId, organizationId);
|
||||
if (!m) {
|
||||
throw new BadRequestException('Provider is not a member of this organization');
|
||||
}
|
||||
if (m.isOwner) {
|
||||
throw new BadRequestException(
|
||||
'Appointments must be assigned to staff with treatment access, not the organization owner',
|
||||
);
|
||||
}
|
||||
if (!m.isActive) {
|
||||
throw new BadRequestException('Provider is not an active staff member');
|
||||
}
|
||||
const names = m.permissions.map((p) => p.permission.name);
|
||||
if (!names.includes('TAB_TREATMENT_EDIT')) {
|
||||
throw new BadRequestException('Provider does not have treatment edit access');
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePatientInOrg(patientId: string, organizationId: string) {
|
||||
const patient = await this.prisma.patient.findFirst({
|
||||
where: { id: patientId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!patient) {
|
||||
throw new NotFoundException('Patient not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async getMembership(userId: string, organizationId: string) {
|
||||
return this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId },
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsDateString, IsIn, IsUUID } from 'class-validator';
|
||||
|
||||
const APPOINTMENT_PURPOSES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
|
||||
|
||||
export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number];
|
||||
|
||||
export class CreateAppointmentDto {
|
||||
@ApiProperty()
|
||||
@IsUUID()
|
||||
patientId: string;
|
||||
|
||||
@ApiProperty({ description: 'Staff user id (column provider)' })
|
||||
@IsUUID()
|
||||
providerUserId: string;
|
||||
|
||||
@ApiProperty({ example: '2026-05-06T09:15:00.000Z' })
|
||||
@IsDateString()
|
||||
startAt: string;
|
||||
|
||||
@ApiProperty({ example: '2026-05-06T09:45:00.000Z' })
|
||||
@IsDateString()
|
||||
endAt: string;
|
||||
|
||||
@ApiProperty({ enum: APPOINTMENT_PURPOSES })
|
||||
@IsIn([...APPOINTMENT_PURPOSES])
|
||||
purpose: AppointmentPurpose;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsDateString } from 'class-validator';
|
||||
|
||||
export class ListAppointmentsDto {
|
||||
@ApiProperty({ description: 'Range start (ISO 8601), e.g. local midnight as instant' })
|
||||
@IsDateString()
|
||||
from: string;
|
||||
|
||||
@ApiProperty({ description: 'Range end (ISO 8601), e.g. next local midnight' })
|
||||
@IsDateString()
|
||||
to: string;
|
||||
}
|
||||
Reference in New Issue
Block a user