Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/0Crazy-0/ClinicFlow/llms.txt

Use this file to discover all available pages before exploring further.

ClinicFlow protects clinic capacity through an automated penalty system. When a patient misses an appointment or cancels too late, the system records a PatientPenalty against them. Repeat offenders receive progressively longer booking blocks — all driven by domain logic without any manual staff intervention. Staff retain the ability to apply manual blocks or remove penalties when circumstances warrant.

Penalty Types

The PenaltyType enum classifies every penalty record:
ValueDescription
WarningA non-blocking advisory notice. Does not prevent future bookings on its own.
TemporaryBlockA time-limited block. Prevents the patient from booking new appointments until BlockedUntil expires.

When Penalties Are Triggered

Penalties are applied automatically by domain event handlers that listen to two appointment lifecycle events:
  • AppointmentMarkedAsNoShowEvent — raised when MarkAsNoShowByStaff() or MarkAsNoShowByDoctor(...) is called on a Scheduled appointment.
  • AppointmentLateCancelledEvent — raised when CancelLate(...) is called, which happens when a patient cancels after the specialty’s CancellationLimit notice window has passed.
Both events invoke PatientPenaltyService.ApplyPenalty(...).

PatientPenaltyService: Automatic Penalty Logic

// ClinicFlow.Domain/Services/PatientPenaltyService.cs
public static IEnumerable<PatientPenalty> ApplyPenalty(
    Guid patientId,
    IReadOnlyList<PatientPenalty> existingPenalties,
    Guid appointmentId,
    string reason,
    DateTime referenceTime
)
{
    var history = new PenaltyHistory(existingPenalties);
    var penaltiesToApply = new List<PatientPenalty>();

    var warning = PatientPenalty.CreateAutomaticWarning(patientId, appointmentId, reason);
    penaltiesToApply.Add(warning);

    if (
        !history.HasPriorWarnings
        || history.IsCurrentlyBlocked(DateOnly.FromDateTime(referenceTime))
    )
        return penaltiesToApply;

    var duration = history.DetermineNextBlockDuration();

    var block = PatientPenalty.CreateAutomaticBlock(
        patientId,
        PenaltyReasons.AutomaticBlock,
        duration,
        referenceTime
    );

    penaltiesToApply.Add(block);

    return penaltiesToApply;
}
The service always creates a Warning. It then decides whether to also create an automatic TemporaryBlock based on two conditions:
  • history.HasPriorWarnings — there must be at least one existing warning on record (the current warning has not yet been persisted, so “prior” means history at the time of the call).
  • !history.IsCurrentlyBlocked(date) — the patient must not already be under an active block. A patient who is currently blocked does not receive another block for the same event; they only get the warning.
1

No-show event fires

A staff member or the attending doctor marks the appointment as a no-show. The AppointmentMarkedAsNoShowEvent domain event is dispatched after the UnitOfWork commit.
2

Event handler invokes PatientPenaltyService

The application-layer event handler calls PatientPenaltyService.ApplyPenalty(patientId, existingPenalties, appointmentId, PenaltyReasons.NoShow, referenceTime).
3

Warning is always created

PatientPenalty.CreateAutomaticWarning(...) produces a Warning record linked to the appointment ID. BlockedUntil is null.
4

Block escalation decision

PenaltyHistory checks HasPriorWarnings. If this is the patient’s first offence, only the warning is returned — no block. If prior warnings exist and the patient is not already blocked, the block escalation path continues.
5

Block duration determined

PenaltyHistory.DetermineNextBlockDuration() walks the escalation ladder: first block → Minor (5 days), second → Moderate (15 days), third and beyond → Severe (30 days).
6

TemporaryBlock is created

PatientPenalty.CreateAutomaticBlock(...) produces a TemporaryBlock record. BlockedUntil is set to referenceDate + blockDuration (days). This record has no AppointmentId — it is a system-generated aggregate.
7

Penalties are persisted

The event handler persists all returned PatientPenalty entities through the repository and calls UnitOfWork.SaveChangesAsync.

Block Duration Escalation

Block severity escalates automatically based on the patient’s total historical block count, regardless of whether past blocks have expired or been removed. The escalation ladder is capped at Severe.
BlockDurationDaysWhen Applied
Minor5First automatic block (patient has 0 prior blocks)
Moderate15Second automatic block (patient has 1 prior block)
Severe30Third block and every block thereafter
// ClinicFlow.Domain/ValueObjects/PenaltyHistory.cs
private static readonly BlockDuration[] EscalationLadder =
[
    BlockDuration.Minor,
    BlockDuration.Moderate,
    BlockDuration.Severe,
];

