# Organizations module

> The Organizations module owns companies, onboarding, employees, invitations and per-company role permissions, and builds the principal that every .NET 10 handler checks.

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

* **Owns:** Companies, employees, invitations, permission settings
* **Schema:** `organizations`, four tables
* **Endpoints:** 14, including `/me` and onboarding
* **Roles:** `Owner`, `Manager`, `Staff`, `Receptionist`

## What’s inside

**Organizations knows which users work for which company, in which roles, and turns that into the principal other modules check.**

| Feature                  | Folder                              | What it does                                                                                                            |
| ------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Companies and onboarding | `Companies/`                        | `Company` is the tenant. Onboarding creates a company and its first employee, who becomes the primary owner.            |
| Employees                | `Employees/`                        | `Employee` links a `UserId` to a company with a set of roles. List the team, change roles, remove.                      |
| Invitations              | `Invitations/`                      | Invite an email with roles, then accept, resend or cancel. Invitations expire after 7 days.                             |
| Permission settings      | `Settings/Permissions/`             | Per-company overrides on top of each role’s default grants, cached for an hour.                                         |
| Current user             | `Me/`, `UserAccess/`                | `GET /me` says who you are and where you work. `GET /me/access` lists your roles and permissions in the active company. |
| Principal resolution     | `Infrastructure/`                   | `UserPrincipalFactory` and `PermissionResolver` turn the caller into a `UserPrincipal` for the active company.          |
| Contracts                | `AppointMe.Organizations.Contracts` | Nine events, plus `ICompanyRehydrationSource` and `IEmployeeRehydrationSource` for other modules’ reconciliation jobs.  |

### Folder map

* src/Organizations/

  * AppointMe.Organizations/

    * Authorization/ # OrganizationsDefaultGrantPolicy

      * …

    * Companies/

      * Database/

        * …

      * Onboarding/

        * …

      * RegisterCompany/

        * …

      * Company.cs

      * …

    * Employees/

      * DeleteEmployee/

        * …

      * GetTeam/

        * …

      * RegisterEmployee/

        * …

      * UpdateEmployeeRoles/

        * …

      * …

    * Invitations/

      * AcceptInvitation/

        * …

      * InviteEmployee/

        * …

      * …

    * Me/ # GET /me

      * …

    * UserAccess/ # GET /me/access

      * …

    * Settings/Permissions/

      * …

    * Infrastructure/ # principal and permission resolution

      * …

    * …

  * AppointMe.Organizations.Contracts/

    * …

  * AppointMe.Organizations.Tests/

    * …

## Endpoints

**Fourteen endpoints: those with a permission also need `X-Company-Id`, while `/me`, onboarding and the invitee’s own invitation calls work before you belong to any company.**

