# Member notifications

Member inbox, per-category channel preferences, manual staff messages, reminders, and session cancellation.

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

Members open **My account → Notifications** for their inbox and **Preferences** for channel settings.
The inbox shows read and unread entries, links to the relevant account page, and supports marking one or
all entries read, or one entry unread. **All / Unread** filters the complete inbox before pagination; changing the filter returns to page 1. Page navigation returns focus and scroll to the inbox heading. Pages contain 20 items, newest first (creation time, then ID descending).

```graphql
query Notifications($clubId: ID!, $page: Int = 1, $unreadOnly: Boolean = false) {
  myNotifications(clubId: $clubId, page: $page, unreadOnly: $unreadOnly) {
    items {
      id
      category
      title
      detail
      startsAt
      timeZone
      body
      href
      createdAt
      readAt
    }
    unreadCount
    hasMore
  }
  myNotificationPreferences(clubId: $clubId) {
    category
    inApp
    email
  }
}
mutation Preferences($clubId: ID!, $input: NotificationPreferenceInput!) {
  setMyNotificationPreference(clubId: $clubId, input: $input) {
    category
    inApp
    email
  }
}
mutation Read($clubId: ID!, $id: ID) {
  markMyNotificationsRead(clubId: $clubId, id: $id)
}
mutation Unread($clubId: ID!, $id: ID!) {
  markMyNotificationUnread(clubId: $clubId, id: $id)
}
```

All operations require member authentication in the requested club. The recipient comes from the
session; the API accepts no recipient ID. Staff cannot use these member operations. IDs must be
UUIDs; `page` defaults to 1 and accepts 1–10,000. `unreadOnly` defaults to false (including explicit null); true returns only entries with no `readAt`. Filtering happens before the 20-item page limit and offset. Alternative sorting is not exposed. `readAt` is nullable; timestamps are ISO 8601 UTC strings. `detail` contains a nullable snapshot of the member, reservation or plan context. A reservation's start is not written into `detail`: `startsAt` (ISO 8601 UTC, nullable) carries it with the club's IANA `timeZone`, so the member app formats it in the interface language ("Le Rooftop · 20 oct. 2026, 19:00"); both are null for other notifications. `href` is a relative
consumer route. `unreadCount` covers the complete visible inbox, not just the current page.
Omitting `id` marks all currently unread entries read. Both read-state mutations return whether any row changed. `markMyNotificationUnread` requires a notification UUID and resets only its `readAt` to null; it returns false for an already-unread, missing, hidden, or other member’s notification. Neither mutation generates notifications or resends email.
`markMyCustomerChatRead` also marks read the member's unread `chat` notifications of that
conversation (`href` `/account/chats/<chatId>`), so a club message counts once in the inbox badge.

`NotificationPreferenceInput` requires `category`, `inApp`, and `email`. Categories are:

| Category      | Events                                                                                                |
| ------------- | ----------------------------------------------------------------------------------------------------- |
| `reservation` | Reservation creation, status and detail changes                                                       |
| `proposal`    | A new or changed staff date proposal                                                                  |
| `sponsorship` | A sponsored person receives a membership (excluding add-ons)                                          |
| `guest`       | The member's guest accepts an invitation                                                              |
| `reminder`    | A confirmed reservation starts within 24 hours                                                        |
| `waitlist`    | A place becomes available, or a waiting member books the session                                      |
| `renewal`     | A Stripe subscription's paid period ends within 7 days, or its price changes after one month's notice |
| `session`     | A booked or waitlisted session occurrence is canceled                                                 |
| `message`     | A professional sends a reservation chat message                                                       |
| `chat`        | A professional starts or answers a customer chat                                                      |
| `club`        | A club administrator sends a manual message from the back office                                      |
| `credits`     | A credit debit drops the balance below the club's low balance alert                                   |

Both channels default to enabled. Saving a category updates only that category. In-app settings
apply to future notifications; existing inbox history remains. Email preferences are checked
again before a queued email is claimed. Re-enabling a category does not backfill suppressed events.
The existing reservation confirmation mail journal respects the `reservation.email` preference;
the notification worker does not send another confirmation email. Notification emails write `startsAt` like the reservation confirmation email ("Tuesday 27 October 2026 at 10:00", followed by "Times shown in Europe/Paris."), so a member promoted from the waitlist receives both emails with the same date format.

## Manual staff messages [#manual-staff-messages]

Club administrators open a member's profile in the back office and choose **Send notification**.
The dialog offers three categories: **Received package** and **Lost object** prefill an editable
message, and **Other** requires a custom one. The member receives the message in the `club`
category through the usual in-app and email channels.

```graphql
mutation Notify($input: SendPersonNotificationInput!) {
  sendPersonNotification(input: $input) {
    id
    category
    title
    body
    href
    createdAt
  }
}
```

`SendPersonNotificationInput` requires `personId`, `clubId`, and `kind`
(`received_package`, `lost_object`, or `other`); `body` is optional. The caller must be an
authenticated administrator of `clubId`, and the person must belong to that club. Both IDs
must be UUIDs. The mutation returns the created `MemberNotification`, or `null` when the person
is not found in the club.

| Kind               | Title                    | Default body                                                                                        |
| ------------------ | ------------------------ | --------------------------------------------------------------------------------------------------- |
| `received_package` | `Package received`       | `A package addressed to you has arrived at the club. Please collect it at the reception.`           |
| `lost_object`      | `Lost object found`      | `An item that may belong to you was found at the club. Please contact the reception to recover it.` |
| `other`            | `Message from your club` | None; `body` is required                                                                            |

