Champagne

Authentication

Member and staff sign in, passwords, person creation and account activation.

Members sign in per club, staff sign in globally with access to several clubs. Send the returned token as Authorization: Bearer <token> on later requests. Automation authenticates with a club API token instead of signing in.

Member sign in

mutation MemberLogin($clubId: ID!, $email: String!, $password: String!) {
  loginMember(clubId: $clubId, email: $email, password: $password) {
    token
    personId
    clubId
  }
}

The same email can exist in different clubs as separate logins. The login email is the person's profile email, compared after trimming and lowercasing, so editing a member's email changes the address they sign in with. memberViewer returns the caller's personId, clubId, guestExpiresAt (null for regular members) and external (true for temporary accounts created by external reservations), or null without a member token. Guests whose access has ended cannot sign in; see guests.

Choose a password after onboarding

People created by an accepted application have no password yet. After the application is accepted and every after-approval step is complete, choose one:

mutation ChoosePassword($applicationId: ID!, $password: String!) {
  setMemberPassword(applicationId: $applicationId, password: $password) {
    token
    personId
    clubId
  }
}

Only the applicant can call it: send the application's scoped X-Application-Token. Staff and member sessions receive UNAUTHENTICATED, even with applications_manage on the club. Passwords need 12-200 characters with at least one lowercase letter, one uppercase letter, one digit and one symbol. Seeded demo people use password123. Setting a password twice fails; duplicate emails in one club fail with a correction message.

Create people from the back office

createPerson requires a staff token with members_manage in the target club. Staff never choose the member's password: the person is created without a login and receives an activation email.

mutation AddPerson($input: CreatePersonInput!) {
  createPerson(input: $input) {
    id
    firstName
    lastName
    email
  }
}
{
  "input": {
    "clubId": "51000000-0000-4000-8000-000000000051",
    "firstName": "New",
    "lastName": "Member",
    "email": "new@example.com"
  }
}

CreatePersonInput takes clubId, firstName, lastName and email; there is no password field. Names must contain 1-100 characters after trimming and the email must be valid. Emails are unique per club after trimming and lowercasing; a duplicate fails with "This email is already used in this club.". Creation records person.created.

After the person is saved, Champagne emails a single-use activation link built from CONSUMER_ORIGIN: /activate-account?token=…&club=<clubId>. The link is valid for 7 days and the email is written in English and French. If CONSUMER_ORIGIN is not a bare http(s) origin, createPerson fails before creating anyone with "Activation emails are not configured. Please contact your administrator.". If delivery fails, the person is still created, the unsent token is discarded and the failure is logged; the person page then shows no pending link and staff can send one with resendPersonActivation.

Activate a staff-created account

mutation Activate($token: String!, $password: String!) {
  activateMemberAccount(token: $token, password: $password)
}

activateMemberAccount is public: the token is the credential. It applies the normal password policy, creates the member login with the person's current profile email, consumes every activation and reset link of that person, records person.activated (attributed to the system) and returns true. No session is returned; like a password reset, the member then signs in with loginMember. The consumer page at /activate-account links back to sign in for the club in the link.

Failures are returned as BAD_USER_INPUT with these messages:

SituationMessage
Unknown, already used or superseded token, or a token sent to an email the person no longer hasThis activation link is invalid or has already been used. Ask the club for a new one.
Token older than 7 daysThis activation link has expired. Ask the club for a new one.
The person already has a passwordThis account is already activated. Sign in instead.

Reset tokens cannot activate an account and activation tokens cannot reset a password.

Person.activated (in people and peoplePage items) and PersonDetail.activated are true once the person has a password. The back office shows an active person who is not activated as Pending activation instead of Active; suspended, booking-blocked, banned and disabled statuses take precedence. status itself and the status filter are unchanged, so pending people still match status: active.

Resend an activation email

mutation Resend($personId: ID!, $clubId: ID!) {
  resendPersonActivation(personId: $personId, clubId: $clubId)
}

Requires members_manage in the person's club. It issues a new 7-day link, emails it to the person's current profile email, then invalidates every earlier activation link and records person.activation_resent. When delivery fails, the new link is discarded, earlier links stay valid and the mutation fails with "The activation email could not be sent. Please try again.". It is refused with "This account is already activated. Sign in instead." when the person already has a password, with "This person has not finished their application steps yet." when the person comes from an accepted application whose after-approval steps are incomplete, and with "This person was not found." when the person does not belong to clubId.