| Method | Route                                    | Permission                | Purpose                                                                                                                                          |
| ------ | ---------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| GET    | `/api/v1/me`                             | Anonymous                 | Who’s calling: `isAuthenticated`, name, email, company memberships, `hasPendingInvitations`, `isSuperAdmin`.                                     |
| GET    | `/api/v1/me/access`                      | Signed in, with a company | The caller’s roles and effective permission codes in the active company.                                                                         |
| POST   | `/api/v1/onboarding`                     | Signed in                 | Body `{ companyName, timeZone }`. Creates the company and makes the caller its primary owner. Returns `201`.                                     |
| GET    | `/api/v1/team`                           | `employees:view`          | Employees and pending invitations in one paged list (`search`, `page`, `limit`), with `isPrimaryOwner` and `lockedRoles`.                        |
| PUT    | `/api/v1/employees/{id}/roles`           | `employees:update_roles`  | Body `{ roles }`. Adding or removing `Owner` also needs `employees:manage_owners`. The primary owner keeps `Owner`.                              |
| DELETE | `/api/v1/employees/{id}`                 | `employees:remove`        | Soft-deletes the employee. You can’t remove yourself.                                                                                            |
| POST   | `/api/v1/invitations`                    | `employees:invite`        | Body `{ email, roles }`. Inviting an `Owner` needs `employees:manage_owners`. `409` if the email is already an employee or has a pending invite. |
| GET    | `/api/v1/invitations/pending`            | Signed in                 | The caller’s pending invitations in every company, matched by email.                                                                             |
| POST   | `/api/v1/invitations/{id}/accept`        | Signed in                 | Accepts an invitation addressed to the caller’s email and creates the employee.                                                                  |
| POST   | `/api/v1/invitations/{id}/resend`        | `invitations:resend`      | Raises `EmployeeInvitationResent`, and Identity sends the email again.                                                                           |
| DELETE | `/api/v1/invitations/{id}`               | `invitations:cancel`      | Deletes a pending invitation. Returns `204`.                                                                                                     |
| GET    | `/api/v1/settings/permissions`           | `permissions:view`        | The matrix of every non-system permission against `Manager`, `Staff` and `Receptionist`, with `isGranted` and `isOverridden`.                    |
| PATCH  | `/api/v1/settings/permissions`           | `permissions:manage`      | Body `{ grants: [{ permissionKey, role, isGranted }] }`. A grant that matches the default removes the override.                                  |
| DELETE | `/api/v1/settings/permissions/overrides` | `permissions:manage`      | Removes every override for the company, back to the defaults.                                                                                    |

Onboarding gives the first employee two roles. `Staff` makes the founder bookable, because Booking turns every `Staff` employee into a service provider:

src/Organizations/AppointMe.Organizations/Companies/Onboarding/Onboarding.cs

```cs
public static (Company company, Employee owner) Onboard(
    CompanyName name,
    TimeZoneInfo timeZone,
    PersonName ownerName,
    Email ownerEmail,
    UserId registeredBy,
    DateTimeOffset registrationDate)
{
    var company = Company.Register(name, timeZone, registeredBy, registrationDate);
    var owner = Employee.Register(
        companyId: company.Id,
        name: ownerName,
        email: ownerEmail,
        roles: [Role.Owner, Role.Staff],
        userId: registeredBy,
        registrationDate: registrationDate);
    return (company, owner);
}
```

## Permissions

**Nine permissions, granted by default only to `Owner` and `Manager`, and two of them are system permissions that only `Owner` holds.**

| Permission                                  | Key                       | Owner | Manager |
| ------------------------------------------- | ------------------------- | ----- | ------- |
| `EmployeePermissions.View`                  | `employees:view`          | ✓     | ✓       |
| `EmployeePermissions.Invite`                | `employees:invite`        | ✓     | ✓       |
| `EmployeePermissions.Remove`                | `employees:remove`        | ✓     | ✓       |
| `EmployeePermissions.UpdateRoles`           | `employees:update_roles`  | ✓     | ✓       |
| `EmployeePermissions.ManageOwners` (system) | `employees:manage_owners` | ✓     | —       |
| `InvitationPermissions.Resend`              | `invitations:resend`      | ✓     | ✓       |
| `InvitationPermissions.Cancel`              | `invitations:cancel`      | ✓     | ✓       |
| `PermissionPermissions.View`                | `permissions:view`        | ✓     | ✓       |
| `PermissionPermissions.Manage` (system)     | `permissions:manage`      | ✓     | —       |

`Staff` and `Receptionist` get none of these by default. The grants live in `EmployeeRolesGrants`, `InvitationRolesGrants` and `PermissionRolesGrants`, and `OrganizationsDefaultGrantPolicy` collects them.

A `SystemPermission` never appears in the settings matrix, and `PATCH /settings/permissions` rejects it with `system_managed_permission`. `Owner` is a `SystemRole`, so its grants can’t be overridden either (`role_permissions_immutable`).

Role changes check two things beyond `employees:update_roles`: the roles the company locks for this employee, and whether the caller may touch system roles:

src/Organizations/AppointMe.Organizations/Employees/UpdateEmployeeRoles/UpdateEmployeeRolesCommandHandler.cs

