# 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.

Source page: https://appointme.dev/docs/modules/booking/

* **Owns:** Appointments and the dashboard statistics
* **Schema:** `booking`, four tables
* **Endpoints:** 8, behind `appointments` permissions
* **Copies:** Companies, staff and customers from other modules

## 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](/docs/concepts/ids-and-value-objects/).

### Folder map

* src/Booking/

  * AppointMe.Booking/

    * Appointments/

      * ScheduleAppointment/

        * …

      * RescheduleAppointment/

        * …

      * CancelAppointment/

        * …

      * …

    * Attendees/

      * ReconcileAttendees/ # CRM event handlers, daily job

        * …

      * …

    * BookingCompanies/

      * ReconcileBookingCompanies/ # Organizations event handler, hourly job

        * …

      * …

    * ServiceProviders/

      * ReconcileServiceProviders/ # Organizations event handlers, hourly job

        * …

      * …

    * Dashboard/ # GetDashboardStats, GetPeakHours

      * …

    * Configuration/ # AddBookingModule

      * …

    * Database/ # BookingDbContext, BookingFilters, migrations

      * …

    * …

  * AppointMe.Booking.Contracts/

    * …

  * AppointMe.Booking.Tests/

    * …

## 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:

src/Booking/AppointMe.Booking/Dashboard/GetDashboardStats/DashboardStatsCalculator.cs

```cs
// 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

**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](/docs/concepts/permissions/).

## 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:

src/Booking/AppointMe.Booking/ServiceProviders/ReconcileServiceProviders/ServiceProviderSynchronizer.cs

```cs
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](/docs/concepts/messaging/).

Gotcha

The copies arrive through events after the source transaction commits. A customer created a moment ago can fail `POST /appointments` with `400 Attendee not found.` until Booking has handled `CustomerRegisteredEvent`.

## 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](/docs/concepts/background-jobs/).

Gotcha

`IX_Appointments_CompanyId_Start` is created with raw SQL in the `AddDashboardStatsIndexes` migration, because EF can’t index an owner column and an owned-type column together. The model snapshot doesn’t know about it, so keep that SQL if you ever squash the migrations.

### Why copy companies, staff and customers into Booking?

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

**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](/docs/guides/database-migrations/).                                                                 |
| React to appointments       | Nothing handles the appointment events yet, so a new handler in another module is the starting point. See [Cross-module events](/docs/guides/cross-module-events/). Notifications are planned for PRO: see [Free vs PRO](/docs/start/free-vs-pro/). |

## Tests

**`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`. |

```bash
dotnet test src/Booking/AppointMe.Booking.Tests
```

## Next

* [Domain events & messaging](/docs/concepts/messaging/) (Concept)
* [Background jobs](/docs/concepts/background-jobs/) (Concept)
* [Cross-module events](/docs/guides/cross-module-events/) (Guide)
