Skip to main content
Version: Latest
Technical ManualLooking for step-by-step instructions? In the User Manual: Scheduling

Scheduling

The Scheduling module books appointments with staff, rooms and equipment. It works out open times from each resource's working hours (or its shifts on a calendar that holds Shift events, such as one a workforce management module provides) less its busy time on the shared calendar, books them so a resource can never be taken twice, and puts every appointment on the resource's calendar. The admin screens are described in the User Manual.

ModuleCrestApps.OrchardCore.Scheduling
AbstractionsCrestApps.OrchardCore.Scheduling.Abstractions (models, IAppointmentService, ISchedulingAvailabilityService, IAppointmentObserver, permissions)
CoreCrestApps.OrchardCore.Scheduling.Core (stores, availability, booking)
CategoryCalendar
YesSql collectionScheduling

Features​

FeatureIDDepends onAdds
SchedulingCrestApps.OrchardCore.SchedulingCrestApps.OrchardCore.CalendarsAppointment types, resources, availability, appointments, holds, the waitlist, the Appointments calendar type and the admin screens under Calendars
Scheduling Self-ServiceCrestApps.OrchardCore.Scheduling.SelfServiceSchedulingPublic booking pages (book/{slug}) and manage links (book/manage/{id}/{token})
Scheduling APICrestApps.OrchardCore.Scheduling.ApiSchedulingThe /api/scheduling/v1 endpoints

Workflow activities are registered when OrchardCore.Workflows is enabled; Appointment Starting Soon also needs Calendar Reminders (CrestApps.OrchardCore.Calendars.Reminders). The AI tools are registered when CrestApps.OrchardCore.AI is enabled.

Permissions​

PermissionKeyDefault rolesGrants
Manage schedulingManageSchedulingAdministratorSet up appointment types and resources.
Manage appointmentsManageAppointmentsAdministrator, EditorBook, change and cancel appointments for anyone; manage the waitlist.
View appointmentsViewAppointmentsAdministrator, EditorSee appointments.

ManageScheduling implies ManageAppointments, which implies ViewAppointments.

Model​