```cs
principal.Require(EmployeePermissions.UpdateRoles);


var employee = await dbContext.Employees.LoadAsync(new EmployeeId(command.Id), companyId, cancellationToken);
var company = await dbContext.Companies.LoadAsync(companyId, cancellationToken);


employee.UpdateRoles(
    command.Roles.ToHashSet(),
    company.LockedRolesFor(employee.Id).ToHashSet(),
    canManageSystemRoles: principal.HasPermission(EmployeePermissions.ManageOwners));
```

Organizations also builds the principal that every module checks. `UserPrincipalFactory` loads the caller’s roles in the active company and the company’s overrides from `RolePermissionOverridesCache`, and `PermissionResolver` combines them with `DenyWinsPolicy`. The full model is in [Permissions](/docs/concepts/permissions/) and [Identity & principal](/docs/concepts/identity-and-principal/).

### Why can't the primary owner lose the Owner role?

Only `Owner` holds `employees:manage_owners` and `permissions:manage`, and both are system permissions that no override can grant to another role. If every owner could be demoted, nobody could appoint an owner or change permission settings again.

`Company.LockedRolesFor` returns `Owner` for the primary owner, so `UpdateRoles` refuses to remove it. `DeleteEmployeeCommandHandler` stops you removing yourself.

## Events

**Organizations publishes nine events from its Contracts project, and the only event it consumes is its own `CompanyPermissionsChanged`.**

| Event                         | Raised by                                                             | Handled by                                                                                             |
| ----------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `CompanyRegistered`           | `Company.Register`, during onboarding                                 | Booking upserts a `BookingCompany`. The API host’s `DemoSeedingSaga` starts when `Demo:Enabled` is on. |
| `EmployeeRegistered`          | `Employee.Register`, during onboarding and when an invite is accepted | Booking creates or updates a service provider if the employee has `Staff`.                             |
| `EmployeeRolesUpdated`        | `employee.UpdateRoles`                                                | Booking runs the same sync. Losing `Staff` soft-deletes the provider.                                  |
| `EmployeeDeleted`             | `employee.Delete`                                                     | Booking soft-deletes the provider.                                                                     |
| `EmployeeInvited`             | `EmployeeInvitation.Create`                                           | Identity creates the provider account and sends the email.                                             |
| `EmployeeInvitationResent`    | `invitation.Resend`                                                   | Identity sends the email again.                                                                        |
| `EmployeeInvitationAccepted`  | `invitation.Accept`                                                   | Nothing yet.                                                                                           |
| `EmployeeInvitationCancelled` | `invitation.Cancel`                                                   | Nothing yet.                                                                                           |
| `CompanyPermissionsChanged`   | Returned by the update and reset handlers                             | `CompanyPermissionsChangedEventHandler` in this module drops the company’s cached overrides.           |

Other modules can also read Organizations directly, through two interfaces in Contracts. Booking’s reconciliation jobs call `ICompanyRehydrationSource.GetAll` and `IEmployeeRehydrationSource.GetAllByCompany`, and CRM’s demo top-up job calls `GetAll`. How events cross modules: [Domain events & messaging](/docs/concepts/messaging/).

## Database and wiring

**Four tables in the `organizations` schema, each with a company filter except `Companies`, because a company is the tenant.**

| Table                                   | Entity                   | Query filters             | Notes                                                                                                                   |
| --------------------------------------- | ------------------------ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `organizations.Companies`               | `Company`                | None                      | `TimeZone` is stored as its id. `PrimaryOwnerEmployeeId` references `Employees`.                                        |
| `organizations.Employees`               | `Employee`               | `SoftDelete`, `CompanyId` | `Roles` is a JSON array of names. Unique on (`CompanyId`, `Email`) where not deleted. Computed `SearchKey` for `/team`. |
| `organizations.EmployeeInvitations`     | `EmployeeInvitation`     | `CompanyId`               | `Status` is stored as a string. Unique on (`CompanyId`, `Email`) while `Pending`. Cancelling deletes the row.           |
| `organizations.RolePermissionOverrides` | `RolePermissionOverride` | `CompanyId`               | Key (`CompanyId`, `Role`, `PermissionCode`). Deleted with its company.                                                  |