Staff sign in

Staff have one global login and can access several clubs. Staff emails are stored trimmed and lowercased, so sign in is case-insensitive:

mutation StaffLogin($email: String!, $password: String!) {
  loginStaff(email: $email, password: $password) {
    token
    userId
    clubIds
  }
}

staffViewer returns the caller's userId, clubIds, per-club clubRoles, assigned reservationCategoryIds, linked resources { id clubId categoryId }, and effective clubRoles.permissions, or null without a back-office token. Linked resources are the reservation resources the caller is linked to, limited to clubs they can access. The demo owner is admin@example.com with password123 and access to every seeded club. Invalid credentials produce BAD_USER_INPUT; creating a person without staff access produces UNAUTHENTICATED.

Sign out

mutation SignOut {
  signOut
}

signOut takes no arguments and ends the session named by the request's Authorization: Bearer <token> header, for members and staff alike. It is public and always returns true: a missing, malformed, expired, or already revoked token is not an error, so clients can call it unconditionally before discarding their stored token. Other sessions of the same account stay active.

Authorization and sessions

Tokens are opaque random credentials and only their SHA-256 hashes are stored. Member sessions expire after 30 days without use and staff sessions after 7 days without use; each use extends the session, at most once a day. Sign-out, password-reset revocation, administrative revocation, and account deletion end a session immediately. Each request checks the session and current club access. Missing and revoked tokens cannot access protected operations. Time-limited application-access tokens continue to expire independently. Guest member sessions stop resolving as soon as the guest's guestExpiresAt has passed.

Members can read only their own person and their own checkout state, plus their club's reservationCategories. The member-only createReservation mutation derives identity from the session and requires an active account and current club membership. Admins manage the full back office within their clubs. Staff receive explicit per-club permissions; reservation operations additionally require an assigned reservation category. Members may list only their own upcoming reservations and start payment only for their own booked reservation; see reservations. Internal PersonDetail.note is always null for members. Club administrators can read and manage people, applications, sponsorships and billing only within their assigned clubs. peoplePage defaults to the administrator's first club when no club is specified; people includes only authorized clubs. Unknown operations are protected by default. Club directory and application configuration remain public so a new applicant can choose a club and apply. Staff directory results exclude inaccessible clubs.

staffViewer additionally returns name, email, isSpaceOwner, isPlatformAdmin, readOnly, hidesPersonalData and closedClubs, and each clubRoles entry lists the club's disabledFeatures (see Platform administration). Owners see admin management, while owners and admins see staff management. Review transitions store the administrator ID and name from the authenticated session; clients cannot choose them. History displays the current name of the linked staff account. If the account is no longer available, its recorded name is retained. Email addresses are not used as display names.

Individual staff permissions

The source requirements are the action/actor inventories in s3://champagne-storage-beb238f6/champagne-cadrage-bloc-1.md §2.4 and s3://champagne-storage-beb238f6/champagne-cadrage-bloc-2.md §2.1–2.5. The latter explicitly lists role/permission management by Direction and Technique, but does not prescribe a fixed role matrix. Champagne therefore uses individual permission selections, independently for every club, instead of inferring access from job titles. The existing administrator role manages staff permissions; only the space owner manages administrators. A staff permission cannot promote its holder or delegate access.

staffSpace.permissionCatalog { id label group implies } returns the assignable permissions in display order. group (members, operations, finance or configuration) groups them in the access editor, and implies lists the read permissions granted automatically with each one, exactly as authorization applies them; the editor shows those as "Included automatically". staffSpace.members.clubPermissions and staffSpace.invitations.clubPermissions return stored grants. staffViewer.clubRoles.permissions returns effective grants, including read dependencies and all permissions for club administrators.

