# CRM module

> The CRM module stores each company's customers with EF Core writes and Dapper reads, reports new-customer statistics, and publishes the events Booking turns into attendees.

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

* **Owns:** Each company’s customers
* **Schema:** `crm`, one table: `Customers`
* **Endpoints:** 6, each behind a `customers` permission
* **Feeds:** Booking’s attendees, through three events

## What’s inside

**CRM is the smallest business module: one aggregate, its CRUD slices, a statistics query and demo data.**

| Feature            | Folder                                                    | What it does                                                                             |
| ------------------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| Customer           | `Customers/`                                              | `Customer`: name, optional date of birth, gender and email, soft-deleted.                |
| Write slices       | `RegisterCustomer/`, `UpdateCustomer/`, `DeleteCustomer/` | Each loads or creates the aggregate with EF Core, calls a domain method and saves.       |
| Read slices        | `GetCustomers/`, `GetCustomerById/`                       | Dapper queries through `CustomersRepository`, always filtered by `CompanyId`.            |
| New-customer stats | `GetNewCustomerStats/`                                    | Registrations per day, week or month in a time zone, with an optional comparison period. |
| Demo data          | `SeedDemoCustomers/`, `TopUpDemoCustomers/`               | Fake customers for a new demo company, plus a daily top-up.                              |
| Data access        | `Customers/Database/`                                     | `CustomerTypeConfiguration`, `CustomerFilters`, `CustomersRepository`.                   |
| Contracts          | `AppointMe.Crm.Contracts`                                 | Three customer events and `ICustomerRehydrationSource`.                                  |

The repo’s `CLAUDE.md` uses its `Customers/` folder as the example slice layout. The split between EF Core writes and Dapper reads is explained in [CQRS: EF writes, Dapper reads](/docs/concepts/cqrs/), and the slice layout in [Vertical slices](/docs/concepts/vertical-slices/).

### Folder map

* src/CRM/

  * AppointMe.Crm/

    * Authorization/ # CrmDefaultGrantPolicy

      * …

    * Configuration/ # AddCrmModule

      * …

    * Customers/

      * Database/ # EF config, filters, Dapper repository

        * …

      * DeleteCustomer/

        * …

      * GetCustomerById/

        * …

      * GetCustomers/

        * …

      * GetNewCustomerStats/

        * …

      * RegisterCustomer/

        * …

      * SeedDemoCustomers/

        * …

      * TopUpDemoCustomers/

        * …

      * UpdateCustomer/

        * …

      * Customer.cs

      * CustomerPermissions.cs

      * …

    * Database/ # CrmDbContext, migrations

      * …

  * AppointMe.Crm.Contracts/

    * Customers/ # events, ICustomerRehydrationSource

      * …

  * AppointMe.Crm.Tests/

    * …

## Endpoints

**Six endpoints, each behind one `customers` permission and scoped to the company in `X-Company-Id`.**

| Method | Route                                 | Permission                  | Purpose                                                                                                                                                       |
| ------ | ------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET    | `/api/v1/customers`                   | `customers:view`            | Paged list, newest first. `search` matches every word against name and email. `page` and `limit` default to 1 and 10, and `limit` is capped at 100.           |
| GET    | `/api/v1/customers/{id}`              | `customers:view`            | One customer. `404` if it’s deleted or belongs to another company.                                                                                            |
| POST   | `/api/v1/customers`                   | `customers:create`          | Body `{ firstName, lastName?, dateOfBirth?, gender?, email? }`. Returns `201` with the new id.                                                                |
| PUT    | `/api/v1/customers/{id}`              | `customers:update`          | Same body as create. Replaces every field and returns `204`.                                                                                                  |
| DELETE | `/api/v1/customers/{id}`              | `customers:delete`          | Soft delete. Booking soft-deletes the matching attendee.                                                                                                      |
| GET    | `/api/v1/crm/dashboard/new-customers` | `customers.statistics:view` | Query `From`, `To`, `Bucket` (`day`, `week` or `month`), `TimeZone`, and optionally `CompareFrom` with `CompareTo`. Returns totals and one bucket per period. |

