# Platform administration

Spaces, clubs and owners managed by platform admins.

Source: https://docs.champagne.dev-ukab.fr/en/graphql/platform

Platform admins run the Champagne instance itself. They create a space for each customer,
add its clubs and choose its owner. The role is a flag on the staff account
(`staffViewer.isPlatformAdmin`); it gives no access to the data of any club. A platform admin
who also works in a club needs club access like any other staff member.

The demo seed creates the platform admin `superadmin@example.com`, who has no club. Platform
admins then give the role to others with `grantPlatformAdmin`.

## Permissions [#permissions]

| Operation                                                                                              | Permission     |
| ------------------------------------------------------------------------------------------------------ | -------------- |
| `platformSpaces`                                                                                       | Platform admin |
| `platformAdmins`, `platformActivity`, `grantPlatformAdmin`, `revokePlatformAdmin`                      | Platform admin |
| `createPlatformSpace`, `createPlatformClub`, `setPlatformSpaceOwner`, `cancelPlatformOwnerInvitation`  | Platform admin |
| `renamePlatformSpace`, `updatePlatformClub`, `setClubStatus`, `setClubFeatures`, `setClubPhoneCodeCap` | Platform admin |
| `setPlatformSpaceArchived`                                                                             | Platform admin |

\| `platformApiTokens`, `createPlatformApiToken`, `setPlatformApiTokenBlocked`, `deletePlatformApiToken` | Platform admin |

