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.
- Owns
- Each company’s customers
- Schema
crm, one table:Customers- Endpoints
- 6, each behind a
customerspermission - Feeds
- Booking’s attendees, through three events
What’s inside
Section titled “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, and the slice layout in Vertical slices.
Folder map
Section titled “Folder map”Directorysrc/CRM/
DirectoryAppointMe.Crm/
DirectoryAuthorization/ # CrmDefaultGrantPolicy
- …
DirectoryConfiguration/ # AddCrmModule
- …
DirectoryCustomers/
DirectoryDatabase/ # EF config, filters, Dapper repository
- …
DirectoryDeleteCustomer/
- …
DirectoryGetCustomerById/
- …
DirectoryGetCustomers/
- …
DirectoryGetNewCustomerStats/
- …
DirectoryRegisterCustomer/
- …
DirectorySeedDemoCustomers/
- …
DirectoryTopUpDemoCustomers/
- …
DirectoryUpdateCustomer/
- …
- Customer.cs
- CustomerPermissions.cs
- …
DirectoryDatabase/ # CrmDbContext, migrations
- …
DirectoryAppointMe.Crm.Contracts/
DirectoryCustomers/ # events, ICustomerRehydrationSource
- …
DirectoryAppointMe.Crm.Tests/
- …
Endpoints
Section titled “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.
Permissions
Section titled “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 permission settings.
Events
Section titled “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:
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.
Database, jobs and wiring
Section titled “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:
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.
Why soft-delete customers?1 min · optional
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
Section titled “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 and regenerate the 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. |
| 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. |
| 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. |
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. |
dotnet test src/CRM/AppointMe.Crm.Tests