PermissionAuthorized actions
members_readMember profiles, internal notes, membership/status history, signed documents, attendance summaries and credit wallets
members_manageCreate/edit profiles and photos, send member notifications, inspect/revoke member sessions, resend activation emails, invite guests
member_notesEdit internal profile notes
member_statusChange member status, including suspension and banning
tags_manageCreate, rename and delete member tags and set them on profiles
memberships_manageAssign/revoke memberships and initiate membership, add-on or credit checkout
applications_manageRead/review applications, transitions, email history and sponsors
billing_manageRead/refresh invoices and issue credit notes
planning_readRead reservations, availability, attendance, invitees and floor plans in assigned categories
reservations_manageCreate/edit reservations, propose dates, refund deposits, cancel sessions and manage reservation invitees in assigned categories
attendance_manageValidate tickets, mark attendance and record early check-outs in assigned categories
availability_manageEdit their own default week, extra hours and time off
chats_manageRead/send reservation messages and manage the personal inbox in assigned categories
customer_chatsStart, read and answer customer chats on any subject and manage the personal customer chat inbox
housekeeping_manageRead/update housekeeping, checklist checks and consumption in assigned categories
catalog_manageConfigure club plans, categories, resources, sessions, blocks and floor plans
settings_manageConfigure club settings, onboarding, credits, merchants, the newsletter sending domain and webhook integrations, including signing secrets
newsletters_manageWrite, test and send member newsletters

The profile-write permissions (members_manage, member_notes, member_status, tags_manage, memberships_manage) and customer_chats include members_read. Operational planning permissions and catalog_manage include planning_read. These dependencies never grant another write permission. Selecting a member when booking requires members_read in addition to reservations_manage. Creating temporary guest access also requires members_manage. Catalog access exposes the club's category configuration and floor plans; access to individual reservations still requires an assigned category. availability_manage does not include planning_read and only edits the caller's own availability.

mutation StaffPermissions(
  $spaceId: ID!
  $userId: ID!
  $clubIds: [ID!]!
  $categories: [ID!]!
  $grants: [StaffClubPermissionsInput!]!
) {
  updateStaffAccess(
    spaceId: $spaceId
    userId: $userId
    clubIds: $clubIds
    reservationCategoryIds: $categories
    clubPermissions: $grants
  )
}

Example $grants:

[
  {
    "clubId": "51000000-0000-4000-8000-000000000051",
    "permissions": ["members_read", "member_notes"]
  },
  {
    "clubId": "51000000-0000-4000-8000-000000000052",
    "permissions": ["planning_read", "attendance_manage"]
  }
]

inviteStaff accepts the same clubPermissions argument. The selected permissions are persisted with the invitation and applied on acceptance. Acceptance rechecks the inviter's current club-management scope and the reservation-category assignments. Changes to an existing member's access invalidate pending invitations for that member.

Both mutations replace access within the acting administrator's manageable clubs; other clubs are preserved. Grants for a club absent from clubIds, duplicate clubs, duplicate permissions, and unknown permission names are rejected. Omitted grants and explicit empty lists grant no operational permissions. Staff without reservation categories can still use their other granted modules. Removing all club access is allowed. The owner cannot be edited or removed, and an administrator cannot remove a staff account with access outside that administrator's clubs.

Sessions load stored permissions on every API request: revocation applies to the next request without signing out. Back-office navigation and actions use the effective viewer permissions and periodically refresh them. The server independently protects operations, including direct GraphQL calls and forged category IDs. New operations are denied to staff until explicitly included in the permission policy. Personal account settings remain available with no operational permissions.

Account settings and invitations

mutation UpdateAccount($name: String!) {
  updateStaffAccount(name: $name)
}
mutation ChangePassword($current: String!, $next: String!) {
  changeStaffPassword(currentPassword: $current, password: $next)
}
mutation Invite($spaceId: ID!, $clubIds: [ID!]!, $typeIds: [ID!]!, $email: String!) {
  inviteStaff(
    spaceId: $spaceId
    clubIds: $clubIds
    reservationCategoryIds: $typeIds
    role: staff
    email: $email
  )
}
mutation CancelInvite($spaceId: ID!, $email: String!) {
  cancelStaffInvitation(spaceId: $spaceId, email: $email)
}
mutation AcceptInvite($token: String!, $name: String!, $password: String!) {
  acceptStaffInvitation(token: $token, name: $name, password: $password) {
    token
    userId
    clubIds
  }
}