`body` is trimmed and replaces the default text when present. It is limited to 2,000 characters and
must not contain null characters; an empty or missing `body` with `kind: other` returns
`BAD_USER_INPUT`. Titles and default bodies are stored in English and translated by the member app;
the back office sends `body: null` when the prefilled text is left unchanged so that translation
still applies. `href` is always `/account/notifications`. Each send creates a new entry: there is no
deduplication of repeated manual messages. The `club` in-app and email preferences apply, and the
email is delivered by the notification worker like any other category.

## Scheduling and delivery [#scheduling-and-delivery]

Notification records commit in the business transaction independently of club webhook settings.
The existing outbound webhook transport remains available and retains its own selection settings.
The API's scoped worker scans each minute, at most 100 due records per event category, and processes
up to 100 notification emails. Reminder windows are currently fixed at 24 hours and 7 days.
Reservations booked inside the reminder window are eligible on the next scan. Renewal reminders
use `paidPeriodEnd`, not an inferred anniversary; manual memberships and add-ons are excluded.
An approaching period end is not proof of a successful future charge.

Unique event keys prevent repeated reminder creation, including when channels are disabled.
Rescheduling a reservation or advancing a paid period creates a new reminder key. Before email
send, canceled/rescheduled reservation reminders and outdated membership reminders are skipped.
Expired reminder emails are also skipped. Proposal emails are sent only while the notified proposal
version is still current, awaiting acceptance, and its start time is in the future. Replaced,
accepted, canceled, or expired proposals are skipped, including on retry. Inbox entries remain
historical records.

When a place opens, the waitlist worker books eligible waiting members through the normal
reservation checks. Successful booking removes the waitlist entry and emits `waitlist.joined`;
the notification links to the resulting reservation and asks the member to check its status and
any payment due. A member booking directly while on the waitlist receives the same notification.

Notification emails are subject `<club name> · <title>` and greet the member by first name, followed by
the body, the context and a link to the page in the member's club account. Reservation confirmation,
security deposit and reservation invitation emails use the same `<club name> · …` subject. A
reservation's context names the session title when the reservation belongs to a titled session,
otherwise the category.

"Reservation update" notifications describe the new status in the member app's words: `pending`
is "Your request was sent to the club.", `booked` is "Your reservation is booked, payment is due.",
and `confirmed` and `completed` say so. A cancellation adds what happened to the price, read from
the reservation when the notification is created: "The price was not refunded." when it was kept
past the cancellation notice, "Your payment is being refunded." after a card refund, or "The price
returned to your credit balance." after a credit refund; otherwise "Your reservation is canceled.". Email requires the configured mail provider and `CONSUMER_ORIGIN`. The worker commits a sending
claim before contacting the provider. Definite failures retry after one hour; uncertain outcomes
and abandoned sending claims become `unknown` and are not automatically resent. Provider
acceptance is not inbox-delivery confirmation. These are in-app and email channels; browser/mobile
push and SMS are not configured.

## Session occurrence cancellation [#session-occurrence-cancellation]

```graphql
mutation CancelSession($clubId: ID!, $categoryId: ID!, $occurrenceId: String!) {
  cancelReservationSession(clubId: $clubId, categoryId: $categoryId, occurrenceId: $occurrenceId)
}
```

Club admins and staff assigned to the reservation category can cancel a future occurrence. IDs follow
`reservationAvailability`: a custom session ID, or weekly definition ID plus `:YYYY-MM-DD` in club
time. A malformed occurrence ID (not one of those two shapes) is rejected with `BAD_USER_INPUT`.
Invalid scope or a past occurrence returns false; an already-canceled occurrence returns true.
The back-office session roster exposes **Cancel session** with a confirmation step.

Cancellation closes that occurrence to bookings/waitlisting, cancels pending/booked/confirmed
reservations and notifies their owners, then removes and notifies its waitlist. Other occurrences
of a recurring definition remain available. Reservation lifecycle webhooks still fire, but members
receive the session-category notification rather than a second reservation-category cancellation.
Paid reservations are refunded by a scoped worker using the existing idempotent Stripe refund
operation; failures remain discoverable and retry each minute. Session cancellation is effective
while refunds are pending. Deleting a session definition is rejected while active reservations,
waitlist entries, or unrefunded payments remain; cancel the affected occurrences first.

### Undo marking all notifications read [#undo-marking-all-notifications-read]

```graphql
mutation ReadAll($clubId: ID!) {
  markAllMyNotificationsRead(clubId: $clubId) {
    ids
    readAt
  }
}
mutation UndoReadAll($clubId: ID!, $receipt: NotificationReadReceiptInput!) {
  undoMyNotificationsRead(clubId: $clubId, receipt: $receipt)
}
```

`markAllMyNotificationsRead` atomically marks all currently unread in-app notifications
for the authenticated member and club, across all pages. It returns the exact changed
UUIDs and their UTC ISO timestamp (`readAt`); `ids` is empty when nothing changed.
Pass that receipt to `undoMyNotificationsRead` to restore those notifications to unread.
Only records still carrying the receipt's timestamp are restored, preserving later
read-state changes. The mutation returns the restored UUIDs and is safe to repeat.
IDs must be valid UUIDs and `readAt` must be a UTC ISO timestamp with milliseconds;
otherwise the mutation fails with `BAD_USER_INPUT`.
Both operations require member authentication for the selected club and leave
previously read entries, other members, notification delivery and email unchanged.
