openapi: 3.1.0
info:
  title: Club-tool Public API
  version: 1.1.0
  description: |
    The Club-tool public REST API lets external developers build their own
    integrations on top of their club's data — including bulk operations on
    members. Full guides live at https://developers.club-tool.nl/.

    **Authentication.** Every request needs a club API key sent as a Bearer
    token: `Authorization: Bearer ct_live_...`. Keys are created by a club
    administrator in the app (System → API) and carry scopes per resource.
    The public API is available on the **Pro** plan.

    **Calls must come from a backend.** API keys are secrets: the API sends no
    CORS headers, so browsers cannot (and should not) call it directly.

    **Errors** follow RFC 9457 (`application/problem+json`). Note: `detail`
    strings coming from domain validation may be localized (Dutch).

    **Pagination** is cursor-based: pass `limit` (max 200) and `starting_after`
    (the last `id` of the previous page); responses are `{data, has_more}`.

    **Idempotency.** Bulk endpoints require an `Idempotency-Key` header. A
    retry with the same key and body replays the stored response
    (`Idempotency-Replayed: true`); the same key with a different body is
    rejected with `409`. Keys are kept for 24 hours.

    **Rate limits** default to 300 requests/minute and 10,000/day per key;
    responses carry `X-RateLimit-*` headers and `429` includes `Retry-After`.

    **Planned:** competition match results are reserved for a future version.
  contact:
    url: https://developers.club-tool.nl/
servers:
  - url: https://app.club-tool.nl
security:
  - bearerAuth: []
tags:
  - name: club
    description: Introspection
  - name: members
    description: Club members, including bulk operations
  - name: groups
    description: Training groups (read-only)
  - name: events
    description: Club calendar events
  - name: competition
    description: Seasons and competition teams
  - name: attendance
    description: Attendance records
  - name: kennismakers
    description: Trial members (intake pipeline) — e.g. a signup form on the club website