Updating the name requires an authenticated staff session and a name of 1–100 characters, but no password. changeStaffPassword separately requires the current password and a valid new password, then invalidates all staff sessions.

inviteStaff sends an invitation email and returns true only after successful delivery. No invitation token or copyable link is returned to the caller. The email names the inviter (their name, or their email when it is empty) and the invited clubs, and contains a single-use activation link, built from the configured WEB_ORIGIN, valid for 48 hours. Sending another invitation to the same email in the same space replaces the previous one. An email whose account already has access to one of the chosen clubs is refused with This person already has access to one of these clubs. Edit their access instead.: change that person's access instead. Delivery failure invalidates the new token and returns a retryable error. Acceptance rechecks the inviter's current management permissions, club scope, role, and reservation-category assignments.

cancelStaffInvitation deletes the pending invitation of that email in the space, so its link stops working, and returns true. It requires the rights needed to send that invitation (the owner for an admin invitation, otherwise an administrator of every invited club) and records staff.invitation_canceled; an email without a pending invitation is refused with This invitation is no longer pending.

acceptStaffInvitation atomically creates/links the account, consumes the invitation, and returns a 12-hour staff session (token, userId, clubIds). The frontend stores this session and opens the first authorized club directly. An existing administrator must enter their current password; their name and password are not overwritten.

Password recovery

mutation Forgot($kind: String!, $email: String!, $clubId: ID) {
  requestPasswordReset(kind: $kind, email: $email, clubId: $clubId)
}
mutation Reset($token: String!, $password: String!) {
  resetPassword(token: $token, password: $password)
}

Use kind: "staff" for the backoffice or kind: "member" plus the club ID for members. The request returns true without revealing whether an account exists. Known accounts receive a single-use 30-minute link via the configured mail provider. Repeated requests within a minute are suppressed. Links use WEB_ORIGIN for staff and CONSUMER_ORIGIN for members, never a caller-supplied redirect. A successful reset revokes existing sessions and other reset links. Password policy applies to every new password.

A member request for a person in that club who has no password yet (created by staff and never activated) sends a new 7-day activation link instead of a reset link, with the same neutral true response. An accepted applicant whose after-approval steps (payment, signature…) are incomplete gets no link: they choose their password with setMemberPassword once the steps are done. Earlier activation links are invalidated once it is delivered. Requests within a minute of the previous activation link, including the one sent at creation, are suppressed.

Member activity

query Activity($personId: ID!, $clubId: ID!) {
  memberSessions(personId: $personId, clubId: $clubId) {
    lastLoginAt
    active
    activated
    activationExpiresAt
  }
}
mutation Revoke($personId: ID!, $clubId: ID!) {
  revokeMemberSessions(personId: $personId, clubId: $clubId)
}

Both operations require members_manage in the person's club. lastLoginAt is an ISO timestamp or null if the person has not signed in. active means at least one session exists, not that the browser is currently online. activated is true once the person has a password. activationExpiresAt is the expiry of the latest activation link still stored for a person who is not activated (it may be in the past), or null when the person is activated or no link is pending. Revocation invalidates all member sessions immediately on the server; open clients recheck their viewer periodically.

Applicant access and development accounts

startApplication returns an accessToken scoped to the created application. Send it as X-Application-Token to read or advance that application before member login. It expires after seven days. Mailbox-verified recoverApplications results each include an accessToken. An OTP-verified continuation from advanceApplication also returns a token for its destination application. The consumer stores these per application in session storage; a fresh browser session uses mailbox recovery. An application UUID alone never grants access. Club administrators and the linked member can also access the application using their normal session.

demoMembers(clubId: ID!): [DemoMember!]! returns id, name, and email for seeded accounts only when DEMO_SEED=true, including production builds of demo deployments. Otherwise it returns an empty list. Results are restricted to seeded demo person IDs with login credentials in the requested club; real members are never listed. Real applicants are not assigned the demo password by seeding. In demo mode, the consumer login prefills the first returned member's email and the existing seeded demo password, both on initial selection and when changing clubs. Switching clubs clears the previous credentials while the new club loads; clubs without demo accounts stay blank. Manual edits survive late responses and background refreshes. Selecting a demo account explicitly restores its demo credentials. Signing in still uses the normal authenticated login mutation; no session is created automatically and non-demo accounts are never prefilled.