All records live in the Scheduling collection.

  • SchedulingResource: Name, Kind (Staff, Room, Equipment, Other), UserId (a staff member's user, whose own calendars also make them busy), CalendarId (the calendar appointments go on, and whose events make the resource busy), TimeZoneId, AvailabilityMode (WorkingHours or Shifts), ShiftCalendarIds (and ShiftCalendarId, the single calendar of resources saved earlier, still read), WorkingHours (SchedulingWorkingHours: Day, Start, End as HH:mm; several per day, so breaks are the gaps), DateOverrides (SchedulingDateOverride: Date, Closed, Start, End), Enabled and BookingVersion. Saving a resource without a calendar creates one of the Appointments type with Source = Scheduling and SourceId set to the resource.
  • AppointmentType: Name, Description, Slug (the booking page address), DurationMinutes (30), SlotIntervalMinutes (30), BufferBeforeMinutes, BufferAfterMinutes, MinimumNoticeMinutes (60), HorizonDays (60), MaxPerDay (0 for no limit), MaxPerWeek (0 for no limit), MaxUpcomingPerCustomer (0 for no limit), CancelNoticeMinutes (0 for any time), RescheduleNoticeMinutes (0 for any time), Capacity (1; more makes a group session), ResourceIds, Assignment, ResourceRequirements, RequiresConfirmation, HoldMinutes (10), AllowSelfService, Location, Color, Enabled.
  • AppointmentAssignment: AnyAvailable (the first free resource in the list), RoundRobin (the free resource with the fewest recent appointments for its weight) or AllTogether (every resource of the list at once, such as a doctor and a nurse).
  • AppointmentResourceRequirement: one group of resources each appointment needs: Name (such as Staff or Room), ResourceIds (in order of preference), Assignment and Weights (round-robin weight by resource id; 1 when not listed). See Resource requirements.
  • Appointment: AppointmentTypeId, ResourceIds, StartUtc, EndUtc, TimeZoneId, Status, HoldExpiresUtc, CustomerName, CustomerEmail, CustomerPhone, UserId, Notes, CalendarEventId, ManageToken (the secret in the manage link), Source (Admin, SelfService, Api, AI or Workflow), CancelReason, created and modified stamps.
  • AppointmentStatus: Held, Requested, Confirmed, Completed, NoShow, Cancelled, Expired.
  • WaitlistEntry: AppointmentTypeId, FromUtc, ToUtc, CustomerName, CustomerEmail, CustomerPhone, Status (Waiting, Notified, Booked, Removed), CreatedUtc, NotifiedUtc.

Availability​

The video shows the rules on screen:

ISchedulingAvailabilityService.FindSlotsAsync(type, fromUtc, toUtc, resourceId) returns AppointmentSlots (StartUtc, EndUtc, the free ResourceIds, and Remaining places for a group session):

  1. The window is cut to the type's minimum notice and booking horizon.
  2. For each eligible resource, the open hours are its weekly working hours less its date overrides, in the resource's time zone, or in Shifts mode the Shift events on its shift calendars (ShiftCalendarIds, any calendars that hold a Shift event type, such as one per team that a workforce management module registers, so a staff member on two teams gets hours from both) that name its user as an attendee.
  3. Busy time comes from ICalendarAvailabilityService.GetBusyAsync for the resource's calendar and its user, which includes every busy-time source. Each busy interval is widened by the type's buffers. An appointment already booked also keeps the free time its own type asks for before and after it, so a time that falls in it isn't offered (a group session's own start still is, while it has places).
  4. Start times step by SlotIntervalMinutes, from the start of each open range. A time is offered when the appointment itself fits inside the open hours (its free time before and after only has to be clear of busy time, so it may run outside them), the resource's daily (MaxPerDay) and weekly (MaxPerWeek, Monday to Sunday in the resource's time zone) limits for the type aren't reached, and, for a group session, the session has places left.
  5. A time is offered only when every resource requirement can be met: a free resource in each group, or with AllTogether every resource of that group. A booked appointment makes each of its resources busy, not only the one whose calendar holds its event.

Resource requirements​

An appointment can need more than one kind of resource, such as one person from the staff and one room. AppointmentType.ResourceRequirements lists the groups, each with its own way of picking:

"resourceRequirements": [
{ "name": "Staff", "resourceIds": [ "drsmith00000001", "drjones00000001" ], "assignment": "RoundRobin", "weights": { "drsmith00000001": 2 } },
{ "name": "Room", "resourceIds": [ "room1000000001", "room2000000001" ], "assignment": "AnyAvailable" }
]
  • Availability is the intersection of the groups: a time is offered only when every group can be met at that time. AppointmentSlot.ResourceIds lists the free resources of every group.
  • Picking runs group by group and never takes the same resource twice: AnyAvailable takes the first free resource in the group's list, RoundRobin the free one with the fewest appointments in the last 30 days divided by its weight (ties go to the one listed first), and AllTogether every resource of the group. The appointment's ResourceIds hold the picks in group order, and its calendar event goes on the first one's calendar.
  • A chosen resource (AppointmentRequest.ResourceId, resourceId on the slots endpoint) narrows only the group it belongs to, so a customer can pick their staff member and still get any free room.
  • Moving an appointment keeps its resources when they are all free at the new time and still meet every group (and no group takes all its resources); otherwise it picks again.
  • Compatibility: a type with no ResourceRequirements has one requirement made of its ResourceIds and Assignment, so types saved before requirements existed work as they did; nothing is migrated. AppointmentTypeExtensions.GetResourceRequirements() returns the requirements either way, and GetAllResourceIds() every resource they can take. The editor saves one unnamed group without weights the old way, and several groups as ResourceRequirements, with every resource in ResourceIds too and the first group's assignment in Assignment.

