# Build your first feature

> Build a .NET 10 vertical slice end to end, notes on a CRM customer, with an EF Core entity, a permission, a migration, a Dapper read query and a generated React Query hook.

Source page: https://appointme.dev/docs/start/first-feature/

* **You'll build:** Notes on a customer, from table to UI
* **Time:** About 45 minutes
* **Touches:** The CRM module and the customer profile page
* **Before you start:** [Quick start](/docs/start/quick-start/) done, stack running

## What you’ll build

**Team members write notes on a customer and read them, newest first, on the customer’s profile page.** The feature touches every layer of a slice once. Every snippet on this page was built, type-checked and tested on top of commit `2ccbe87`.

| Layer         | You add                                                                                   | Steps |
| ------------- | ----------------------------------------------------------------------------------------- | ----- |
| Domain        | A `CustomerNote` aggregate, a `CustomerNoteId`, the `LongString` value object             | 1–2   |
| Write model   | An EF Core mapping, a named tenant filter, a migration                                    | 3–4   |
| Authorization | A `CustomerNotePermissions` class and default grants                                      | 5     |
| API           | An `AddCustomerNote` command slice, a `GetCustomerNotes` query slice, a Dapper repository | 6–8   |
| Frontend      | The regenerated client, two permission labels, a notes section                            | 9–11  |

The CRM module before, showing only the parts you touch:

* src/CRM/AppointMe.Crm/

  * Authorization/

    * CrmDefaultGrantPolicy.cs

  * Configuration/

    * CrmModule.cs

  * Customers/ # the Customer aggregate and its slices

    * Customer.cs

    * CustomerId.cs

    * CustomerPermissions.cs

    * RegisterCustomer/

      * …

    * …

  * Database/

    * CrmDbContext.cs

    * Migrations/

      * …

And after. New files are bold:

* src/CRM/AppointMe.Crm/

  * Authorization/

    * CrmDefaultGrantPolicy.cs # grants the new permissions

  * Configuration/

    * CrmModule.cs # registers the repository

  * CustomerNotes/ # new feature folder, next to Customers/

    * **CustomerNote.cs**

    * **CustomerNoteId.cs**

    * **CustomerNoteDto.cs**

    * **CustomerNotePermissions.cs**

    * AddCustomerNote/ # new command slice: factory, command, handler, endpoint, request, response

      * …

    * GetCustomerNotes/ # new query slice: query, handler, endpoint, response

      * …

    * Database/ # new: CustomerNoteFilters, CustomerNoteTypeConfiguration, CustomerNotesRepository

      * …

  * Customers/ # unchanged

    * …

  * Database/

    * CrmDbContext.cs # adds the DbSet and the tenant filter

    * Migrations/

      * **…\_AddCustomerNotes.cs** # generated by dotnet ef

## Model and store the note

**The note is its own aggregate in its own feature folder, with a typed ID, a validated text value object and a named tenant filter.**

1. **Add the ID and the aggregate.** Create `CustomerNotes/` next to `Customers/`. The ID wraps a `Guid` the way `CustomerId` does, and the aggregate records which company and which customer the note belongs to.

   src/CRM/AppointMe.Crm/CustomerNotes/CustomerNoteId.cs (new)

   ```cs
   namespace AppointMe.Crm.CustomerNotes;


   public readonly record struct CustomerNoteId(Guid Value);
   ```

   src/CRM/AppointMe.Crm/CustomerNotes/CustomerNote.cs (new)

   ```cs
   using AppointMe.Crm.Customers;


   namespace AppointMe.Crm.CustomerNotes;


   public sealed record CustomerNote : AggregateRoot
   {
       public required CustomerNoteId Id { get; init; }
       public required CompanyId CompanyId { get; init; }
       public required CustomerId CustomerId { get; init; }
       public required LongString Text { get; init; }
       public required DateTimeOffset CreatedAt { get; init; }
   }
   ```

   `LongString` is the shared value object for free text up to `LongString.MaxLength` (4,000) characters. `AggregateRoot`, `CompanyId` and `LongString` need no `using`, because the module’s `GlobalUsings.cs` imports their namespaces.