Account restrictions

Two independent switches restrict a back-office account, whatever its role and permissions. They are set with inviteStaff(readOnly:, hidesPersonalData:) and updateStaffAccess(readOnly:, hidesPersonalData:) (both default to false), returned by staffSpace.members and staffSpace.invitations, and reported to the account itself by staffViewer { readOnly hidesPersonalData }. They are stored per space and apply to the whole account: an account restricted in one of its spaces is restricted everywhere. The space owner is never restricted. Only the owner can change an admin's restrictions; admins change those of staff.

readOnly — every query the role and permissions allow keeps working, and every mutation is refused with UNAUTHENTICATED and the message This account is read only. The exceptions are the account's own updateStaffAccount, changeStaffPassword, updateStaffProfile (own profile only) and reportBug, signing in and out, and platform operations for a platform admin. Staff document uploads over REST answer 403.

hidesPersonalData — the account reads the same records, pseudonymized:

  • A person's name becomes Member followed by a six-character code derived from the person's ID, so the same person carries the same pseudonym in lists, the planning, invoices and the activity history. On Person, PersonDetail, Application and ApplicationSummary, firstName is Member and lastName is the code. Names without a linked person (walk-in or external customers, invitees) read Guest.
  • Email, phone, address, birth date, gender, photo, company identifiers, custom attribute values, application answers, internal notes, status and rejection reasons, customer sheets, invoice payer details and invoice PDF links are returned empty or null.
  • Chat subjects and message bodies read Hidden.
  • Activity entries about a person, application, reservation, invoice or membership carry the pseudonym and no note or changes; configuration and team history is unchanged.
  • peoplePage, people, applications and invoices refuse the search, minAge, maxAge, gender and attributes arguments, and the following operations are refused, all with This account cannot access personal data.: creating or editing a profile, photo, note or attribute values, sending a member notification or newsletter test, downloading a signed document, inviting a guest or reservation invitee, creating a staff reservation, writing chat messages or customer sheets, and managing the team (which could otherwise grant an unrestricted account).

Combining both gives a technical account that can inspect production without changing it or learning who the members are. Staff names and emails are not member data and stay visible. Restrictions are re-read on every request.

Admin and staff management

A space groups clubs under an owner. Clubs belong to one space and back-office accounts can belong to multiple spaces. Owners manage admins and staff; admins can manage staff within the clubs they administer. Staff cannot delegate access. New invitees enter their name when accepting an invitation.

query Spaces {
  staffSpaces {
    id
    name
  }
}
query Staff($spaceId: ID!) {
  staffSpace(spaceId: $spaceId) {
    id
    name
    ownerId
    clubs {
      id
      name
    }
    reservationCategories {
      id
      clubId
      name
    }
    members {
      id
      name
      email
      isOwner
      clubIds
      role
      reservationCategoryIds
    }
  }
}
mutation StaffAccess($spaceId: ID!, $userId: ID!, $clubIds: [ID!]!, $typeIds: [ID!]!) {
  updateStaffAccess(
    spaceId: $spaceId
    userId: $userId
    clubIds: $clubIds
    reservationCategoryIds: $typeIds
  )
}
mutation RemoveStaff($spaceId: ID!, $userId: ID!) {
  removeStaffAccess(spaceId: $spaceId, userId: $userId)
}

staffSpaces lists spaces the caller can manage, sorted by name. staffSpace returns the admins, staff, invitations, clubs, and reservation categories in the manageable scope. The owner sees the whole space. A club admin who does not own the space sees only the members and invitations with access to at least one club they administer, plus staff and invitations with no club in the space (the owner is not listed unless they hold such access); people who only work in the space's other clubs are left out. These lists have no filtering or pagination. Empty spaces return empty lists. Foreign spaces and unauthorized callers are rejected. Club IDs must be distinct, belong to the selected space, and number at most 200. Invitations require at least one club. Access edits accept an empty list to revoke every club in that space and preserve access to other spaces. The owner cannot have their own club access edited here. Edits invalidate pending invitations for that person in the same space. Existing sessions see current access on their next server request.