A resource should be in only one group of a type; the editor refuses to save one in two groups.

Booking and concurrency​

IAppointmentService is the one way to change appointments. BookAsync(AppointmentRequest) books (or with Hold = true holds for the type's HoldMinutes), ConfirmAsync confirms a held or requested appointment, RescheduleAsync moves one, CancelAsync cancels one, CompleteAsync(appointment, noShow) records that it took place or that the customer didn't come, and ExpireHoldsAsync ends holds that ran out. Results are AppointmentResult (Succeeded, Errors, and Conflict when the time was taken).

A request sent twice takes effect once. BookAsync refuses, with Conflict, a booking for a customer (same email, phone or user) who already has an appointment of that type at that time, so a retried request can't take a second place in a group session. Confirming or cancelling twice fails the second time, and CompleteAsync with the outcome the appointment already has succeeds without raising a second change.

Every booking and reschedule:

  1. Takes a distributed lock SCHEDULING_RESOURCE_{id} on every resource it could take, from every resource requirement (narrowed by a chosen resource), in ordinal key order so two requests never deadlock. The calendars of those resources are locked too, under the name Calendars uses (CalendarLockKeys.Calendar), so an event added to a linked calendar such as a room can't take the time meanwhile. With a per-customer limit, the customer's SCHEDULING_CUSTOMER_... keys join the same ordered set.
  2. Checks availability again under the locks.
  3. Picks a resource (or all of them) from each requirement.
  4. Raises the BookingVersion of every resource it took, in every group, and saves them with YesSql's concurrency check, so two nodes can't book the same staff member, room or piece of equipment for the same time even when their bookings share only one resource.
  5. Writes the appointment and its calendar event (Source = Scheduling, SourceId = the appointment; tentative while held or requested, confirmed once booked) in the same session. The calendar write sets CalendarLocksHeld, so a calendar that limits overlapping events (a room) runs its own overlap check, under the locks Scheduling already holds, and refuses a time taken there.

Booking policies​

An appointment type can hold customers who book for themselves to stricter rules than staff. These rules are enforced inside IAppointmentService, so the public booking page, the AI tools, the API and workflows all get the same answer; they apply only when the call is marked as self-service: AppointmentRequest.SelfService = true for a booking, and selfService: true on CancelAsync and RescheduleAsync.

PropertySelf-service ruleError
MaxUpcomingPerCustomerA customer can hold at most this many held, requested or confirmed appointments of the type that haven't started yet. Customers are matched by user id, email (in any case) or phone number. The check runs under a lock on the customer (SCHEDULING_CUSTOMER_{typeId}_{identifier}), so two bookings at once can't both slip under the limit.Not a conflict; the message says how many the customer already has.
CancelNoticeMinutesA customer can't cancel within this many minutes of the start.The message says how much notice is needed.
RescheduleNoticeMinutesA customer can't move the appointment once it is within this many minutes of its start. The new time still has to be open.The message says how much notice is needed.

Who counts as self-service:

CallerSelf-service
Public booking page and manage linkAlways
AI toolsUnless the signed-in user has Manage appointments
Admin screens, HTTP API (which needs Manage appointments) and workflowsNever: staff and automations aren't held to these rules

AppointmentTypeExtensions.CustomerCanCancel and CustomerCanReschedule tell a page whether to offer those actions; the manage page hides them, with a note, once the notice has passed.

MinimumNoticeMinutes, HorizonDays, MaxPerDay and MaxPerWeek shape the open times themselves, so they apply to everyone.

A group session (Capacity > 1) shares one calendar event per time, with SourceId = session:.... A new booking is Confirmed, or Requested when the type asks for confirmation. Cancelling or moving an appointment frees its time: the oldest waiting WaitlistEntry whose range covers that time is marked Notified and the Waitlist Slot Available change is raised.

AppointmentHoldsBackgroundTask runs every minute and calls ExpireHoldsAsync: each expired hold is set to Expired and its calendar event is cancelled (for a group session, the customer is taken off the shared event).

Extension points​

ContractUse
IAppointmentServiceBook, confirm, reschedule, cancel and complete appointments from another module.
ISchedulingAvailabilityServiceFind open times for an appointment type.
IAppointmentSlotFilterFilterAsync(AppointmentSlotFilterContext, slots) narrows the open times the resources' hours give: it can drop slots or lower Remaining, never add any. The context has the AppointmentType and the ExcludeAppointmentId of a booking being moved. Booking and rescheduling check the time against the same slots, so a time a filter drops can't be booked. Dispatch uses it for technicians' capacity.
IAppointmentObserverOnChangedAsync(AppointmentChange) after the change commits: Kind (Held, Requested, Confirmed, Rescheduled, Cancelled, Completed, NoShow, HoldExpired, WaitlistSlotAvailable), Appointment, AppointmentType, PreviousStartUtc, WaitlistEntry, Actor. The workflow integration listens here.
DisplayDriver<SchedulingResource>, DisplayDriver<AppointmentType>Add a section to the resource editor (scheduling/resources/edit/{id?}) or the appointment type editor (scheduling/types/edit/{id?}), which are built with IDisplayManager<T>. The built-in fields are one driver each (SchedulingResourceFields_Edit and AppointmentTypeFields_Edit, at Content:1). Register yours with services.AddDisplayDriver<T, TDriver>(), bind your own view model with the driver's Prefix, and report errors under it ($"{Prefix}.Field"): any error keeps the record from being saved, and the editor cancels the session.
Calendar seamsAppointments are calendar events, so ICalendarEventObserver, the calendar workflows and free/busy all see them. Busy time from an ICalendarBusyTimeSource closes slots.

Self-service​

With Scheduling Self-Service on, an appointment type with Customers can book it themselves (AllowSelfService) and a slug gets a public page at /book/{slug}: pick a day, pick an open time (shown in the site's time zone), enter a name, email, phone and notes, and book. The customer lands on their manage page, /book/manage/{id}/{token}, whose secret ManageToken lets them move the appointment to another open time or cancel it without signing in. A disabled type, or one without self-service, returns 404.

Scheduling sends no email of its own. To send the confirmation and the manage link, add a workflow on Appointment Confirmed (or Appointment Requested) with a Send Email task; the input has AppointmentId and ManageToken to build the link.

Workflows​

The video builds a workflow from the Scheduling events and tasks:

Activities are in the Scheduling category.

EventRaised when
Appointment Requested (AppointmentRequestedEvent)A booking waits for confirmation.
Appointment Confirmed (AppointmentConfirmedEvent)An appointment is booked or confirmed.
Appointment Rescheduled (AppointmentRescheduledEvent)An appointment moves. PreviousStartUtc holds the old start.
Appointment Cancelled (AppointmentCancelledEvent)An appointment is cancelled.
Appointment Completed (AppointmentCompletedEvent)An appointment is marked as having taken place.
Appointment Marked No-Show (AppointmentNoShowEvent)The customer didn't come.
Reservation Hold Expired (AppointmentHoldExpiredEvent)A hold ran out.
Waitlist Slot Available (WaitlistSlotAvailableEvent)A freed time matched a waiting customer.
Appointment Starting Soon (AppointmentStartingSoonEvent)An appointment starts within Minutes before the start. Needs Calendar Reminders.

Each event can be limited to some appointment types (AppointmentTypeIds, empty for all). The input holds Appointment (the appointment's fields), AppointmentId, Id, AppointmentTypeId, AppointmentTypeName, ResourceIds, StartUtc, EndUtc, Status, CustomerName, CustomerEmail, CustomerPhone, UserId, Notes, CalendarEventId, ManageToken, Source, CancelReason, Actor and PreviousStartUtc. Waitlist Slot Available holds AppointmentTypeId, AppointmentTypeName, WaitlistEntryId, CustomerName, CustomerEmail, CustomerPhone and StartUtc (the freed time). Times are ISO 8601 UTC.

Tasks take Liquid fields; each can rename its output with OutputName.

TaskFieldsOutcomesOutput
Find Appointment SlotsAppointmentTypeId, From, ToFound, NoneAppointmentSlots
Book AppointmentAppointmentTypeId, Start, CustomerName, CustomerEmail, CustomerPhone, NotesDone, Taken, FailedAppointment
Confirm AppointmentAppointmentIdDone, FailedAppointment
Reschedule AppointmentAppointmentId, StartDone, Taken, FailedAppointment
Cancel AppointmentAppointmentId, ReasonDone, FailedAppointment
Get AppointmentAppointmentIdFound, NotFoundAppointment
Add to WaitlistAppointmentTypeId, From, To, CustomerName, CustomerEmail, CustomerPhoneDone, Failed

Appointments booked by a workflow have Source = Workflow.

Recipes and deployment​

The video builds a deployment plan with the Calendars and Scheduling steps:

The Scheduling deployment step exports the scheduling resources and appointment types, with Include appointments every appointment (with the customer's name, email and phone, but without its manage link), and with Include the waitlist the waitlist. Add the Calendars step too (with events when appointments are included), so the resources' calendars and the appointments' calendar events come along.

The Scheduling recipe step reads the same shape back: Resources, AppointmentTypes, Appointments and Waitlist, each created or, when its itemId matches a stored one, replaced in place. Run it after the Calendars step. An imported appointment keeps the manage link it already had on the site, or gets a new one. Property names are camelCase and enums are written by name (a number is read too).

{
"steps": [
{
"name": "Scheduling",
"Resources": [
{ "itemId": "drsmith00000001", "name": "Dr. Smith", "kind": "Staff", "calendarId": "drsmithcal00001", "timeZoneId": "America/New_York",
"availabilityMode": "WorkingHours", "workingHours": [ { "day": "Monday", "start": "09:00", "end": "17:00" } ], "enabled": true }
],
"AppointmentTypes": [
{ "itemId": "consultation001", "name": "Consultation", "slug": "consultation", "durationMinutes": 30, "slotIntervalMinutes": 30,
"resourceIds": [ "drsmith00000001" ], "assignment": "AnyAvailable", "allowSelfService": true, "enabled": true }
]
}
]
}

With the CrestApps Recipes feature on, the module describes the Scheduling recipe step (SchedulingRecipeStepSchema) and the deployment step's settings (SchedulingDeploymentStepSchema) to it.

Report Builder data source​

With Data Pipelines Core (OrchardCore.DataPipelines.Core) on, which the Report Builder needs, Scheduling adds the Scheduling data source (Scheduling) to the Report Builder and to Orchard Core data pipelines. Appointments, Appointment resources, Appointment types and Scheduling resources need View appointments (ViewAppointments); Waitlist needs Manage appointments (ManageAppointments), as the waitlist screen does. The customer's notes, the reason an appointment was canceled, the link that lets a customer change it, and resources' working hours are never exposed.

Data setRowsFields
Appointments (Appointments)One per appointment, newest first by Starts, the default dateItemId, Status (Held, Requested, Confirmed, Completed, NoShow, Cancelled, Expired), IsCancelled, IsNoShow, AppointmentTypeId (references AppointmentTypes), ResourceIds (comma separated), ResourceCount, Source (Admin, SelfService, Api, AI or Workflow), CalendarEventId (references Calendars > CalendarEvents), CustomerName, CustomerEmail, CustomerPhone, UserId (references Users), StartUtc, EndUtc, DurationMinutes, TimeZoneId, CreatedUtc (booked), LeadTimeMinutes (from booking to start), HoldExpiresUtc, ModifiedUtc
AppointmentResources (Appointment resources)One per resource an appointment books, so appointments can be counted by staff member or roomAppointmentId (references Appointments), ResourceId (references SchedulingResources), Position, AppointmentTypeId, Status, StartUtc, EndUtc, DurationMinutes
AppointmentTypes (Appointment types)One per appointment typeItemId, Name, Slug, Location, Enabled, AllowSelfService, RequiresConfirmation, Assignment, ResourceIds, ResourceCount, DurationMinutes, SlotIntervalMinutes, BufferBeforeMinutes, BufferAfterMinutes, MinimumNoticeMinutes, HorizonDays, MaxPerDay, Capacity, HoldMinutes
SchedulingResources (Scheduling resources)One per staff member, room, piece of equipment or other resourceItemId, Name, Kind, UserId (references Users), CalendarId and ShiftCalendarId (reference Calendars > Calendars), AvailabilityMode, TimeZoneId, Enabled
WaitlistEntries (Waitlist)One per waitlist entry, newest first by when the customer joinedItemId, Status (Waiting, Notified, Booked, Removed), AppointmentTypeId, CustomerName, CustomerEmail, CustomerPhone, CreatedUtc, FromUtc, ToUtc, NotifiedUtc

Date ranges on the default date, joins on the record, appointment type and customer user IDs, and status filters are applied in the database. Simple groups and totals of appointments are counted there too, except by the customer's name, email address or phone number, which the index does not keep exactly. Appointments do not record how many times they were moved or when they were canceled, so neither is reported; Last changed is the last change of any kind.

The UpdateFrom1Async migration step adds the database indexes appointments (StartUtc, Id) and waitlist entries (CreatedUtc, Id) are read through.

HTTP API​

The Scheduling API feature maps /api/scheduling/v1, authenticated with Orchard Core's Api scheme.

Method and routeDoesPermission
GET typesThe appointment types: id, name, description, durationMinutes, capacity, requiresConfirmation, enabled, and resourceRequirements (name, resourceIds, assignment, weights; one entry for a type with a single resource list).View appointments
GET types/{id}/slots?from=&to=&resourceId=Open times between two ISO 8601 times (at most 31 days): startUtc, endUtc, resourceIds (the free resources of every requirement), remaining. resourceId narrows the requirement it belongs to.View appointments
POST appointmentsBook from an AppointmentRequest (appointmentTypeId, startUtc, resourceId, customerName, customerEmail, customerPhone, userId, notes, timeZoneId, hold). resourceId picks the resource of its own requirement; the others are picked by their assignment. Returns 201.Manage appointments
GET appointments/{id}One appointment.View appointments
POST appointments/{id}/confirmConfirm a held or requested appointment.Manage appointments
POST appointments/{id}/rescheduleMove it: { "startUtc": "...", "resourceId": null }.Manage appointments
POST appointments/{id}/cancelCancel it: { "reason": "..." }.Manage appointments

A time that was taken, or a booking the customer already has, returns 409 Conflict as Problem Details. A missing body, or a slot range whose end is not after its start, returns 400.

AI tools​

The video shows the Scheduling tools in an AI profile:

With CrestApps.OrchardCore.AI enabled, Scheduling registers these tools in the Scheduling category (an AIToolsStartup with [RequireFeatures("CrestApps.OrchardCore.AI")]). They are meant for customer-facing assistants, so they work for anonymous visitors, but only with appointment types that are enabled and allow self-service, unless the caller has Manage appointments.

FunctionTitleDescription
listAppointmentTypesList appointment typesLists the kinds of appointments that can be booked.
findAppointmentSlotsFind appointment slotsFinds open times for an appointment.
bookAppointmentBook appointmentBooks an appointment for the customer at a startUtc that findAppointmentSlots returned. The result names the staff, rooms and equipment it is with.
changeAppointmentChange appointmentCancels or moves a customer's appointment. The caller proves it is theirs with the manage code, or the email or phone number on the appointment (a phone number only when the saved one has digits), unless it has Manage appointments.

Customers using the tools are held to the type's booking policies; a caller with Manage appointments isn't.

See AI Tools.