`gender` accepts `Male`, `Female` or `Other`, in any case. Names are title-cased and emails lower-cased by the [shared value objects](/docs/modules/shared/).

Gotcha

The statistics range must be UTC. A `From` or `To` with a non-zero offset fails with `400 Start and end must be in UTC.`, so send `Z` times and pass the local zone in `TimeZone`.

## Permissions

**Five permissions in `CustomerPermissions`: every role can view customers, and only `Owner` can delete them.**

| Permission                           | Key                         | Owner | Manager | Staff | Receptionist |
| ------------------------------------ | --------------------------- | ----- | ------- | ----- | ------------ |
| `CustomerPermissions.View`           | `customers:view`            | ✓     | ✓       | ✓     | ✓            |
| `CustomerPermissions.Create`         | `customers:create`          | ✓     | ✓       | —     | ✓            |
| `CustomerPermissions.Update`         | `customers:update`          | ✓     | ✓       | —     | ✓            |
| `CustomerPermissions.Delete`         | `customers:delete`          | ✓     | —       | —     | —            |
| `CustomerPermissions.ViewStatistics` | `customers.statistics:view` | ✓     | ✓       | —     | —            |

The defaults are in `CrmDefaultGrantPolicy`. A company can override them for every role except `Owner` in the [Organizations](/docs/modules/organizations/) permission settings.

## Events

**CRM publishes three customer events that Booking consumes, and consumes no other module’s events.**

| Event                     | Raised by                                               | Handled by                                                         |
| ------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------ |
| `CustomerRegisteredEvent` | `Customer.Register`, from the API and from demo seeding | Booking’s `CustomerRegisteredEventHandler` creates the attendee.   |
| `CustomerUpdatedEvent`    | `customer.Update`                                       | Booking’s `CustomerUpdatedEventHandler` updates the attendee.      |
| `CustomerDeletedEvent`    | `customer.Delete`                                       | Booking’s `CustomerDeletedEventHandler` soft-deletes the attendee. |

The factory raises the event on the aggregate, and Wolverine publishes it when the handler saves the change:

src/CRM/AppointMe.Crm/Customers/RegisterCustomer/RegisterCustomer.cs

```cs
public static Customer Register(CompanyId companyId, PersonName name, DateOfBirth? dateOfBirth,
    Gender? gender, Email? email, DateTimeOffset registrationDate)
{
    var customer = new Customer
    {
        Id = new CustomerId(NewId()),
        CompanyId = companyId,
        Name = name,
        DateOfBirth = dateOfBirth,
        Gender = gender,
        Email = email,
        RegistrationDate = registrationDate,
        IsDeleted = false
    };


    customer.Raise(new CustomerRegisteredEvent(
        customer.Id.Value, companyId.Value,
        name.FirstName, name.LastName, dateOfBirth?.Value, email?.Value));


    return customer;
}
```

CRM does handle two commands. `SeedDemoCustomersCommand` comes from the API host’s `DemoSeedingSaga`, and `TopUpDemoCustomersCommand` from its own job. Booking also reads customers directly through `ICustomerRehydrationSource.GetByCompanyAsync`, a paged query for its reconciliation job. The event flow is covered in [Domain events & messaging](/docs/concepts/messaging/).

## Database, jobs and wiring

**One table, one demo-only job, and a registration method that reads no configuration.**

| Table           | Entity     | Query filters             | Notes                                                                                                                                                        |
| --------------- | ---------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `crm.Customers` | `Customer` | `SoftDelete`, `CompanyId` | Index on (`CompanyId`, `RegistrationDate`) where not deleted. Computed `SearchKey`: lower-cased first name, last name and email. `Version` is a row version. |

| Job id                      | Schedule                   | What it does                                                                                                                                                         |
| --------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `crm:top-up-demo-customers` | `0 3 * * *` (daily, 03:00) | Only when `Demo:Enabled` is on. For every company from `ICompanyRehydrationSource`, invokes `TopUpDemoCustomersCommand(2)`, which adds two customers registered now. |

The registrar only schedules the job when demo mode is on:

src/CRM/AppointMe.Crm/Customers/TopUpDemoCustomers/TopUpDemoCustomersJobRegistrar.cs

```cs
private const string JobId = "crm:top-up-demo-customers";
private const string CronExpression = "0 3 * * *"; // daily at 03:00 UTC


public void Register(IRecurringJobScheduler scheduler)
{
    if (!configuration.GetValue<bool>("Demo:Enabled"))
    {
        return;
    }


    scheduler.AddOrUpdate<TopUpDemoCustomersJob>(
        JobId,
        job => job.Run(CancellationToken.None),
        CronExpression);
}
```

`AddCrmModule()` registers `CrmDbContext` (schema `crm`), the endpoints, `CustomersRepository`, `CustomerRehydrationSource`, the demo seeder and top-up job, the permissions and the recurring jobs. The demo saga asks for 150 seeded customers, and `SeedDemoCustomersCommandHandler` skips a company that already has any. How recurring jobs run: [Background jobs](/docs/concepts/background-jobs/).

### Why soft-delete customers?

Booking keeps a customer’s appointments after the customer is gone. `CustomerDeletedEvent` soft-deletes the matching attendee, and `AppointmentsRepository` joins attendees without an `IsDeleted` check, so past appointments still show the name.

A hard delete would make those appointments vanish from the calendar, because the calendar query uses an `INNER JOIN` on attendees.

## Extend it

**A new customer field touches the aggregate, both slices, the read model and, if Booking needs it, the events.**

| Change                              | Start here                                                                                                                                                                                                                                                                                                     |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Add a field, such as a phone number | `Customer` and `CustomerTypeConfiguration`, the request and command in `RegisterCustomer/` and `UpdateCustomer/`, then `CustomerRecord`, the SQL in `CustomersRepository` and `CustomerDto`. Add a [migration](/docs/guides/database-migrations/) and regenerate the [API client](/docs/frontend/api-client/). |
| Send the field to Booking           | Add it to `CustomerRegisteredEvent`, `CustomerUpdatedEvent` and `CustomerSnapshot` in `AppointMe.Crm.Contracts`, then to Booking’s `Attendee`. See [Cross-module events](/docs/guides/cross-module-events/).                                                                                                   |
| Search on another column            | The `SearchKey` computed-column SQL in `CustomerTypeConfiguration`, plus a migration.                                                                                                                                                                                                                          |
| Add a second aggregate              | A feature folder next to `Customers/`, a `DbSet` and filters in `CrmDbContext`, a `*Permissions` class and its grants in `CrmDefaultGrantPolicy`. See [Add an endpoint](/docs/guides/add-an-endpoint/).                                                                                                        |
| Drop the demo top-up                | Set `Demo:Enabled` to `false` before the first start. The registrar never removes a job, so one Hangfire already stored stays scheduled until you delete it on the dashboard at `/admin/jobs`.                                                                                                                 |

## Tests

**`AppointMe.Crm.Tests` covers the domain methods, the demo seeder and the statistics, but not updating a customer or the Dapper repository.**

| Test class                             | Covers                                                                                      |
| -------------------------------------- | ------------------------------------------------------------------------------------------- |
| `RegisterCustomerTests`                | `Customer.Register` sets the state and raises `CustomerRegisteredEvent`.                    |
| `DeleteCustomerTests`                  | `customer.Delete` sets `IsDeleted` and raises `CustomerDeletedEvent`.                       |
| `SeedDemoCustomersTests`               | Seeded customers carry the registration date they’re given.                                 |
| `NewCustomerStatsCalculatorTests`      | Day buckets in the company’s time zone, empty buckets, and compare buckets only when asked. |
| `GetNewCustomerStatsQueryHandlerTests` | The handler throws `AccessDeniedException` without `customers.statistics:view`.             |

```bash
dotnet test src/CRM/AppointMe.Crm.Tests
```

## Next

* [CQRS: EF writes, Dapper reads](/docs/concepts/cqrs/) (Concept)
* [Booking module](/docs/modules/booking/) (Module)
* [Add an endpoint](/docs/guides/add-an-endpoint/) (Guide)