`GET /me` and the invitee’s calls skip only the company filter, with `IgnoreQueryFilters([EmployeeFilters.CompanyId])` and its invitation equivalent. Filters are explained in [Multi-tenancy](/docs/concepts/multi-tenancy/#writes-ef-core-query-filters).

`AddOrganizationsModule()` takes no configuration and binds no keys. Besides the DbContext, endpoints and permissions, it registers `DenyWinsPolicy` as the `IOverrideConflictPolicy`. `GET /me` reads `Authentication:SuperAdmins` through the `SuperAdminRegistry` the API host registers, and the module has no background jobs.

Gotcha

Nothing ever sets `InvitationStatus.Expired`. `Accept` and `Resend` reject an invite past `ExpiresAt`, but the row stays `Pending`: it still shows in `/team` and `/invitations/pending`, and it blocks a new invite to that email (`409 invitation_already_exists`) until someone cancels it.

Gotcha

`DELETE /employees/{id}` only stops you removing yourself. A `Manager` holds `employees:remove` by default, so a manager can remove the primary owner or any other owner.

## Extend it

**Roles, company settings and the conflict policy are each one or two files away.**

| Change                            | Start here                                                                                                                                                                                                                                                                                                       |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Add a role                        | Add it to `Role.BuiltIn` in `AppointMe.Shared/Authorization/Roles/Role.cs` and grant it permissions in each module’s default grant policy. `Role.Create` already accepts unknown names, but such a role gets no grants and isn’t in the settings matrix. See [Add a permission](/docs/guides/add-a-permission/). |
| Add a company setting             | `Company`, `CompanyTypeConfiguration` and a migration. There’s no update-company endpoint yet, so add a slice under `Companies/`. If Booking needs the value, add it to `CompanyRegistered` and `CompanySnapshot`.                                                                                               |
| Let a granting role win conflicts | Register `GrantWinsPolicy` instead of `DenyWinsPolicy` in `OrganizationsModule`. Both live in `AppointMe.Shared/Authorization/Permissions/OverrideConflicts`.                                                                                                                                                    |
| Expire invitations                | Add an `IRecurringJobRegistrar`, call `AddRecurringJobs(ModuleAssembly.Instance)` in `OrganizationsModule`, and set `InvitationStatus.Expired` past `ExpiresAt`. See [Background jobs](/docs/concepts/background-jobs/).                                                                                         |
| Change the invitation lifetime    | `now.AddDays(7)` in `Invitations/InviteEmployee/InviteEmployee.cs`.                                                                                                                                                                                                                                              |

## Tests

**`AppointMe.Organizations.Tests` covers permission resolution and the role rules on employees and invitations, but no handler that touches the database.**

| Folder         | Covers                                                                                                                                                                                                                                                  |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Permissions/` | `PermissionResolver`, as `when_…` specifications on the `Specification` base class from `AppointMe.Shared.Tests`. Default grants, deny-wins and grant-wins conflicts, overrides for roles the user doesn’t hold, and overrides that match the defaults. |
| `Employees/`   | `UpdateEmployeeRoles`: locked roles, system roles with and without `employees:manage_owners`, and custom role names.                                                                                                                                    |
| `Invitations/` | `EmployeeInvitation.Create`: who may invite an `Owner`.                                                                                                                                                                                                 |
| `Companies/`   | `CompanyOwnership`: only the primary owner has `Owner` locked.                                                                                                                                                                                          |

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

## Next

* [Permissions](/docs/concepts/permissions/) (Concept)
* [Identity & principal](/docs/concepts/identity-and-principal/) (Concept)
* [Add a permission](/docs/guides/add-a-permission/) (Guide)