Staff assignments must reference reservation categories from the selected clubs. Removing a staff member removes their access and planning assignments immediately. Removing a staff member, or withdrawing their access to a club, also unlinks them from that club's resources (ReservationResource.staffUserIds), so those resources stay editable and their slots no longer follow the former coach's availability. Existing bookings are kept. Only the owner can invite, edit, or remove admins; the owner account itself cannot be removed.

Pending invitations

staffSpace(spaceId: ID!) also returns invitations { email clubIds role reservationCategoryIds readOnly hidesPersonalData expired }, ordered by email. These entries appear in the matching Admins or Staff table with an invitation-pending or invitation-expired status and a button that cancels the invitation with cancelStaffInvitation. No token or invitation URL is returned.

Demo staff accounts

With demo seeding enabled, all these accounts initially use password123:

EmailNameClub accessRole
admin@example.comDemo AdministratorUkab Clubs, Vosges, AstelSpace owner
ukab.staff@example.comSport coachUkab ClubsStaff
vosges.staff@example.comAlex BernardVosgesAdmin
multi.staff@example.comLouise MoreauVosges, AstelAdmin
tech.staff@example.comTechnical supportUkab Clubs, Vosges, AstelAdmin
chloe.dubois@example.comChloé DuboisUkab ClubsStaff
ines.laurent@example.comInès LaurentUkab ClubsStaff
manon.petit@example.comManon PetitUkab ClubsStaff
lea.garnier@example.comLéa GarnierUkab ClubsStaff

tech.staff@example.com is read only with personal data hidden (see Account restrictions).

The four Ukab Clubs stylists manage only the "Soin en salon de beauté" category, whose resources carry their names.

Additional staff accounts are created only when their email does not already exist. Reseeding preserves their edited names, passwords and club permissions, including removed access. Only owners and club administrators can manage staff or invitations. Newly seeded coaches receive planning, reservation, attendance, chat and housekeeping permissions; reseeding preserves edited permissions.

Staff edits to a member's profile email also update their existing login email atomically. Password recovery uses the updated address; links issued to a different address are no longer valid. Existing sessions and passwords remain unchanged. See Email consistency.

Camille is assigned to the seeded Ukab reservation categories and therefore lands in Planning.

Staff activity history

Every staff change is written to one append-only activity log, in the same database transaction as the change itself. The acting user comes from the authenticated session, never from mutation input. Reads are not recorded. Changes made through Stripe webhooks and background workers are attributed to the system; member self-service changes are attributed to the member.

query StaffActivity($spaceId: ID!, $userId: ID!, $page: Int!) {
  staffActivity(spaceId: $spaceId, userId: $userId, page: $page) {
    page
    hasMore
    items {
      id
      occurredAt
      action
      actorName
      clubId
      clubName
      subjectType
      subjectId
      subjectLabel
      personId
      personName
      note
      changes {
        field
        before
        after
      }
    }
  }
}

staffActivity(spaceId: ID!, userId: ID!, page: Int! = 1) returns the actions performed by one member of the space, newest first, 20 per page, ordered by occurredAt and id. page must be between 1 and 10,000. New entries can shift offset pages; refresh page 1 to see recent actions.

Access follows team management: the space owner can read every member's history, including team changes (invitations, access updates, removals and staff documents), which carry no clubId. Club admins can read the history of staff members only, limited to actions in the clubs they administer. Other callers receive UNAUTHENTICATED; a user outside the space or an admin viewed by a non-owner is rejected with the team-management error.

action is a dotted verb such as person.status_changed, reservation.attendance_marked, plan.updated or webhook.secret_revealed. subjectType and subjectId identify the changed record, and subjectLabel is its name when the action happened (a person, plan, resource, webhook URL or email). personId/personName identify the affected member when there is one; personName and actorName reflect current names. changes lists modified fields as display strings (before/after are null when empty); secrets, tokens, passwords, file contents and message bodies are never stored. note holds the reason or note entered with the change. staff.invited and staff.access_updated describe access with the same fields: role (invitations only), clubs, permissions (per club, e.g. Vosges: members_read, tags_manage) and reservationCategories. An invitation lists only the fields it grants, so the history shows who gave which right whether it came from an invitation or a later edit.