2. **Write the factory.** Domain operations are extension methods in the slice that owns them, so `CustomerNote.Add` lives in `AddCustomerNote/`, the way `Customer.Register` lives in `RegisterCustomer/`.

   src/CRM/AppointMe.Crm/CustomerNotes/AddCustomerNote/AddCustomerNote.cs (new)

   ```cs
   using AppointMe.Crm.Customers;


   namespace AppointMe.Crm.CustomerNotes.AddCustomerNote;


   public static class AddCustomerNote
   {
       extension(CustomerNote)
       {
           public static CustomerNote Add(CompanyId companyId, CustomerId customerId, LongString text,
               DateTimeOffset createdAt)
           {
               return new CustomerNote
               {
                   Id = new CustomerNoteId(NewId()),
                   CompanyId = companyId,
                   CustomerId = customerId,
                   Text = text,
                   CreatedAt = createdAt
               };
           }
       }
   }
   ```

   The factory takes a `LongString`, not a `string`, so it can’t receive unvalidated text. The handler in step 6 builds it with `LongString.Create`, which throws `ValidationException` for blank text or text over 4,000 characters. `NewId()` returns a version 7 `Guid`.

3. **Map it in EF Core and scope it to the tenant.** Add a type configuration in `CustomerNotes/Database/`. The `ApplyConfigurationsFromAssembly` call in `CrmDbContext` picks it up.

   src/CRM/AppointMe.Crm/CustomerNotes/Database/CustomerNoteTypeConfiguration.cs (new)

   ```cs
           builder.ToTable("CustomerNotes");


           builder.HasKey(note => note.Id);
           // …
           builder.Property(note => note.Text)
               .HasConversion(text => text.Value, value => new LongString(value))
               .HasMaxLength(LongString.MaxLength)
               .IsRequired();


           builder.Property(note => note.CreatedAt)
               .IsRequired();


           builder.HasOne<Customer>()
               .WithMany()
               .HasForeignKey(note => note.CustomerId)
               .OnDelete(DeleteBehavior.Restrict);


           builder.HasIndex(note => new { note.CustomerId, note.CreatedAt });
   ```

   The cut lines map the three IDs the way `CustomerTypeConfiguration` does, for example `.HasConversion(id => id.Value, value => new CustomerNoteId(value))`. The index starts with `CustomerId`, so EF reuses it for the foreign key instead of adding a second one. Next, name the tenant filter and register it in the context:

   src/CRM/AppointMe.Crm/CustomerNotes/Database/CustomerNoteFilters.cs (new)

   ```cs
   namespace AppointMe.Crm.CustomerNotes.Database;


   public static class CustomerNoteFilters
   {
       public const string CompanyId = nameof(CompanyId);
   }
   ```

   src/CRM/AppointMe.Crm/Database/CrmDbContext.cs

   ```cs
       public DbSet<Customer> Customers => Set<Customer>();
       public DbSet<CustomerNote> CustomerNotes => Set<CustomerNote>();
       // …
           modelBuilder.Entity<Customer>()
               .HasQueryFilter(CustomerFilters.SoftDelete, customer => !customer.IsDeleted)
               .HasQueryFilter(CustomerFilters.CompanyId, customer => customer.CompanyId == currentCompany.CompanyId);


           modelBuilder.Entity<CustomerNote>()
               .HasQueryFilter(CustomerNoteFilters.CompanyId, note => note.CompanyId == currentCompany.CompanyId);
   ```

   Add `using AppointMe.Crm.CustomerNotes;` and `using AppointMe.Crm.CustomerNotes.Database;` at the top. Every EF query on `CustomerNotes` now sees only the current company’s rows. [Multi-tenancy](/docs/concepts/multi-tenancy/#make-your-own-entity-tenant-scoped) has the full checklist.

4. **Add the migration.** From the repo root, run `dotnet ef` against the CRM project, with the API as the startup project:

   ```bash
   dotnet ef migrations add AddCustomerNotes --project src/CRM/AppointMe.Crm --startup-project src/AppointMe.Api --context CrmDbContext
   ```

   EF writes the migration next to the existing ones in `Database/Migrations/` and updates `CrmDbContextModelSnapshot.cs`:

   src/CRM/AppointMe.Crm/Database/Migrations/\<timestamp>\_AddCustomerNotes.cs (generated)

   ```cs
               migrationBuilder.CreateTable(
                   name: "CustomerNotes",
                   schema: "crm",
                   columns: table => new
                   {
                       Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
                       CompanyId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
                       CustomerId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
                       Text = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: false),
                       CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
                   },
                   // …
               migrationBuilder.CreateIndex(
                   name: "IX_CustomerNotes_CustomerId_CreatedAt",
                   schema: "crm",
                   table: "CustomerNotes",
                   columns: new[] { "CustomerId", "CreatedAt" });
   ```

   The cut lines add the primary key and `FK_CustomerNotes_Customers_CustomerId` with `ReferentialAction.Restrict`. You don’t apply the migration by hand. The API runs pending migrations at startup and logs `Applying migration '<timestamp>_AddCustomerNotes'.` See [Database migrations](/docs/guides/database-migrations/).

## Write and read through the API

**A permission class, a command slice and a query slice. Wolverine injects the company and the caller into both handlers.**

5. **Add the permissions.** `AddPermissions(ModuleAssembly.Instance)`, which `CrmModule` already calls, discovers any static class whose name ends in `Permissions`.

   src/CRM/AppointMe.Crm/CustomerNotes/CustomerNotePermissions.cs (new)

   ```cs
   using AppointMe.Shared.Authorization.Permissions;


   namespace AppointMe.Crm.CustomerNotes;


   public static class CustomerNotePermissions
   {
       private const string CustomerNotes = "customers.notes";


       public static readonly Permission View = new(CustomerNotes, "view");
       public static readonly Permission Create = new(CustomerNotes, "create");
   }
   ```

   No role holds a new permission until you grant it, so add it to the module’s default grant policy:

   src/CRM/AppointMe.Crm/Authorization/CrmDefaultGrantPolicy.cs

   ```cs
           new(Role.Owner,
               CustomerPermissions.View,
               CustomerPermissions.Create,
               CustomerPermissions.Update,
               CustomerPermissions.Delete,
               CustomerPermissions.ViewStatistics,
               CustomerNotePermissions.View,
               CustomerNotePermissions.Create
           ),
           // …
           new(Role.Staff,
               CustomerPermissions.View,
               CustomerNotePermissions.View
           ),
   ```

   | Role                         | `customers.notes:view` | `customers.notes:create` |
   | ---------------------------- | ---------------------- | ------------------------ |
   | Owner, Manager, Receptionist | ✓                      | ✓                        |
   | Staff                        | ✓                      | —                        |

   The two codes also appear in the OpenAPI `Permission` enum, which the frontend uses in step 10. See [Add a permission](/docs/guides/add-a-permission/).

6. **Add the command slice.** Besides the factory from step 2, `AddCustomerNote/` holds three message files, the handler and the endpoint:

   | File                         | Contents                                                                     |
   | ---------------------------- | ---------------------------------------------------------------------------- |
   | `AddCustomerNoteCommand.cs`  | `public sealed record AddCustomerNoteCommand(Guid CustomerId, string Text);` |
   | `AddCustomerNoteRequest.cs`  | A `sealed class` with `public required string Text { get; init; }`           |
   | `AddCustomerNoteResponse.cs` | `public sealed record AddCustomerNoteResponse(Guid Id);`                     |

   The handler checks the permission first, then loads the customer through the filtered `DbSet`:

   src/CRM/AppointMe.Crm/CustomerNotes/AddCustomerNote/AddCustomerNoteCommandHandler.cs (new)

   ```cs
   // …
   public sealed class AddCustomerNoteCommandHandler(CrmDbContext dbContext, TimeProvider timeProvider)
   {
       public async Task<AddCustomerNoteResponse> HandleAsync(AddCustomerNoteCommand command, CompanyId companyId,
           IPrincipal principal, CancellationToken cancellationToken)
       {
           principal.Require(CustomerNotePermissions.Create);


           var customer = await dbContext.Customers.LoadAsync(new CustomerId(command.CustomerId), cancellationToken);


           var note = CustomerNote.Add(
               companyId: companyId,
               customerId: customer.Id,
               text: LongString.Create(command.Text),
               createdAt: timeProvider.GetUtcNow()
           );


           await dbContext.CustomerNotes.AddAsync(note, cancellationToken);
           await dbContext.SaveChangesAsync(cancellationToken);


           return new AddCustomerNoteResponse(note.Id.Value);
       }
   }
   ```

   Wolverine middleware fills in `CompanyId` and `IPrincipal`, and loading the principal is what checks that the caller works for that company. `LoadAsync`, from `Customers/Database/CustomerQueries.cs`, throws `NotFoundException` (a `404`) when the customer doesn’t exist in the current company. The file also needs `using` lines for `AppointMe.Crm.Customers`, `AppointMe.Crm.Customers.Database` and `AppointMe.Crm.Database`.

   The endpoint maps the route under `api/v1` and sends the command through Wolverine:

   src/CRM/AppointMe.Crm/CustomerNotes/AddCustomerNote/AddCustomerNoteEndpoint.cs (new)

   ```cs
   using Microsoft.AspNetCore.Http.HttpResults;
   using Microsoft.AspNetCore.Mvc;


   namespace AppointMe.Crm.CustomerNotes.AddCustomerNote;


   internal sealed class AddCustomerNoteEndpoint : IEndpoint
   {
       public void MapEndpoint(IEndpointRouteBuilder builder)
       {
           builder.MapPost("/customers/{id:guid}/notes", AddCustomerNote).WithName(nameof(AddCustomerNote));
       }


       private static async Task<Created<AddCustomerNoteResponse>> AddCustomerNote(Guid id,
           [FromBody] AddCustomerNoteRequest request, IMessageBus bus, CancellationToken cancellationToken)
       {
           var result = await bus.InvokeAsync<AddCustomerNoteResponse>(
               new AddCustomerNoteCommand(id, request.Text), cancellationToken);
           return TypedResults.Created($"/customers/{id}/notes", result);
       }
   }
   ```

   The class is `internal` and needs no registration, because `AddEndpoints(ModuleAssembly.Instance)` finds every `IEndpoint` in the module. [Add an endpoint](/docs/guides/add-an-endpoint/) covers the conventions.

7. **Write the Dapper read.** Reads skip EF, so the repository takes a `CompanyId` and puts it in the `WHERE` clause itself.

   src/CRM/AppointMe.Crm/CustomerNotes/Database/CustomerNotesRepository.cs (new)

   ```cs
   // …
   public sealed class CustomerNotesRepository(IDbConnectionFactory connectionFactory)
   {
       public async Task<IReadOnlyList<CustomerNoteDto>> GetByCustomer(CustomerId customerId, CompanyId companyId,
           CancellationToken cancellationToken)
       {
           using var connection = await connectionFactory.OpenConnectionAsync(cancellationToken);
           var commandDefinition = new CommandDefinition(
               """
               SELECT [Id], [Text], [CreatedAt]
               FROM [crm].[CustomerNotes]
               WHERE [CompanyId] = @CompanyId AND [CustomerId] = @CustomerId
               ORDER BY [CreatedAt] DESC
               """,
               new { CompanyId = companyId.Value, CustomerId = customerId.Value },
               cancellationToken: cancellationToken);
           var notes = await connection.QueryAsync<CustomerNoteDto>(commandDefinition);
           return notes.ToList();
       }
   }
   ```

   The cut lines are the `using` lines for `AppointMe.Crm.Customers`, `AppointMe.Shared.Database` and `Dapper`, and the namespace. Register the repository next to `CustomersRepository`:

   src/CRM/AppointMe.Crm/Configuration/CrmModule.cs

   ```cs
                   .AddScoped<CustomersRepository>()
                   .AddScoped<CustomerNotesRepository>()
   ```

   Gotcha

   Dapper never sees EF’s query filters. Without `[CompanyId] = @CompanyId`, this query returns notes from every company. Against a migrated database, the query above returns a company’s note for that company and no rows for any other.

8. **Add the query slice.** `GetCustomerNotes/` holds the query, the response, the handler and the endpoint:

   | File                                      | Contents                                                                                          |
   | ----------------------------------------- | ------------------------------------------------------------------------------------------------- |
   | `GetCustomerNotesQuery.cs`                | `public sealed record GetCustomerNotesQuery(Guid CustomerId);`                                    |
   | `GetCustomerNotesResponse.cs`             | A `sealed class` with `public required IReadOnlyList<CustomerNoteDto> Notes { get; init; }`       |
   | `CustomerNoteDto.cs`, in `CustomerNotes/` | `Id`, `Text` and `CreatedAt` as `required` properties. Dapper maps the columns onto them by name. |

   src/CRM/AppointMe.Crm/CustomerNotes/GetCustomerNotes/GetCustomerNotesQueryHandler.cs (new)

   ```cs
   // …
   public sealed class GetCustomerNotesQueryHandler(CustomerNotesRepository repository)
   {
       public async Task<GetCustomerNotesResponse> HandleAsync(GetCustomerNotesQuery query, CompanyId companyId,
           IPrincipal principal, CancellationToken cancellationToken)
       {
           principal.Require(CustomerNotePermissions.View);


           var notes = await repository.GetByCustomer(new CustomerId(query.CustomerId), companyId, cancellationToken);
           return new GetCustomerNotesResponse
           {
               Notes = notes
           };
       }
   }
   ```

   src/CRM/AppointMe.Crm/CustomerNotes/GetCustomerNotes/GetCustomerNotesEndpoint.cs (new)

   ```cs
   namespace AppointMe.Crm.CustomerNotes.GetCustomerNotes;


   internal sealed class GetCustomerNotesEndpoint : IEndpoint
   {
       public void MapEndpoint(IEndpointRouteBuilder builder)
       {
           builder.MapGet("/customers/{id:guid}/notes", GetCustomerNotes).WithName(nameof(GetCustomerNotes));
       }


       private static async Task<GetCustomerNotesResponse> GetCustomerNotes(Guid id, IMessageBus bus,
           CancellationToken cancellationToken)
       {
           return await bus.InvokeAsync<GetCustomerNotesResponse>(new GetCustomerNotesQuery(id), cancellationToken);
       }
   }
   ```

   Check: run `dotnet build AppointMe.sln`. The build also writes `src/AppointMe.Api/obj/openapi.json`, which now lists both operations under `/api/v1/customers/{id}/notes`.

## Show it in the React app

**Regenerate the typed client, label the new permissions, then render the notes with the generated TanStack Query hooks.**

9. **Regenerate the client.** You don’t need a running backend: the script builds the API, which writes the OpenAPI document, then runs orval against it.

   ```bash
   cd src/AppointMe.Frontend
   npm run generate:api
   ```

   | Generated in `src/api/`           | Use                                                                           |
   | --------------------------------- | ----------------------------------------------------------------------------- |
   | `useGetCustomerNotes(id)`         | Reads a `GetCustomerNotesResponse`                                            |
   | `getGetCustomerNotesQueryKey(id)` | The cache key to invalidate after a write                                     |
   | `useAddCustomerNote()`            | A mutation whose variables are `{ id: string; data: AddCustomerNoteRequest }` |
   | `Permission`                      | Gains `'customers.notes:view'` and `'customers.notes:create'`                 |

   The hook names come from `.WithName(nameof(...))` on each endpoint. See [Typed API client](/docs/frontend/api-client/).

10. **Label the new permissions.** `npm run check` now fails, because the labels are a `Record<Permission, string>` and two keys are missing. The error ends in `missing the following properties from type 'Record<Permission, string>': "customers.notes:create", "customers.notes:view"`. Add both, plus a label for the new group on the Permissions settings page:

    src/AppointMe.Frontend/src/app/settings/permissions/permission-labels.ts

    ```ts
        'customers.statistics': 'Customer statistics',
        'customers.notes': 'Customer notes',
    };
    // …
        'customers.statistics:view': 'View customer statistics',
        'customers.notes:view': 'View customer notes',
        'customers.notes:create': 'Add customer notes',
    ```

11. **Render the notes.** Add two components to `app/customers/`: a form that posts a note and invalidates the list, and the list itself. Each is gated by its permission with `<Can>`.

    src/AppointMe.Frontend/src/app/customers/add-customer-note-form.tsx (new)

    ```tsx
    // …
    export const AddCustomerNoteForm = ({ customerId }: { customerId: string }) => {
        const queryClient = useQueryClient();
        const [text, setText] = useState('');
        const { mutateAsync: addNote, isPending } = useAddCustomerNote({
            mutation: {
                onSuccess: () => queryClient.invalidateQueries({ queryKey: getGetCustomerNotesQueryKey(customerId) }),
            },
        });


        return (
            <form
                onSubmit={async event => {
                    event.preventDefault();
                    await addNote({ id: customerId, data: { text } });
                    setText('');
                }}
            >
                <Textarea value={text} maxLength={4000} onChange={event => setText(event.target.value)} />
                <Button type='submit' size='sm' disabled={isPending || !text.trim()}>
                    Add note
                </Button>
            </form>
        );
    };
    ```

    src/AppointMe.Frontend/src/app/customers/customer-notes.tsx (new)

    ```tsx
    import { AddCustomerNoteForm } from './add-customer-note-form';
    import { useGetCustomerNotes } from '@/api/client';
    import { Can } from '@/components/auth/can';
    import { FormattedDate } from '@/components/format';


    export const CustomerNotes = ({ customerId }: { customerId: string }) => {
        const { data } = useGetCustomerNotes(customerId);


        return (
            <section className='space-y-3'>
                <h2 className='text-lg font-semibold'>Notes</h2>
                <Can permission='customers.notes:create'>
                    <AddCustomerNoteForm customerId={customerId} />
                </Can>
                <ul className='space-y-2'>
                    {data?.notes.map(note => (
                        <li key={note.id} className='rounded-md border p-3 text-sm'>
                            <p className='whitespace-pre-wrap'>{note.text}</p>
                            <FormattedDate date={note.createdAt} format='dayMonthShortYearTime24' />
                        </li>
                    ))}
                </ul>
            </section>
        );
    };
    ```

    Then mount it under the details on the profile page, and import it with `import { CustomerNotes } from './customer-notes';`:

    src/AppointMe.Frontend/src/app/customers/customer-profile.tsx

    ```tsx
                </dl>


                <Can permission='customers.notes:view'>
                    <CustomerNotes customerId={customer.id} />
                </Can>
    ```

    The form’s cut lines import `getGetCustomerNotesQueryKey` and `useAddCustomerNote` from `@/api/client`, `Button` and `Textarea` from `@/components/ui`, `useQueryClient` and `useState`. The app’s dialogs use react-hook-form and zod; this form keeps the hook-up minimal. Check: `npm run check` passes the format check, lint and type-check.

## Verify it works

**The CRM tests cover the factory and the permission check. The running app and the OpenAPI document cover the rest.**

Add a test file next to the existing CRM tests. It uses the same pattern as `GetNewCustomerStatsQueryHandlerTests`: a principal with no permissions, and a handler that must fail before it touches the database.

src/CRM/AppointMe.Crm.Tests/CustomerNotes/AddCustomerNote/AddCustomerNoteTests.cs (new)

```cs
    [Fact]
    public async Task should_deny_when_principal_lacks_create_permission()
    {
        var handler = new AddCustomerNoteCommandHandler(null!, TimeProvider.System);
        var principal = new UserPrincipal(new CompanyId(NewId()), new HashSet<Role>(), new HashSet<Permission>());


        await Assert.ThrowsAsync<AccessDeniedException>(() => handler.HandleAsync(
            new AddCustomerNoteCommand(NewId(), "Prefers mornings"), principal.CompanyId, principal,
            CancellationToken.None));
    }
```

The file’s other test, `should_add_note_to_customer`, builds a customer with `Create.Customer.Build()` and asserts that `CustomerNote.Add` copies every field. Run the module’s tests:

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

Expected: 9 tests pass, the module’s 7 plus your 2. Then try the feature:

| Where   | Do                                                                     | Expect                                                                               |
| ------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| The app | Start the stack, open **Customers**, click a row                       | A **Notes** section under the customer’s details. A note you add appears at the top. |
| OpenAPI | Open `https://localhost:7233/openapi/v1.json` (Development only)       | `GetCustomerNotes` and `AddCustomerNote` under `/api/v1/customers/{id}/notes`        |
| HTTP    | `POST /api/v1/customers/{id}/notes` with `{"text":"Prefers mornings"}` | `201` and `{"id":"…"}`. A `GET` on the same path returns `{"notes":[…]}`.            |

Every HTTP call needs a signed-in user and the `X-Company-Id` header. A cookie session also needs the `X-XSRF-TOKEN` header on a `POST`. The SPA’s axios client sends both, so the browser’s network tab is the quickest place to watch the calls.

## Troubleshooting

**Most failures are a missing grant, a missing label or a stale client.**

| Symptom                                                                                    | Cause                                                                        | Fix                                                                                   |
| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `npm run check`: `missing the following properties from type 'Record<Permission, string>'` | New permission codes have no labels                                          | Add them to `permission-labels.ts` (step 10)                                          |
| `403` with `The following permissions are required: customers.notes:create.`               | The caller’s role isn’t granted the permission                               | Add it to `CrmDefaultGrantPolicy`, or grant it on the Permissions settings page       |
| `404` with `Customer with id='…' not found`                                                | The customer belongs to another company, or is deleted                       | Send the `X-Company-Id` of the customer’s company                                     |
| `400` with `"error": "Value cannot be empty."`                                             | The note text is blank, so `LongString.Create` rejects it                    | Send non-blank text                                                                   |
| `useGetCustomerNotes` isn’t in `client.ts`                                                 | You ran `npx orval` alone, so it read an old `obj/openapi.json`              | Run `npm run generate:api`, which builds first                                        |
| `dotnet ef migrations remove` fails with a SQL network error                               | `remove` connects to the database to check whether the migration was applied | Start SQL Server first. See [Database migrations](/docs/guides/database-migrations/). |

### Why a separate aggregate instead of a list of notes on Customer?

A note has its own life. It’s written far more often than the customer changes, and listing notes never needs the customer’s fields. As a separate aggregate, a note is one `INSERT`, and the list is one Dapper query that sorts on its own index.

It also leaves `Customer` alone. The aggregate, its events and the contracts in `AppointMe.Crm.Contracts` that Booking consumes don’t change.

### Why load the customer when there's a foreign key?

The foreign key proves the customer exists. It can’t prove the customer belongs to the caller’s company, because the database doesn’t know who is calling.

`dbContext.Customers.LoadAsync` runs through the `CompanyId` query filter, so a customer ID from another tenant fails with `404` before anything is written. The note’s own `CompanyId` is the injected one, the same value the filter used.

## Next

* [Add an endpoint](/docs/guides/add-an-endpoint/) (Guide)
* [Database migrations](/docs/guides/database-migrations/) (Guide)
* [Vertical slices](/docs/concepts/vertical-slices/) (Concept)