public BlockDuration DetermineNextBlockDuration()
{
    var escalationLevel = Math.Min(TotalHistoricalBlocks, EscalationLadder.Length - 1);
    return EscalationLadder[escalationLevel];
}
TotalHistoricalBlocks counts all TemporaryBlock records in the patient’s history, including those that have already expired or been manually removed by staff. A patient cannot reset their escalation level by waiting for blocks to expire.

Scheduling Gate: PenaltyHistory.EnsureNotBlocked

At scheduling and rescheduling time (patient-initiated only), the domain checks for active blocks:
// ClinicFlow.Domain/ValueObjects/PenaltyHistory.cs
public void EnsureNotBlocked(DateOnly referenceDate)
{
    var activePenalties = _penalties
        .Where(p =>
            !p.IsRemoved
            && p.Type is PenaltyType.TemporaryBlock
            && p.BlockedUntil.HasValue
            && p.BlockedUntil.Value > referenceDate
        )
        .ToList();

    if (activePenalties.Count > 0)
    {
        throw new PatientBlockedException(
            DomainErrors.Patient.Blocked,
            activePenalties.Max(p => p.BlockedUntil) ?? referenceDate
        );
    }
}
PatientBlockedException carries the latest BlockedUntil date so callers can communicate exactly when the patient becomes eligible to book again.

PatientPenalty Fields

FieldTypeDescription
PatientIdGuidThe patient this penalty applies to
AppointmentIdGuid?Linked appointment for automatic warnings; null for manual or automatic blocks
TypePenaltyTypeWarning or TemporaryBlock
ReasonstringHuman-readable reason (e.g., "No show", "Late cancellation", "Automatic block due to 3 strikes")
BlockedUntilDateOnly?Expiry date for TemporaryBlock; null for Warning
IsRemovedboolSet to true by staff via Remove(). Excluded from all active-block checks.

Staff-Initiated Penalties

Staff can issue penalties independently of the automatic system through two Application-layer commands.

BlockPatient

Staff call the BlockPatient command to issue a manual TemporaryBlock with an explicit BlockDuration:
// PatientPenalty.CreateManualBlock — called by the BlockPatient command handler
public static PatientPenalty CreateManualBlock(
    Guid patientId,
    string reason,
    BlockDuration duration,
    DateTime referenceTime
)
{
    // ...validation...
    return new PatientPenalty(
        patientId,
        null,                         // no AppointmentId for manual blocks
        PenaltyType.TemporaryBlock,
        reason,
        DateOnly.FromDateTime(referenceTime).AddDays((int)duration)
    );
}
Manual blocks accept any BlockDuration value (Minor, Moderate, or Severe) and do not go through the escalation ladder.

RemovePenalty

Staff call the RemovePenalty command to soft-remove any existing penalty:
// PatientPenalty.Remove()
public void Remove()
{
    if (IsRemoved)
        throw new DomainValidationException(DomainErrors.Penalty.AlreadyRemoved);

    IsRemoved = true;
}
Setting IsRemoved = true immediately excludes the penalty from EnsureNotBlocked and IsCurrentlyBlocked checks. The record is preserved for audit purposes — it is never physically deleted.

CancellationLimit: Per-Specialty Notice Windows

The trigger point for a LateCancellation (which carries a penalty) is determined per medical specialty by the CancellationLimit value object on MedicalSpecialty.CancellationPolicy:
// ClinicFlow.Domain/ValueObjects/CancellationLimit.cs
public static readonly IReadOnlyCollection<int> AllowedHours = Array.AsReadOnly([
    0,   // No cancellation restriction
    12,
    24,
    48,
    72,
]);

internal bool IsNoticePeriodMet(DateTime appointmentDateTime, DateTime referenceTime)
{
    var timeUntilAppointment = (appointmentDateTime - referenceTime).TotalHours;
    return timeUntilAppointment >= Hours;
}
Only the pre-approved hour values are accepted — arbitrary values are rejected at construction time. A value of 0 means the specialty has no cancellation restriction, and cancellations are always penalty-free.
MedicalSpecialty.IsCancellationAllowed(appointmentDateTime, referenceTime) is a convenience wrapper that delegates to CancellationPolicy.IsNoticePeriodMet(...). It is called by AppointmentCancellationService.CancelByPatient to decide between Cancel (no penalty) and CancelLate (penalty).

Summary: Automatic vs. Manual Penalties

Automatic (System)

Triggered by NoShow or LateCancellation events. Always starts with a Warning. Escalates to a TemporaryBlock on the second offence and beyond, using the progressive Minor → Moderate → Severe ladder.

Manual (Staff)

BlockPatient command creates a TemporaryBlock with any duration chosen by staff. RemovePenalty command soft-removes any penalty. Neither action affects the escalation history counter used for automatic blocks.

Build docs developers (and LLMs) love