Every staff mutation must declare the activity it records, or be explicitly exempt, in staffMutationActivity; a unit test fails when a new staff mutation is missing. Exempt mutations only create checkout links, refresh provider data, mark chats as read, or change the caller's own account.

Staff professional dossiers

The Team page opens an existing account's Professional profile and Access tabs. A dossier belongs to (spaceId, userId), independently of dossiers in other spaces. It is available after invitation acceptance. No member-facing profile is published.

The space owner can manage every dossier in that space, including their own. An administrator can manage a staff dossier only when the person has staff access to at least one club they administer, or when the person is still a member of the space without access to any club (so it can be reopened and given access again). A person with any administrator role in the space (or its owner) can only have their dossier managed by the owner. Ordinary staff, including the dossier subject, and members have no access. These rules are checked against current database access for every read, write and file request.

query {
  staffDocuments(spaceId: "SPACE_UUID", userId: "STAFF_UUID") {
    id
    kind
    title
    number
    issuer
    issuedOn
    expiresOn
    filename
    contentType
    size
  }
}

staffDocuments returns all dossier documents ordered by kind, title and ID, without pagination or filters. kind is professional_card, diploma or other; multiple documents of each kind are supported. title is required (1–200 trimmed characters). number and issuer are nullable strings of at most 200 characters. issuedOn and expiresOn are nullable valid YYYY-MM-DD dates; expiration cannot precede issue. size is the byte count. Storage keys are never exposed.

mutation {
  updateStaffDocument(
    spaceId: "SPACE_UUID"
    userId: "STAFF_UUID"
    id: "DOCUMENT_UUID"
    input: {
      kind: professional_card
      title: "Professional card"
      number: "DEMO-123"
      issuer: "Issuing organization"
      issuedOn: "2026-01-01"
      expiresOn: "2031-01-01"
    }
  ) {
    id
    title
    expiresOn
  }
}

This mutation replaces the metadata; omitted optional fields are cleared. It preserves the file. removeStaffDocument(spaceId, userId, id) removes metadata and queues private-file deletion durably; repeated deletion returns true. Cleanup runs immediately and retries every minute if storage is unavailable.

Files use authenticated HTTP rather than GraphQL payloads:

  • PUT /api/staff-documents/:spaceId/:userId/:id: multipart file and metadata (JSON with all metadata fields, using null for absent optional fields). Use a client-generated document UUID for creation and reuse it when retrying. The same URL replaces an existing document's file and metadata atomically after storage succeeds. A failed upload leaves the previous document intact.
  • GET /api/staff-documents/:spaceId/:userId/:id: attachment download with Cache-Control: private, no-store and X-Content-Type-Options: nosniff.

Both endpoints require the staff Bearer token. PDF, JPEG and PNG files must have matching detected signatures and MIME types, be nonempty, and be at most 10 MiB. Requests are bounded to that limit plus 64 KiB multipart overhead. Invalid inputs return 400, oversized bodies 413, unauthenticated requests 401, unauthorized scopes 403, missing documents 404, and unconfigured storage 503. Unexpected failures are masked. GraphQL uses BAD_USER_INPUT for invalid metadata and FORBIDDEN for dossier authorization failures after the staff-management gate.

The UI shows Not expired, Expired, or Expiry not provided using the Europe/Paris calendar date; expiration is inclusive of its stated date. This is only a date indicator, not certification. No reminders, planning restrictions, or document version history are provided.

Storage uses the existing private TICKET_S3_* configuration with the separate staff-documents/ key prefix. With no S3 configuration in development, files live under /app/.local/staff-documents (override: STAFF_DOCUMENT_STORAGE_DIRECTORY). Partial S3 settings and production never fall back to local storage. Interrupted uploads are queued for cleanup after one hour. Removing team access makes the dossier inaccessible to ordinary administrators; the dossier remains associated with its space and account and is available again if that account rejoins.

Expired documents in the team directory

staffSpace(spaceId: ID!).members.expiredDocumentCount: Int counts professional documents whose expiration date is strictly before today's calendar date in Europe/Paris. Documents expiring today remain current until the end of the day; missing expiration dates are not counted. The count is scoped to this team space. It returns 0 for an accessible dossier with no expired documents and null when the viewer cannot consult that person's dossier. In particular, other administrators' and the owner's counts are hidden from club administrators, as are staff dossiers without a shared managed club. Ordinary staff and members cannot query staffSpace.

