← writing

The Hard Part: Appointment Scheduling, Conflict Detection, and Role-Aware Queries

4 min read
nestjsprismabusiness-logicappointmentsbackendRhizoBook

The appointment module is where most of the interesting backend decisions live. Booking, cancellation, and querying appointments all have constraints that can’t just be delegated to Prisma — they require explicit logic in the service layer.

Conflict detection on booking

When a patient books an appointment, the service needs to verify that the provider isn’t already booked in that time slot. The check is a findFirst query against existing appointments for the same provider and an overlapping time window:

const conflict = await this.prisma.appointment.findFirst({
  where: {
    providerId,
    status: 'SCHEDULED',
    startTime: { lt: endTime },
    endTime: { gt: startTime },
  },
});
if (conflict) throw new ConflictException('Provider is not available at this time');

The overlap condition (startTime < endTime && endTime > startTime) catches all cases: contained slots, straddling slots, exact duplicates. Only SCHEDULED appointments count — a cancelled appointment doesn’t block the slot.

DTOs validate that the submitted startTime is a valid ISO 8601 datetime string with @IsDateString() before any of this logic runs. Malformed dates are rejected at the boundary; the service can trust the input is parseable.

Cancel flow and the state machine

Cancellation is PATCH /appointments/:id/cancel. The service enforces a one-way transition:

if (appointment.status !== AppointmentStatus.SCHEDULED) {
  throw new ConflictException('Only scheduled appointments can be cancelled');
}

SCHEDULED → CANCELLED is the only allowed move. Trying to cancel an already-cancelled appointment throws a 409. Trying to cancel a completed appointment throws a 409. The COMPLETED status exists in the schema for future use — there’s currently no flow that sets it.

The cancel endpoint accepts an optional cancellationReason string in the request body, stored to the Appointment record. This supports future patient-facing history views where the reason for cancellation is meaningful.

Role-aware GET /appointments

The most interesting design decision in this module: GET /appointments returns different data depending on who calls it, using a single endpoint.

The JWT payload contains roleId. The service reads it and branches:

if (roleName === 'provider') {
  return this.prisma.appointment.findMany({ where: { providerId: userId } });
} else {
  return this.prisma.appointment.findMany({ where: { patientId: userId } });
}

Providers see their own upcoming appointments. Patients see their own bookings. Same route, same guard, different query. This avoids the proliferation of /provider/appointments vs. /patient/appointments endpoints that would require separate documentation, separate guards, and separate tests for what is conceptually one operation.

GET /appointments/:id has an additional access control check: the caller must be either the appointment’s provider or its patient. Anyone else gets a 403. This is enforced in the service after fetching the appointment — the guard verifies authentication, but authorization (whether this user can see this appointment) is a business rule.

Per-controller guards vs. a global guard

All appointment routes use @UseGuards(JwtAuthGuard) at the controller level. The provider list routes have no guard. This split is intentional (documented back in Post 1) and the appointments module reinforces why: every operation here requires knowing who the caller is. The guard isn’t optional and it’s not an afterthought — it’s the first line of every controller method’s preconditions.

A global guard with @Public() decorator exceptions would also work. The per-controller approach makes the contract explicit in the file you’re reading, without requiring you to know about a global guard or a decorator convention.

What was punted

Two features are notably absent:

Timezone handling. All datetimes are stored and returned as UTC. There’s no client-side offset logic, no timezone field on users or providers, no conversion in the API. This is fine for an MVP where everyone is assumed to be in the same timezone, and it’s a known gap. Adding timezone support will require storing a timezone field on ProviderProfile (or User), converting availability slots on display, and handling DST edge cases — a real project, not a small addition.

Recurring availability schedules. Each AvailabilitySlot is independent. There’s no concept of “every Monday 9–5 recurring indefinitely.” Slots are created and managed individually. This is acceptable for the current scope and would need a recurrence model (RRULE or a simpler weekly pattern) to change.

Key files:

// comments via github discussions