paths:
  /api/v1/club:
    get:
      tags: [club]
      summary: Introspect the authenticated club and key
      operationId: getClub
      responses:
        '200':
          description: The club this key belongs to, plus the key's scopes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: {type: integer}
                  name: {type: string}
                  slug: {type: string}
                  key:
                    type: object
                    properties:
                      label: {type: string}
                      prefix: {type: string}
                      scopes:
                        type: array
                        items: {type: string}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}

  /api/v1/members:
    get:
      tags: [members]
      summary: List members
      operationId: listMembers
      description: Requires scope `members:read`. List items omit e-mail and
        birth date (data minimisation) — fetch a single member for details.
      parameters:
        - {$ref: '#/components/parameters/limit'}
        - {$ref: '#/components/parameters/startingAfter'}
        - {$ref: '#/components/parameters/updatedSince'}
        - name: status
          in: query
          schema: {type: string, enum: [active, trial, archived]}
        - name: group_id
          in: query
          schema: {type: integer}
      responses:
        '200':
          description: A page of members.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: {$ref: '#/components/schemas/Member'}
                  has_more: {type: boolean}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
    post:
      tags: [members]
      summary: Create a member
      operationId: createMember
      description: Requires scope `members:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MemberInput'}
      responses:
        '201':
          description: The created member (detail form).
          content:
            application/json:
              schema: {$ref: '#/components/schemas/MemberDetail'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}

  /api/v1/members/{id}:
    parameters:
      - {$ref: '#/components/parameters/id'}
    get:
      tags: [members]
      summary: Get a member
      operationId: getMember
      description: Requires scope `members:read`.
      responses:
        '200':
          description: The member (detail form, includes e-mail and birth date).
          content:
            application/json:
              schema: {$ref: '#/components/schemas/MemberDetail'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
    patch:
      tags: [members]
      summary: Update a member
      operationId: updateMember
      description: Requires scope `members:write`. Partial update — omitted
        fields stay unchanged.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MemberInput'}
      responses:
        '200':
          description: The updated member.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/MemberDetail'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
    delete:
      tags: [members]
      summary: Delete a member
      operationId: deleteMember
      description: Requires scope `members:write`. Permanently deletes the
        member (GDPR-relevant — prefer archiving via `status`).
      responses:
        '204': {description: Deleted.}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /api/v1/members/bulk:
    post:
      tags: [members]
      summary: Bulk create/update/delete members
      operationId: bulkMembers
      description: |
        Requires scope `members:write` and an `Idempotency-Key` header.
        Up to 100 mixed operations per request. Operations are applied
        independently: a failing item never affects the others — check the
        per-item `results`.
      parameters:
        - {$ref: '#/components/parameters/idempotencyKey'}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operations]
              properties:
                operations:
                  type: array
                  maxItems: 100
                  items:
                    type: object
                    required: [op]
                    properties:
                      op: {type: string, enum: [create, update, delete]}
                      id:
                        type: integer
                        description: Required for update/delete.
                      data: {$ref: '#/components/schemas/MemberInput'}
      responses:
        '200':
          description: Per-item results.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/BulkResult'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '409': {$ref: '#/components/responses/Conflict'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}

  /api/v1/groups:
    get:
      tags: [groups]
      summary: List training groups
      operationId: listGroups
      description: Requires scope `groups:read`. Change a member's group
        memberships via the members resource (`group_ids`). Note `updated_at`
        only tracks the group row itself — training-moment or membership
        changes via link tables do not bump it.
      parameters:
        - {$ref: '#/components/parameters/limit'}
        - {$ref: '#/components/parameters/startingAfter'}
        - {$ref: '#/components/parameters/updatedSince'}
      responses:
        '200':
          description: A page of groups.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: {$ref: '#/components/schemas/Group'}
                  has_more: {type: boolean}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}

  /api/v1/groups/{id}:
    parameters:
      - {$ref: '#/components/parameters/id'}
    get:
      tags: [groups]
      summary: Get a training group
      operationId: getGroup
      responses:
        '200':
          description: The group.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Group'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /api/v1/events:
    get:
      tags: [events]
      summary: List calendar events
      operationId: listEvents
      description: Requires scope `events:read`.
      parameters:
        - {$ref: '#/components/parameters/limit'}
        - {$ref: '#/components/parameters/startingAfter'}
        - {$ref: '#/components/parameters/updatedSince'}
        - name: from
          in: query
          description: Only events on or after this date.
          schema: {type: string, format: date}
        - name: to
          in: query
          description: Only events on or before this date.
          schema: {type: string, format: date}
      responses:
        '200':
          description: A page of events.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: {$ref: '#/components/schemas/Event'}
                  has_more: {type: boolean}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
    post:
      tags: [events]
      summary: Create an event
      operationId: createEvent
      description: Requires scope `events:write`. Event registrations are not
        part of API v1.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventInput'}
      responses:
        '201':
          description: The created event.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Event'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}

  /api/v1/events/{id}:
    parameters:
      - {$ref: '#/components/parameters/id'}
    get:
      tags: [events]
      summary: Get an event
      operationId: getEvent
      responses:
        '200':
          description: The event.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Event'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
    patch:
      tags: [events]
      summary: Update an event
      operationId: updateEvent
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventInput'}
      responses:
        '200':
          description: The updated event.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Event'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
    delete:
      tags: [events]
      summary: Delete an event
      operationId: deleteEvent
      responses:
        '204': {description: Deleted.}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /api/v1/seasons:
    get:
      tags: [competition]
      summary: List seasons
      operationId: listSeasons
      description: Requires scope `competition:read`. Seasons are managed in
        the app; the API is read-only here.
      parameters:
        - {$ref: '#/components/parameters/limit'}
        - {$ref: '#/components/parameters/startingAfter'}
      responses:
        '200':
          description: A page of seasons.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: {$ref: '#/components/schemas/Season'}
                  has_more: {type: boolean}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}

  /api/v1/competition-teams:
    get:
      tags: [competition]
      summary: List competition teams
      operationId: listCompetitionTeams
      parameters:
        - {$ref: '#/components/parameters/limit'}
        - {$ref: '#/components/parameters/startingAfter'}
        - {$ref: '#/components/parameters/updatedSince'}
        - name: season_id
          in: query
          schema: {type: integer}
      responses:
        '200':
          description: A page of teams.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: {$ref: '#/components/schemas/CompetitionTeam'}
                  has_more: {type: boolean}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
    post:
      tags: [competition]
      summary: Create a competition team
      operationId: createCompetitionTeam
      description: Requires scope `competition:write`. Unknown member ids from
        another club are silently dropped from `member_ids`.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/CompetitionTeamInput'}
      responses:
        '201':
          description: The created team.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/CompetitionTeam'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /api/v1/competition-teams/{id}:
    parameters:
      - {$ref: '#/components/parameters/id'}
    get:
      tags: [competition]
      summary: Get a competition team
      operationId: getCompetitionTeam
      responses:
        '200':
          description: The team.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/CompetitionTeam'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
    patch:
      tags: [competition]
      summary: Update a competition team
      operationId: updateCompetitionTeam
      description: Set `member_ids` to replace the full player list.
        `season_id` cannot be changed after creation.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/CompetitionTeamInput'}
      responses:
        '200':
          description: The updated team.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/CompetitionTeam'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
    delete:
      tags: [competition]
      summary: Delete a competition team
      operationId: deleteCompetitionTeam
      responses:
        '204': {description: Deleted.}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /api/v1/kennismakers:
    get:
      tags: [kennismakers]
      summary: List trial members
      operationId: listKennismakers
      description: Requires scope `kennismakers:read`. List items omit contact
        details (data minimisation) — fetch a single record for details.
      parameters:
        - {$ref: '#/components/parameters/limit'}
        - {$ref: '#/components/parameters/startingAfter'}
        - {$ref: '#/components/parameters/updatedSince'}
        - name: status
          in: query
          schema:
            type: string
            enum: [aangemeld, wachtlijst, actief, opvolging_nodig, in_afwachting,
                   wacht_op_formulier, lid_geworden, geen_lid]
      responses:
        '200':
          description: A page of trial members.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: {$ref: '#/components/schemas/Kennismaker'}
                  has_more: {type: boolean}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
    post:
      tags: [kennismakers]
      summary: Create a trial member (signup)
      operationId: createKennismaker
      description: Requires scope `kennismakers:write`. Typical use — a signup
        form on the club website. New records start as `aangemeld`. Add spam
        protection (captcha) on your own form.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/KennismakerInput'}
      responses:
        '201':
          description: The created trial member (detail form).
          content:
            application/json:
              schema: {$ref: '#/components/schemas/KennismakerDetail'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}

  /api/v1/kennismakers/{id}:
    parameters:
      - {$ref: '#/components/parameters/id'}
    get:
      tags: [kennismakers]
      summary: Get a trial member
      operationId: getKennismaker
      responses:
        '200':
          description: The trial member (detail form, includes contact details).
          content:
            application/json:
              schema: {$ref: '#/components/schemas/KennismakerDetail'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
    patch:
      tags: [kennismakers]
      summary: Update a trial member or change its status
      operationId: updateKennismaker
      description: >-
        Partial update of the fields in `KennismakerInput`. A status change is
        a separate action with its own semantics (a `reason` on `geen_lid`;
        `lid_geworden` creates the linked member): send only `status` and an
        optional `reason` — mixing it with other fields answers `400`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - {$ref: '#/components/schemas/KennismakerInput'}
                - type: object
                  required: [status]
                  properties:
                    status:
                      type: string
                      enum: [aangemeld, wachtlijst, actief, opvolging_nodig,
                             in_afwachting, wacht_op_formulier, lid_geworden, geen_lid]
                    reason:
                      type: string
                      description: Optional drop reason (used with `geen_lid`).
      responses:
        '200':
          description: The updated trial member.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/KennismakerDetail'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /api/v1/attendance:
    get:
      tags: [attendance]
      summary: List attendance records
      operationId: listAttendance
      description: Requires scope `attendance:read` and a `member_id` or
        `group_id` filter (attendance is unbounded).
      parameters:
        - {$ref: '#/components/parameters/limit'}
        - {$ref: '#/components/parameters/startingAfter'}
        - {$ref: '#/components/parameters/updatedSince'}
        - name: member_id
          in: query
          schema: {type: integer}
        - name: group_id
          in: query
          schema: {type: integer}
        - name: from
          in: query
          schema: {type: string, format: date}
        - name: to
          in: query
          schema: {type: string, format: date}
      responses:
        '200':
          description: A page of attendance records.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: {$ref: '#/components/schemas/AttendanceRecord'}
                  has_more: {type: boolean}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
    post:
      tags: [attendance]
      summary: Upsert one attendance record
      operationId: upsertAttendance
      description: >-
        Requires scope `attendance:write`. Setting `present` to `null` deletes
        the record. The member must train in the given group.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/AttendanceUpsert'}
      responses:
        '200':
          description: Applied.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: {type: boolean}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /api/v1/attendance/bulk:
    post:
      tags: [attendance]
      summary: Bulk upsert attendance records
      operationId: bulkAttendance
      description: Requires scope `attendance:write` and an `Idempotency-Key`
        header. Up to 100 records, applied independently.
      parameters:
        - {$ref: '#/components/parameters/idempotencyKey'}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [records]
              properties:
                records:
                  type: array
                  maxItems: 100
                  items: {$ref: '#/components/schemas/AttendanceUpsert'}
      responses:
        '200':
          description: Per-record results.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/BulkResult'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '409': {$ref: '#/components/responses/Conflict'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: A club API key (`ct_live_...`), created in the app under
        System → API.

  parameters:
    id:
      name: id
      in: path
      required: true
      schema: {type: integer}
    limit:
      name: limit
      in: query
      description: Page size (1–200, default 50).
      schema: {type: integer, minimum: 1, maximum: 200, default: 50}
    startingAfter:
      name: starting_after
      in: query
      description: Cursor — the `id` of the last item on the previous page.
      schema: {type: integer}
    updatedSince:
      name: updated_since
      in: query
      description: Only items modified on or after this ISO-8601 date/datetime
        (UTC). Enables incremental sync; deletions are not reflected — do a
        periodic full sync.
      schema: {type: string}
    idempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Unique key (max 120 chars) that makes retries safe.
      schema: {type: string, maxLength: 120}

  responses:
    BadRequest:
      description: Invalid request.
      content:
        application/problem+json:
          schema: {$ref: '#/components/schemas/Problem'}
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/problem+json:
          schema: {$ref: '#/components/schemas/Problem'}
    Forbidden:
      description: Insufficient scope, plan (Pro required) or a blocked club.
      content:
        application/problem+json:
          schema: {$ref: '#/components/schemas/Problem'}
    NotFound:
      description: The resource does not exist (or belongs to another club).
      content:
        application/problem+json:
          schema: {$ref: '#/components/schemas/Problem'}
    Conflict:
      description: Idempotency-Key reuse with a different body.
      content:
        application/problem+json:
          schema: {$ref: '#/components/schemas/Problem'}
    PayloadTooLarge:
      description: Too many items in one request.
      content:
        application/problem+json:
          schema: {$ref: '#/components/schemas/Problem'}

  schemas:
    Problem:
      type: object
      description: RFC 9457 problem details.
      properties:
        type:
          type: string
          format: uri
          description: Error documentation URL under
            https://developers.club-tool.nl/errors/.
        title: {type: string}
        status: {type: integer}
        detail:
          type: string
          description: Human-readable explanation. Domain-validation messages
            may be localized (Dutch).
        required_scope:
          type: string
          description: Present on `insufficient_scope` errors.

    Member:
      type: object
      description: List form — no e-mail or birth date (data minimisation).
      properties:
        id: {type: integer}
        name: {type: string}
        status: {type: string, enum: [active, trial, archived]}
        gender:
          type: [string, 'null']
          enum: [male, female, x]
        group_id:
          type: [integer, 'null']
          description: Primary group.
        group_ids:
          type: array
          items: {type: integer}
        trial_group_id: {type: [integer, 'null']}
        updated_at: {type: [string, 'null'], format: date-time}

    MemberDetail:
      allOf:
        - {$ref: '#/components/schemas/Member'}
        - type: object
          properties:
            email: {type: string}
            birth_date: {type: [string, 'null'], format: date}
            created_at: {type: [string, 'null'], format: date-time}

    MemberInput:
      type: object
      description: Fields for create/update. Unknown fields are rejected.
      properties:
        name: {type: string}
        status: {type: string, enum: [active, trial, archived]}
        gender:
          type: [string, 'null']
          enum: [male, female, x]
        email: {type: string}
        birth_date: {type: [string, 'null'], format: date}
        group_ids:
          type: array
          description: Full list of group memberships; the first id becomes
            the primary group.
          items: {type: integer}
        group_id:
          type: [integer, 'null']
          description: Legacy single-group alternative to `group_ids`.
        trial_group_id: {type: [integer, 'null']}

    Group:
      type: object
      properties:
        id: {type: integer}
        name: {type: string}
        type: {type: [string, 'null'], enum: [jeugd, senioren]}
        level: {type: [string, 'null']}
        sport: {type: [string, 'null']}
        age_category: {type: [string, 'null']}
        updated_at: {type: [string, 'null'], format: date-time}
        training_moments:
          type: array
          items:
            type: object
            properties:
              id: {type: integer}
              day:
                type: string
                description: Dutch day name (club language), e.g. "Maandag".
              time: {type: [string, 'null']}
              location: {type: [string, 'null']}
              variant:
                type: [string, 'null']
                enum: [zomer, winter]

    Event:
      type: object
      properties:
        id: {type: integer}
        title: {type: string}
        date: {type: string, format: date}
        date_to: {type: [string, 'null'], format: date}
        start_time: {type: [string, 'null'], description: "'HH:MM'"}
        end_time: {type: [string, 'null']}
        location: {type: [string, 'null']}
        description: {type: string}
        no_training:
          type: boolean
          description: Whether the event cancels regular trainings.
        season_id: {type: [integer, 'null']}
        updated_at: {type: [string, 'null'], format: date-time}

    EventInput:
      type: object
      description: Fields for create/update. Unknown fields are rejected.
      properties:
        title: {type: string}
        date: {type: string, format: date}
        date_to: {type: [string, 'null'], format: date}
        start_time: {type: [string, 'null']}
        end_time: {type: [string, 'null']}
        location: {type: [string, 'null']}
        description: {type: string}
        no_training: {type: boolean}
        season_id: {type: [integer, 'null']}

    Season:
      type: object
      properties:
        id: {type: integer}
        name: {type: string}
        is_current: {type: boolean}
        start_date: {type: [string, 'null'], format: date}
        end_date: {type: [string, 'null'], format: date}

    CompetitionTeam:
      type: object
      properties:
        id: {type: integer}
        name: {type: string}
        season_id: {type: integer}
        level: {type: [string, 'null']}
        captain_member_id: {type: [integer, 'null']}
        member_ids:
          type: array
          items: {type: integer}
        updated_at: {type: [string, 'null'], format: date-time}

    CompetitionTeamInput:
      type: object
      description: '`season_id` is required on create and immutable afterwards.'
      properties:
        name: {type: string}
        season_id: {type: integer}
        level: {type: [string, 'null']}
        captain_member_id: {type: [integer, 'null']}
        member_ids:
          type: array
          items: {type: integer}

    Kennismaker:
      type: object
      description: Trial member, list form — no contact details (data minimisation).
      properties:
        id: {type: integer}
        name: {type: string}
        status:
          type: string
          description: Intake pipeline status (Dutch domain terms), from
            `aangemeld` (signed up) to `lid_geworden` (became a member) or
            `geen_lid` (did not join).
        group_id: {type: [integer, 'null']}
        signup_date: {type: [string, 'null'], format: date}
        start_date: {type: [string, 'null'], format: date}
        follow_up_date: {type: [string, 'null'], format: date}
        member_id:
          type: [integer, 'null']
          description: Set once the trial member became a club member.
        created_at: {type: [string, 'null'], format: date-time}
        updated_at: {type: [string, 'null'], format: date-time}

    KennismakerDetail:
      allOf:
        - {$ref: '#/components/schemas/Kennismaker'}
        - type: object
          properties:
            email: {type: string}
            phone: {type: string}
            parent_name: {type: string}
            birth_date: {type: [string, 'null'], format: date}
            sessions_required: {type: integer}
            notes: {type: string}
            drop_reason: {type: [string, 'null']}

    KennismakerInput:
      type: object
      description: Fields for create/update. Unknown fields are rejected. Use
        the status-only PATCH form for status changes.
      properties:
        name: {type: string}
        birth_date: {type: [string, 'null'], format: date}
        parent_name: {type: string}
        email: {type: string}
        phone: {type: string}
        group_id: {type: [integer, 'null']}
        signup_date: {type: [string, 'null'], format: date}
        start_date: {type: [string, 'null'], format: date}
        sessions_required: {type: integer}
        notes: {type: string}

    AttendanceRecord:
      type: object
      properties:
        id: {type: integer}
        member_id: {type: integer}
        group_id: {type: [integer, 'null']}
        date: {type: string, format: date}
        present: {type: boolean}
        source:
          type: string
          description: "'staff' (default) or 'lid' (member self-report)."
        updated_at: {type: [string, 'null'], format: date-time}

    AttendanceUpsert:
      type: object
      required: [member_id, date]
      properties:
        member_id: {type: integer}
        group_id:
          type: [integer, 'null']
          description: Must be one of the member's groups.
        date: {type: string, format: date}
        present:
          type: [boolean, 'null']
          description: '`null` deletes the record.'

    BulkResult:
      type: object
      properties:
        results:
          type: array
          items:
            type: object
            properties:
              status:
                type: integer
                description: Per-item HTTP-like status (2xx = applied).
              id: {type: [integer, 'null']}
              member_id: {type: [integer, 'null']}
              error: {type: [string, 'null']}
        succeeded: {type: integer}
        failed: {type: integer}