query TeamDocumentStatus($spaceId: ID!) {
  staffSpace(spaceId: $spaceId) {
    members {
      id
      name
      expiredDocumentCount
    }
  }
}

The team screen filters the returned members locally by a positive count; this adds no server filter, sort or pagination argument. Invitations have no dossier and are excluded from the expired-documents filter. Counts refresh after document changes. The document list keeps its categories and displays expired documents first within each category, then sorts by expiry date with undated files last. The private preview uses the authenticated file download endpoint and releases its temporary browser URL when closed. It grants no public or member access.

Approaching document deadlines

Staff members also expose nullable expiringDocumentCount, using the same dossier permissions and team-space scope as expiredDocumentCount. It counts expiry dates from today through today plus 30 calendar days, inclusive, in Europe/Paris. Expired and undated documents are excluded. Unauthorized counts are null. The team directory offers an “Expires within 30 days” client-side filter; there are no automatic reminders or planning restrictions.

Dossier consultation details

StaffMember.nextDocumentExpiry returns the earliest expiry date on or after today (Europe/Paris), including dates beyond 30 days. It is null when there is no upcoming expiry or the viewer cannot access the dossier; the same space and role checks as the document counts apply.

StaffDocument.addedOn is the first upload's date in Europe/Paris. It is assigned by the server and preserved by metadata edits and file replacements. Earlier documents without a recorded upload date return null.

The dossier's search filters titles and issuing organizations locally, ignoring case and accents, while retaining category grouping and expiry ordering. Search does not change GraphQL arguments or pagination.

Login attempt limits

Member and staff password logins consume a shared PostgreSQL quota before looking up credentials or verifying the password. Each normalized account has 10 attempts per 15-minute window. Member accounts are scoped to their club; staff accounts have a separate scope. Successful logins also count and do not reset the window.

Each network peer has an additional quota of 100 attempts per minute across all accounts and both login operations. The peer address comes from the server's connection, never from X-Real-IP or GraphQL arguments. When that connection comes from a loopback address (127.0.0.1, ::1 or ::ffff:127.0.0.1), which in production is the Caddy proxy running in the same container, the first valid IP address of X-Forwarded-For is used instead, so each client keeps its own quota. The header is ignored from any other peer and when it holds no valid address. Behind another proxy (such as the development Caddy container), its users share the peer quota. Requests without transport address information share an unknown bucket.

Windows begin on the first accepted attempt and expire using the database clock. Concurrent requests and multiple API instances share atomic counters. Rejected attempts do not extend the expiry. Expired rows are reclaimed after one day during later login requests; account and peer identifiers are stored as SHA-256 digests. Quota exhaustion returns the same Invalid email or password. response as invalid credentials. It never triggers password verification or creates a session. Passwords over 200 characters are rejected before verification as well.

Member-visible coach profiles

staffViewer and staffSpace.members include nullable bio and avatar fields. Staff can edit their own profile; a space owner or an administrator can edit a staff member they manage in that space. Profile fields belong to the global staff account and are shared across its clubs.

mutation UpdateCoachProfile($bio: String, $avatar: String, $spaceId: ID, $userId: ID) {
  updateStaffProfile(bio: $bio, avatar: $avatar, spaceId: $spaceId, userId: $userId)
}

Omit userId to edit yourself. Editing another user requires their userId and spaceId; the server checks team membership, administrative scope and shared club access. Only the space owner can edit another administrator. The mutation returns true on success. It replaces both profile fields: omitted, null or empty fields clear the existing value. Biographies are trimmed and limited to 4,000 characters. Photos use PNG or JPEG data URLs, with a verified file signature and a decoded size of at most 512,000 bytes, matching member profile photos.

Members see only the coach's id, name, bio and avatar through messages in their own reservation conversations; see reservations.

StaffViewer.qualifications and StaffMember.qualifications contain the diploma and professional-card metadata shown in the coach profile preview, scoped to the associated clubs. Original staff documents remain available only through the administrative dossier.

On this page