Booking module
The Booking module schedules, reschedules and cancels appointments, keeps local copies of companies, staff and customers fed by events, and serves the dashboard statistics.
- Owns
- Appointments and the dashboard statistics
- Schema
booking, four tables- Endpoints
- 8, behind
appointmentspermissions - Copies
- Companies, staff and customers from other modules
What’s inside
Section titled “What’s inside”Booking owns appointments, and keeps its own copies of the companies, staff and customers it needs, so it never reads another module’s tables.
| Feature | Folder | What it does |
|---|---|---|
| Appointments | Appointments/ |
Schedule, reschedule, cancel, list by date range, get by id. Status is Scheduled or Cancelled. |
| Service providers | ServiceProviders/ |
A copy of every Organizations employee with the Staff role, under the same id. |
| Attendees | Attendees/ |
A copy of every CRM customer, under the same id. |
| Booking companies | BookingCompanies/ |
A copy of each company’s id, name and time zone. Appointments reference it. |
| Dashboard | Dashboard/ |
Appointment counts, booked minutes, utilization, returning-client rate, trends, staff load and peak hours. |
| Demo data | SeedDemoAppointments/, TopUpDemoAppointments/ |
Up to three appointments per seeded demo attendee, plus a daily top-up. |
| Contracts | AppointMe.Booking.Contracts |
Three appointment events, plus AttendeeProjected and ServiceProviderProjected. |
The three copies are projections: other modules’ events keep them current, and recurring jobs repair them. The staff and customer copies have their own ID types (ServiceProviderId, AttendeeId), and BookingCompany reuses CompanyId, as described in IDs & value objects.
Folder map
Section titled “Folder map”Directorysrc/Booking/
DirectoryAppointMe.Booking/
DirectoryAppointments/
DirectoryScheduleAppointment/
- …
DirectoryRescheduleAppointment/
- …
DirectoryCancelAppointment/
- …
- …
DirectoryAttendees/
DirectoryReconcileAttendees/ # CRM event handlers, daily job
- …
- …
DirectoryBookingCompanies/
DirectoryReconcileBookingCompanies/ # Organizations event handler, hourly job
- …
- …
DirectoryServiceProviders/
DirectoryReconcileServiceProviders/ # Organizations event handlers, hourly job
- …
- …
DirectoryDashboard/ # GetDashboardStats, GetPeakHours
- …
DirectoryConfiguration/ # AddBookingModule
- …
DirectoryDatabase/ # BookingDbContext, BookingFilters, migrations
- …
- …
DirectoryAppointMe.Booking.Contracts/
- …
DirectoryAppointMe.Booking.Tests/
- …
Endpoints
Section titled “Endpoints”Eight endpoints, all needing an appointments permission and X-Company-Id, and no write endpoints for providers or attendees, because Organizations and CRM own them.
| Method | Route | Permission | Purpose |
|---|---|---|---|
| GET | /api/v1/appointments |
appointments:view |
Appointments that overlap From to To, cancelled ones included, sorted by start, with provider and attendee names. |
| GET | /api/v1/appointments/{id} |
appointments:view |
One appointment with names. |
| POST | /api/v1/appointments |
appointments:schedule |
Body { providerId, attendeeId, start, end, notes? }. Checks that the provider and attendee exist in the company. Returns 201 with the id. |
| PUT | /api/v1/appointments/{id}/reschedule |
appointments:reschedule |
Body { providerId, start, end }. Only a Scheduled appointment. Returns 204. |
| POST | /api/v1/appointments/{id}/cancel |
appointments:cancel |
Sets Cancelled. Only a Scheduled appointment. Returns 204. |
| GET | /api/v1/booking/service-providers |
appointments:view |
Active providers, sorted by name. |
| GET | /api/v1/booking/dashboard/stats |
appointments.statistics:view |
Query From, To, Bucket (day, week or month), TimeZone, and optionally CompareFrom with CompareTo. KPIs, trend buckets and per-provider load. |
| GET | /api/v1/booking/dashboard/peak-hours |
appointments.statistics:view |
Query From, To, TimeZone. Average bookings for each weekday and hour. |
Every time range goes through DateTimeOffsetPeriod.Create, so it must be UTC with the end after the start, or the call fails with 400. Scheduling doesn’t check for overlaps: a provider can be double-booked.
The dashboard counts only scheduled appointments of active providers. Capacity is a fixed convention, not real working hours:
// Bookable-capacity convention for the template: every active provider is assumed// bookable 8 hours per business day (Mon–Sat). Replace with real working hours// when a scheduling domain exists.private const int BookableHoursPerBusinessDay = 8;Permissions
Section titled “Permissions”Five permissions in AppointmentPermissions: every role can book, but by default only Owner and Manager see the dashboard.
| Permission | Key | Owner | Manager | Staff | Receptionist |
|---|---|---|---|---|---|
AppointmentPermissions.View |
appointments:view |
✓ | ✓ | ✓ | ✓ |
AppointmentPermissions.Schedule |
appointments:schedule |
✓ | ✓ | ✓ | ✓ |
AppointmentPermissions.Reschedule |
appointments:reschedule |
✓ | ✓ | ✓ | ✓ |
AppointmentPermissions.Cancel |
appointments:cancel |
✓ | ✓ | ✓ | ✓ |
AppointmentPermissions.ViewStatistics |
appointments.statistics:view |
✓ | ✓ | — | — |
The defaults are in BookingDefaultGrantPolicy. GET /booking/service-providers has no permission of its own and reuses appointments:view. How defaults and overrides combine: Permissions.
Events
Section titled “Events”Booking consumes seven events from Organizations and CRM to keep its copies current, and publishes five of its own.
| Consumed event | From | Handler | What it does |
|---|---|---|---|
CompanyRegistered |
Organizations | CompanyRegisteredEventHandler |
Upserts the BookingCompany. |
EmployeeRegistered |
Organizations | EmployeeRegisteredEventHandler |
Runs ServiceProviderSynchronizer: creates, updates or restores the provider if the employee has Staff, and soft-deletes an existing one if not. |
EmployeeRolesUpdated |
Organizations | EmployeeRolesUpdatedEventHandler |
Same synchronizer. |
EmployeeDeleted |
Organizations | EmployeeDeletedEventHandler |
Soft-deletes the provider. |
CustomerRegisteredEvent |
CRM | CustomerRegisteredEventHandler |
Upserts the attendee through AttendeeSynchronizer. |
CustomerUpdatedEvent |
CRM | CustomerUpdatedEventHandler |
Upserts the attendee. |
CustomerDeletedEvent |
CRM | CustomerDeletedEventHandler |
Soft-deletes the attendee. |
The synchronizer decides from the employee’s roles alone:
var isStaff = snapshot?.Roles.Contains(Role.Staff) == true;
await ((existing, snapshot, isStaff) switch{ // Doesn't exist, needs to be created (existing: null, snapshot: not null, isStaff: true) => CreateServiceProvider(snapshot, cancellationToken),
// Exists, needs update (and potential restoration) (existing: not null, snapshot: not null, isStaff: true) => UpdateServiceProvider(existing, snapshot),
// Exists but no longer has role, needs deletion (existing: not null, snapshot: _, isStaff: false) => DeleteServiceProvider(existing),
_ => Task.CompletedTask});| Published event | Raised by | Handled by |
|---|---|---|
AppointmentScheduledEvent |
Appointment.Schedule |
Nothing yet. |
AppointmentRescheduledEvent |
appointment.Reschedule |
Nothing yet. |
AppointmentCancelledEvent |
appointment.Cancel |
Nothing yet. |
ServiceProviderProjected |
ServiceProvider.Create |
The API host’s DemoSeedingSaga seeds customers after the first provider appears. |
AttendeeProjected |
Attendee.Create |
DemoSeedingSaga seeds up to three appointments for each of the first 150 attendees. |
Booking also handles commands: SeedDemoAppointmentsCommand from the saga, and its own reconcile and top-up commands. How the handlers get their company and run as the system identity: Domain events & messaging.
Database and jobs
Section titled “Database and jobs”Four tables in the booking schema, three jobs that repair the copies if an event goes missing, and one demo-only job.
| Table | Entity | Query filters | Notes |
|---|---|---|---|
booking.Appointments |
Appointment |
CompanyId |
No soft delete: cancelling sets Status. Start, End and ScheduledAt are UTC datetime2. References BookingCompanies. |
booking.ServiceProviders |
ServiceProvider |
SoftDelete, CompanyId |
Id is the Organizations EmployeeId. |
booking.Attendees |
Attendee |
SoftDelete, CompanyId |
Id is the CRM CustomerId. |
booking.BookingCompanies |
BookingCompany |
None | Id is the CompanyId. The per-company jobs loop over this table. |
| Job id | Schedule | What it does |
|---|---|---|
booking:company-reconciliation |
0 * * * * (hourly) |
Reads every company through ICompanyRehydrationSource and upserts BookingCompanies. |
booking:service-provider-reconciliation |
0 * * * * (hourly) |
For each company, compares providers with IEmployeeRehydrationSource and runs the synchronizer on every pair. |
booking:attendee-reconciliation |
0 3 * * * (daily, 03:00) |
For each company, pages through ICustomerRehydrationSource 100 at a time and upserts attendees. |
booking:top-up-demo-appointments |
0 3 * * * (daily, 03:00) |
Only when Demo:Enabled is on: 15 appointments per company. |
AddBookingModule() takes no configuration. It registers BookingDbContext (schema booking), the endpoints, three Dapper repositories, the synchronizers, the job classes, the demo seeder, the permissions and the recurring-job registrars. How the jobs run and get their company: Background jobs.
Why copy companies, staff and customers into Booking?2 min · optional
Booking never queries the organizations or crm schemas. It keeps its own rows, keyed by the same GUIDs, and the calendar query is one SQL statement inside booking. Organizations and CRM can change their tables without breaking it.
The cost is eventual consistency, which the Gotcha above shows. Events keep the copies close, and the reconciliation jobs pull full snapshots through the Contracts interfaces to repair anything an event missed.
Extend it
Section titled “Extend it”Most Booking changes are in one handler or one calculator.
| Change | Start here |
|---|---|
| Prevent double-booking | ScheduleAppointmentCommandHandler and RescheduleAppointmentCommandHandler: look for a Scheduled appointment of the same provider that overlaps the new period before saving. |
| Use real working hours | BookableHoursPerBusinessDay in DashboardStatsCalculator, and StatsBucketing.CountBusinessDays in Shared for the Monday-to-Saturday rule. |
| Make another role bookable | The isStaff check in ServiceProviderSynchronizer. |
| Copy another customer field | Add it to the CRM events and CustomerSnapshot, then to Attendee, AttendeeTypeConfiguration and AttendeeSynchronizer, plus a migration. |
| React to appointments | Nothing handles the appointment events yet, so a new handler in another module is the starting point. See Cross-module events. Notifications are planned for PRO: see Free vs PRO. |
AppointMe.Booking.Tests covers every domain method, the DTO enricher and both dashboard calculators, but not the synchronizers, event handlers or reconciliation jobs.
| Folder | Covers |
|---|---|
Appointments/ |
Schedule, reschedule and cancel, including rejecting a cancelled appointment. AppointmentDtoEnricher names and initials. |
Attendees/, ServiceProviders/, BookingCompanies/ |
The domain methods on each copy: create and update on all three, delete on attendees and providers, restore on providers. |
Dashboard/ |
DashboardStatsCalculator: cancelled excluded, time-zone buckets, Monday weeks, returning clients, Sunday-free capacity, idle providers, compare periods. PeakHoursCalculator: seven days of 24 hourly averages. Both handlers reject a caller without appointments.statistics:view. |
dotnet test src/Booking/AppointMe.Booking.Tests