feature: a minimal implementation of the appointment feature done.

This commit is contained in:
2026-05-06 23:05:37 +03:30
parent bc06be3ca6
commit 8123a94a3d
23 changed files with 1542 additions and 9 deletions

View File

@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "appointments" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"patientId" TEXT NOT NULL,
"providerUserId" TEXT NOT NULL,
"startAt" TIMESTAMP(3) NOT NULL,
"endAt" TIMESTAMP(3) NOT NULL,
"purpose" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "appointments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "appointments_organizationId_providerUserId_startAt_idx" ON "appointments"("organizationId", "providerUserId", "startAt");
-- CreateIndex
CREATE INDEX "appointments_organizationId_startAt_idx" ON "appointments"("organizationId", "startAt");
-- AddForeignKey
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "patients"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -60,6 +60,7 @@ model Organization {
sharedWithOthers OrganizationLink[] @relation("OrganizationA")
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
patients Patient[]
appointments Appointment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -82,6 +83,7 @@ model Patient {
organization Organization @relation(fields: [organizationId], references: [id])
treatments PatientTreatmentHistory[]
appointments Appointment[]
@@index([organizationId, createdAt])
@@index([organizationId, lastName, firstName])
@@ -106,6 +108,26 @@ model PatientTreatmentHistory {
@@map("patient_treatment_histories")
}
model Appointment {
id String @id @default(uuid())
organizationId String
patientId String
providerUserId String
startAt DateTime
endAt DateTime
purpose String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, providerUserId, startAt])
@@index([organizationId, startAt])
@@map("appointments")
}
model Plan {
id String @id @default(uuid())
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"

View File

@@ -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(),

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

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

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

View File

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

View File

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