Club admins and space owners are refused with `UNAUTHENTICATED`. A
[platform API token](/en/graphql/api-tokens#platform-tokens) is limited to `platformSpaces`,
`createPlatformClub`, `updatePlatformClub`, `setClubStatus` and `setClubFeatures`.

## List spaces [#list-spaces]

```graphql
query PlatformSpaces {
  platformSpaces {
    id
    name
    owner {
      id
      name
      email
    }
    pendingOwner {
      email
      expiresAt
      expired
      removesPreviousOwner
    }
    clubs {
      id
      name
      timezone
      hostedSlug
      status
      disabledFeatures
    }
  }
}
```

Spaces are ordered by name, and their clubs by name. `owner` is null until an invited owner
accepts. `pendingOwner` is the owner invitation waiting to be accepted, for a new space or
a transfer. `expiresAt` is when its link stops working, 7 days after it was sent, and
`expired` turns true from then on: the invitation must then be sent again.
`removesPreviousOwner` tells whether the current owner loses their access once it is
accepted.

## Create a space [#create-a-space]

```graphql
mutation CreatePlatformSpace($input: CreatePlatformSpaceInput!) {
  createPlatformSpace(input: $input) {
    id
    owner {
      email
    }
    pendingOwner {
      email
    }
  }
}
```

```json
{
  "input": {
    "name": "Riviera group",
    "ownerEmail": "owner@riviera.example",
    "clubs": [{ "name": "Riviera Nice", "timezone": "Europe/Paris", "hostedSlug": "riviera-nice" }]
  }
}
```

The space and its clubs are created in one transaction. Each club starts with the default
settings and onboarding of any new club.

* If `ownerEmail` matches an existing staff account, that account becomes owner at once and
  admin of every club in the space.
* Otherwise the email receives an invitation to the back office's `/invitation` page.
  Accepting it creates the account (or reuses one created since), makes it owner and admin
  of every club in the space. The invitation never appears in the space's team list.

If the email cannot be delivered, the space stays created, the invitation is withdrawn, and
the mutation fails with a message saying so. Send the invitation again with
`setPlatformSpaceOwner`.

## Add a club [#add-a-club]

```graphql
mutation CreatePlatformClub($spaceId: ID!, $input: PlatformClubInput!) {
  createPlatformClub(spaceId: $spaceId, input: $input) {
    clubs {
      id
      name
    }
  }
}
```

The owner, if there is one, becomes admin of the new club. Other staff get access from the
team page as usual.

## Change the owner [#change-the-owner]

```graphql
mutation SetPlatformSpaceOwner($spaceId: ID!, $email: String!, $removePreviousOwner: Boolean) {
  setPlatformSpaceOwner(
    spaceId: $spaceId
    email: $email
    removePreviousOwner: $removePreviousOwner
  ) {
    owner {
      email
    }
    pendingOwner {
      email
    }
  }
}
```

The same rule applies: an existing account takes over at once, anyone else is invited. The
previous owner stays owner until the invitation is accepted. Calling it again with the
pending email sends a fresh invitation and withdraws the previous one.

`removePreviousOwner` defaults to `true`: when the new owner takes over (at once for an
existing account, on acceptance for an invitation), the previous owner loses every club
access, reservation assignment and their place in the space, as if removed from the team.
Pass `false` to keep them as admin of the clubs.

## Cancel an owner invitation [#cancel-an-owner-invitation]

```graphql
mutation CancelPlatformOwnerInvitation($spaceId: ID!) {
  cancelPlatformOwnerInvitation(spaceId: $spaceId) {
    pendingOwner {
      email
    }
  }
}
```

Withdraws the owner invitation waiting on the space; its link stops working at once. The
current owner, if any, is unchanged.

## Suspend a club [#suspend-a-club]

```graphql
mutation SetClubStatus($clubId: ID!, $status: ClubStatus!) {
  setClubStatus(clubId: $clubId, status: $status) {
    clubs {
      id
      status
    }
  }
}
```

`status` is `active` or `suspended`. Suspending keeps every record of the club, but from the
next request:

* the club disappears from the `clubIds` and `clubRoles` of its staff sessions, so its
  operations are refused, and staff whose only club it was see the no-club page;
* member sessions of the club no longer resolve, and `loginMember` is refused with
  `This club is suspended. Contact the club for more information.`;
* the club leaves `clubDirectory`, and `hostedClub` returns null for its hosted address;
* it leaves `externalBookingClubs`, `externalReservationCatalog` returns null, its
  availability is empty and `createExternalReservation` is refused;
* `onboarding`, `onboardings` and `hostedOnboarding` return nothing, `startApplication` returns
  `The onboarding is unavailable.`, application recovery answers
  `This club could not be found.`, and applicant tokens stop granting access to its
  applications;
* `plans` returns no plan for it, and `membershipOffers` and `addonOffers` report
  `Merchant payments or catalog are not ready.`
* background jobs schedule no reservation or renewal reminder for it, skip its notification
  emails and promote no one from its waitlists; reservation confirmations, application
  decisions and application reminders wait and go out once the club is reactivated. Refunds,
  deposit refunds and their emails continue.

Setting `active` again restores every access, including existing sessions that have not
expired.

The people of a closed club are told why it is gone:

* `staffViewer.closedClubs` lists the clubs the account works in that are suspended or
  archived, with their `name` and `status`. The back office shows them on its no-club page.
* The public `closedClub(clubId)` query returns the club's `name` and `status` when it is
  suspended or archived, and null otherwise. The member sign-in page asks it for a remembered
  or requested club that `clubDirectory` no longer lists.

```graphql
query ClosedClub($clubId: ID!) {
  closedClub(clubId: $clubId) {
    name
    status
  }
}
```

## Archive a club or a space [#archive-a-club-or-a-space]

`setClubStatus` with `archived` closes the club exactly like a suspension; the only
difference is for the people involved: `loginMember` answers `This club is closed.` and the
platform page hides archived clubs until its archives are shown. Restore a club with
`status: active`.

```graphql
mutation SetPlatformSpaceArchived($spaceId: ID!, $archived: Boolean!) {
  setPlatformSpaceArchived(spaceId: $spaceId, archived: $archived) {
    archived
    clubs {
      id
      status
    }
  }
}
```

Archiving a space sets `archived` and archives every club of the space, suspended ones
included. While the space is archived, `createPlatformClub` and `setClubStatus` with any
status other than `archived` are refused with `This space is archived. Restore it first.`
Restoring the space puts each club back in the status it had when the space was archived: a
suspended club stays `suspended`, and a club archived before the space, or archived with
`setClubStatus` while the space was archived, stays `archived`.

## Phone code limit [#phone-code-limit]

```graphql
mutation SetClubPhoneCodeCap($clubId: ID!, $perHour: Int) {
  setClubPhoneCodeCap(clubId: $clubId, perHour: $perHour) {
    clubs {
      id
      phoneCodesPerHour
    }
  }
}
```

`phoneCodesPerHour` is the club's own hourly cap on application phone verification codes,
between 1 and 1,000,000, for example raised for an opening day. Null, the default, makes the
club follow the deployment's per-club cap (`SMS_HOURLY_CAP_CLUB`, 100 unless configured). Other
values are refused with `BAD_USER_INPUT`. The per-number, per-connection and platform caps and
the anti-bot check still apply. The change is recorded as `platform.club_phone_cap_changed`.

## Turn modules off [#turn-modules-off]

```graphql
mutation SetClubFeatures($clubId: ID!, $disabledFeatures: [ClubFeature!]!) {
  setClubFeatures(clubId: $clubId, disabledFeatures: $disabledFeatures) {
    clubs {
      id
      disabledFeatures
    }
  }
}
```

Every module is on for a new club. `disabledFeatures` replaces the list of modules turned
off, returned in the order below whatever the order sent. A module turned off removes the
staff permissions it covers for every role, club admins included: its back-office sections
disappear and its staff operations are refused with `UNAUTHENTICATED`. Member-facing
operations, such as booking or chatting from the member area, are not affected, except for
`billing` (see [Clubs with billing turned off](#clubs-with-billing-turned-off)).

| Module           | Staff permissions removed                                                   | Sections hidden                              |
| ---------------- | --------------------------------------------------------------------------- | -------------------------------------------- |
| `reservations`   | `reservations_manage`, `attendance_manage`, `chats_manage`, `planning_read` | Reservations, Club calendar, My reservations |
| `availability`   | `availability_manage`                                                       | My availability                              |
| `customer_chats` | `customer_chats`                                                            | Chats, unless reservation messages remain    |
| `housekeeping`   | `housekeeping_manage`                                                       | Housekeeping                                 |
| `newsletters`    | `newsletters_manage`                                                        | Newsletters                                  |
| `applications`   | `applications_manage`                                                       | Applications                                 |
| `billing`        | `billing_manage`                                                            | Finance                                      |

Reservation categories and resources are configured with `catalog_manage`, which also covers
plans, so these operations stay available to admins when `reservations` is off; only the
Reservations section is hidden.

Staff sessions read the modules on every request: `staffViewer.clubRoles` returns
`disabledFeatures` for each club, and its `permissions` no longer list the removed ones.

### Clubs with billing turned off [#clubs-with-billing-turned-off]

`billing` also covers members: a club with it off charges nothing. `Club.billingEnabled` is
`false` for that club in `clubSettings`, `hostedClub`, `clubDirectory` and a person's `club`,
so both apps hide prices, credits, top-ups, plans and payment steps.

Every operation that would charge money or credits for the club is refused with
`BAD_USER_INPUT` and the message `Payments are turned off for this club.`:

| Area         | Operations                                                                                                                                                                   |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Memberships  | `createMembershipCheckout`, `createApplicationCheckout`, `createPaymentMethodPortal`                                                                                         |
| Credits      | `createCreditCheckout`, `configureCredits`, and every credit debit: `payReservationCredits`, `payAddonCredits`, reservation edits that cost more, `reportProductConsumption` |
| Reservations | `createReservationCheckout`                                                                                                                                                  |
| Catalog      | `createPlan`, `updatePlan`, `splitPlan`                                                                                                                                      |
| Merchants    | `createMerchant`, `selectMerchant`, `merchantOnboarding`, `prepareMerchantCatalog`                                                                                           |
| Onboardings  | `saveOnboarding` with a `PLAN_SELECTION` or `PLAN_PAYMENT` step (returned in `error`)                                                                                        |

Bookings stay open and are free: resource prices, plan prices, deposits and product prices
count as zero, so new reservations are confirmed without payment, including online bookings.
Onboarding reads (`onboarding`, `onboardings`, `hostedOnboarding`) leave out plan selection and
payment steps, and new applications start without them. Turning `billing` off with
`setClubFeatures` also removes the plan steps that open applications have not reached yet, in
the same transaction; an accepted application left with no remaining step is marked complete. A club created with `billing` off gets a
default onboarding without them.

Turning billing off keeps existing data: invoices, credit balances and history, paid memberships,
paid reservations and stored prices are not changed. Refunds of earlier payments still work, so a
cancellation or deposit refund returns what the member paid. Stripe webhooks still finalize a
payment started before billing was turned off. Turning billing back on restores prices and steps
as they were stored.

## Platform admins [#platform-admins]

```graphql
query PlatformAdmins {
  platformAdmins {
    id
    name
    email
  }
}

mutation GrantPlatformAdmin($email: String!, $name: String!) {
  grantPlatformAdmin(email: $email, name: $name) {
    id
  }
}

mutation RevokePlatformAdmin($userId: ID!) {
  revokePlatformAdmin(userId: $userId) {
    id
  }
}
```

`platformAdmins` lists the accounts with the role, ordered by name.

`grantPlatformAdmin` gives the role to the staff account using `email`, and `name` is then
ignored. When no account uses it, a new account is created with `name` (1–100 characters)
and an unusable password, and the email receives a link to the back office's
`/reset-password` page to choose one, valid for 7 days. If that email cannot be delivered,
the new account is deleted and the mutation fails with
`The setup email could not be sent, so no account was created. Try again.`

`revokePlatformAdmin` removes the role and keeps the account and its club access. Removing
your own role is refused with `You cannot remove your own platform access.`, so the platform
always keeps at least one admin.

## Platform history [#platform-history]

```graphql
query PlatformActivity($page: Int!) {
  platformActivity(page: $page) {
    items {
      occurredAt
      action
      actorName
      subjectType
      subjectLabel
      changes {
        field
        before
        after
      }
    }
    hasMore
  }
}
```

Every platform mutation records an entry, newest first, 20 per page. `action` is one of
`platform.space_created`, `platform.space_renamed`, `platform.space_archived`,
`platform.space_restored`, `platform.club_created`, `platform.club_updated`,
`platform.club_status_changed`, `platform.club_modules_changed`,
`platform.club_phone_cap_changed`, `platform.owner_changed`,
`platform.owner_invited`, `platform.owner_invitation_canceled`, `platform.admin_granted`,
`platform.admin_revoked`, `platform.api_token_created`, `platform.api_token_blocked`,
`platform.api_token_unblocked` or `platform.api_token_deleted`. `subjectType` is `space`, `club` or `staff`, and `subjectLabel` is
its name at the time.

`changes` lists the fields that changed: `owner` (emails), `space` (for a new club), `name`,
`timezone`, `hostedSlug`, `status` and `disabledFeatures` (comma-separated module ids).
These entries belong to no club or space, so they never appear in a club's or a staff
member's history.

## Validation [#validation]

| Field        | Rule                                                                     |
| ------------ | ------------------------------------------------------------------------ |
| `name`       | Space and club names hold 1 to 100 characters after trimming.            |
| `ownerEmail` | A valid email address, compared ignoring case.                           |
| `clubs`      | 1 to 20 clubs when creating a space.                                     |
| `timezone`   | An IANA timezone, such as `Europe/Paris`.                                |
| `hostedSlug` | Optional; 3 to 63 lowercase letters, digits or hyphens, unique per club. |

Club names are saved with an uppercase first letter, so `riviera Nice` becomes `Riviera Nice`.
Names are compared ignoring case: no two spaces share a name, archived ones included, and no two
clubs of a space share a name.

Invalid input is refused with `BAD_USER_INPUT`. A hosted address already used by another club is
refused with the message `This address is already used by another club.`, a taken space name
with `Another space already has this name.`, and a club name taken in its space with
`Another club of this space already has this name.`
