openapi: 3.1.0
info:
  title: BATbern Users API
  version: 1.0.0
  description: |
    Consolidated Users API for BATbern Event Management Platform.

    This API provides comprehensive user management capabilities with:
    - **Resource Expansion**: `?include=` parameter for company, roles, preferences, settings, activity
    - **Advanced Filtering**: MongoDB-style JSON filter syntax for complex queries
    - **Field Selection**: Sparse fieldsets to reduce payload size
    - **Pagination**: Standard page/limit parameters
    - **Caching**: Caffeine in-memory cache (10min TTL)
    - **Role Management**: Business rules enforcement (minimum 2 organizers)
    - **GDPR Compliance**: Cascade deletion and audit logging

    **Important (ADR-004)**: User entity is the single source of truth for email, name, bio, photo, company.
    Domain entities (Speaker, Attendee, Partner) reference User and never duplicate these fields.

    **Story**: 1.14-2 - User Management Service Foundation
    **Story 1.16.2**: Eliminate UUIDs from API - Use username as identifier

    **Performance Targets**:
    - Current user (GET /users/me): <100ms (P95)
    - User list (no includes): <100ms (P95)
    - User detail (basic): <150ms (P95)
    - User detail (all includes): <150ms (P95)
    - Cached search response: <50ms (P95)
    - Get-or-create user: <200ms (P95)

  contact:
    name: BATbern Platform Team
    email: platform@batbern.ch
  license:
    name: Proprietary
    identifier: Proprietary

servers:
  - url: https://api.batbern.ch/api/v1
    description: Production
  - url: https://api.batbern.ch/api/v1
    description: Staging
  - url: http://localhost:8080/api/v1
    description: Development

tags:
  - name: User Management
    description: User profile CRUD and management operations
  - name: User Search
    description: User search and autocomplete with caching
  - name: User Preferences
    description: User preference management
  - name: User Settings
    description: User account settings management
  - name: Role Management
    description: User role management with business rules
  - name: Profile Picture
    description: Profile picture upload and management
  - name: GDPR Compliance
    description: GDPR-compliant data export and deletion
  - name: Domain Integration
    description: Domain service integration endpoints
  - name: User Administration
    description: Admin Cognito↔DB reconciliation and sync-status (Story 1.2.5)
  - name: Public Organizers
    description: Anonymous public organizer directory (About page)
  - name: Public
    description: |
      Anonymous (unauthenticated) endpoints. Strictly narrow public projection —
      no email, no role list, no other PII. Mirrors the established
      /api/v1/public/organizers pattern (Story 11.C.1).

paths:
  /public/users/{username}:
    get:
      operationId: getPublicUserByUsername
      tags:
        - Public
      summary: Get public user by username (no authentication)
      description: |
        Anonymous lookup of a user's narrow public projection (Story 11.C.1, AC7).

        Replaces the deleted GET /api/v1/speakers/{username} endpoint that the
        public archive page used to call for speaker portraits. Mirrors the
        narrow projection of /api/v1/public/organizers (firstName, lastName,
        profilePictureUrl + username) — no email, no role list, no other PII.

        Used by web-frontend's useUserPortrait hook on the public archive page.
      security: []
      parameters:
        - name: username
          in: path
          required: true
          description: Public user identifier (ADR-003)
          schema:
            type: string
            example: john.doe
      responses:
        '200':
          description: Public user projection
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicUserResponse'
        '404':
          description: User not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'


  /users/{username}/watch-pairing:
    post:
      operationId: generateWatchPairingCode
      tags:
        - Watch Pairing
      summary: Generate pairing code (AC1)
      description: |
        Generate a 6-digit numeric pairing code for the organizer's Apple Watch.
        Code expires after 24 hours (NFR20). Max 2 watches per organizer (NFR19).
      security:
        - BearerAuth: []
      parameters:
        - name: username
          in: path
          required: true
          schema:
            type: string
      responses:
        '201':
          description: Pairing code generated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PairingCodeResponse'
        '409':
          description: Maximum 2 watches already paired (AC2)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized - missing or invalid JWT
        '403':
          description: Forbidden - not owner or not ORGANIZER role

    get:
      operationId: getWatchPairingStatus
      tags:
        - Watch Pairing
      summary: Get pairing status
      description: Returns list of paired watches and any pending pairing code.
      security:
        - BearerAuth: []
      parameters:
        - name: username
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Pairing status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PairingStatusResponse'
        '401':
          description: Unauthorized - missing or invalid JWT
        '403':
          description: Forbidden

  /users/{username}/watch-pairing/{watchId}:
    delete:
      operationId: unpairWatch
      tags:
        - Watch Pairing
      summary: Unpair a watch (AC4)
      description: Hard-delete the pairing record by UUID (GDPR compliant). Uses id instead of deviceName because deviceName is nullable (not set during pairing flow).
      security:
        - BearerAuth: []
      parameters:
        - name: username
          in: path
          required: true
          schema:
            type: string
        - name: watchId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Watch unpaired
        '404':
          description: Watch not found
        '401':
          description: Unauthorized - missing or invalid JWT
        '403':
          description: Forbidden

  /users/me:
    get:
      tags:
        - User Management
      summary: Get current authenticated user
      description: |
        Retrieve the profile of the currently authenticated user with optional resource expansion.

        **Acceptance Criteria**: AC1

        **Resource Expansion**:
        - company: Expands company details
        - preferences: Includes user preferences
        - settings: Includes account settings
        - roles: Includes full role information
        - activity: Includes recent activity history

        **Performance**: <100ms (P95)

      operationId: getCurrentUser
      parameters:
        - name: include
          in: query
          description: Comma-separated list of resources to include
          required: false
          schema:
            type: string
            example: 'company,preferences,settings'
      responses:
        '200':
          description: User profile retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
              examples:
                basicUser:
                  summary: Basic user profile
                  value:
                    id: john.doe
                    email: john.doe@example.com
                    firstName: John
                    lastName: Doe
                    roles: [SPEAKER, ATTENDEE]
                    active: true
                    createdAt: 2025-01-15T10:00:00Z
                    updatedAt: 2025-01-15T10:00:00Z
                expandedUser:
                  summary: User with all expansions
                  value:
                    id: john.doe
                    email: john.doe@example.com
                    firstName: John
                    lastName: Doe
                    roles: [SPEAKER, ATTENDEE]
                    companyId: Swiss IT Solutions AG
                    active: true
                    createdAt: 2025-01-15T10:00:00Z
                    updatedAt: 2025-01-15T10:00:00Z
                    company:
                      id: Swiss IT Solutions AG
                      name: Swiss IT Solutions AG
                      industry: Technology
                    preferences:
                      theme: dark
                      language: de
                      emailNotifications: true
                    settings:
                      profileVisibility: members_only
                      showEmail: false
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

    patch:
      tags:
        - User Management
      summary: Partially update current user profile
      description: |
        Partially update the profile of the currently authenticated user.

        **Acceptance Criteria**: AC2

        Similar to PUT but allows partial updates.

      operationId: patchCurrentUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateUserRequest'
      responses:
        '200':
          description: User updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users:
    get:
      tags:
        - User Management
      summary: List users with advanced query support
      description: |
        Retrieve a paginated list of users with optional filtering, sorting,
        field selection, and resource expansion.

        **Acceptance Criteria**: AC3, AC13, AC14

        **Authorization**: ADMIN or ORGANIZER role required

        **Filter Syntax Examples**:
        - Single filter: {"role":"SPEAKER"}
        - Multiple fields: {"role":"SPEAKER","isActive":true}
        - Company filter: {"companyId":"550e8400-e29b-41d4-a716-446655440000"}
        - Logical operators: {"$or":[{"role":"SPEAKER"},{"role":"PARTNER"}]}

        **Sort Syntax**:
        - Ascending: lastName or +lastName
        - Descending: -createdAt
        - Multiple fields: lastName,-createdAt

        **Field Selection**:
        - Specific fields: ?fields=id,email,firstName,lastName,roles
        - All fields: omit fields parameter

        **Resource Expansion**:
        - ?include=company: Expands company details
        - ?include=roles: Includes full role information
        - ?include=preferences: Includes user preferences

        **Performance**:
        - Basic query: <100ms (P95)
        - With all includes: <150ms (P95)

      operationId: listUsers
      parameters:
        - name: filter
          in: query
          description: MongoDB-style JSON filter criteria
          required: false
          schema:
            type: string
            example: '{"role":"SPEAKER","isActive":true}'
        - name: sort
          in: query
          description: Sort fields (comma-separated, prefix with - for descending)
          required: false
          schema:
            type: string
            example: '-createdAt,lastName'
        - name: page
          in: query
          description: Page number (1-indexed)
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: limit
          in: query
          description: Items per page (max 100)
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: fields
          in: query
          description: Comma-separated field names for sparse fieldsets
          required: false
          schema:
            type: string
            example: 'id,email,firstName,lastName,roles'
        - name: include
          in: query
          description: Comma-separated list of resources to include
          required: false
          schema:
            type: string
            example: 'company,roles,preferences'
        - name: search
          in: query
          description: Free-text search across name and email
          required: false
          schema:
            type: string
            example: Anna
      responses:
        '200':
          description: Successful response with paginated users
          headers:
            X-Cache-Status:
              description: Cache hit status (HIT or MISS)
              schema:
                type: string
                enum: [HIT, MISS]
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedUserResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

    post:
      tags:
        - User Management
      summary: Create new user (Organizer/Admin only)
      description: |
        Create a new user with Cognito integration and automatic username generation.

        **Acceptance Criteria**: AC4 (Story 2.5.2 - User Management Frontend)

        **Authorization**: ORGANIZER or ADMIN role required

        **Business Rules**:
        - Email must be unique
        - Username auto-generated from first/last name (e.g., john.doe)
        - User created in Cognito with email_verified=true
        - Default role: ATTENDEE (unless initialRoles specified)
        - Password set via Cognito welcome email

        **Events Published**: UserCreatedEvent to EventBridge

        **Performance**: <200ms (P95)

      operationId: createUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
            examples:
              basicUser:
                summary: Basic user creation
                value:
                  email: anna.mueller@example.com
                  firstName: Anna
                  lastName: Müller
                  companyId: Swiss IT Solutions AG
                  initialRoles:
                    - ATTENDEE
              organizerUser:
                summary: Create organizer
                value:
                  email: john.admin@batbern.ch
                  firstName: John
                  lastName: Admin
                  companyId: BATbern GmbH
                  initialRoles:
                    - ORGANIZER
                  bio: Senior event organizer
      responses:
        '201':
          description: User created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
              examples:
                createdUser:
                  summary: Newly created user
                  value:
                    id: anna.mueller
                    email: anna.mueller@example.com
                    firstName: Anna
                    lastName: Müller
                    username: anna.mueller
                    companyId: Swiss IT Solutions AG
                    roles:
                      - ATTENDEE
                    active: true
                    createdAt: '2025-10-21T20:00:00Z'
                    updatedAt: '2025-10-21T20:00:00Z'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                duplicateEmail:
                  summary: Email already exists
                  value:
                    timestamp: '2025-10-21T20:00:00Z'
                    status: 400
                    error: ERR_VALIDATION
                    message: User with email anna.mueller@example.com already exists
                    path: /api/v1/users
                invalidEmail:
                  summary: Invalid email format
                  value:
                    timestamp: '2025-10-21T20:00:00Z'
                    status: 400
                    error: ERR_VALIDATION
                    message: Invalid email format
                    path: /api/v1/users
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Forbidden - Requires ORGANIZER or ADMIN role
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /public/organizers:
    get:
      tags:
        - Public Organizers
      summary: List all organizers (public information only)
      description: |
        Anonymous endpoint returning organizers' public information for the About page.
        Exposes only publicly-shareable fields (name, bio, email, profile picture, company).
      operationId: getAllOrganizers
      responses:
        '200':
          description: List of organizers (public projection)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PublicOrganizerResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /users/by-company:
    get:
      tags:
        - User Management
      summary: List users by company (service-to-service)
      description: |
        Returns a paginated list of users in a company, optionally filtered by role.
        VPC-internal service-to-service endpoint — authorization is enforced at the filter chain.
      operationId: listUsersByCompany
      parameters:
        - name: company
          in: query
          description: Company name (ADR-003 meaningful ID)
          required: true
          schema:
            type: string
        - name: role
          in: query
          description: Optional role filter
          required: false
          schema:
            type: string
        - name: page
          in: query
          description: Page number (1-indexed)
          required: false
          schema:
            type: integer
            default: 1
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 100
      responses:
        '200':
          description: Paginated users for the company
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedUserResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/{username}/profile-picture/upload-from-url:
    post:
      tags:
        - Profile Picture
      summary: Admin — upload a user's profile picture from a URL
      description: |
        Fetches an image from a URL and uploads it directly to S3 (bypasses the browser to avoid
        binary corruption). Used for batch imports of speaker portraits.

        **Authorization**: ORGANIZER role required
      operationId: uploadProfilePictureFromUrl
      parameters:
        - name: username
          in: path
          description: Username (unique identifier)
          required: true
          schema:
            type: string
            pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
            example: john.doe
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AdminUploadProfilePictureFromUrlRequest'
      responses:
        '200':
          description: Profile picture uploaded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AdminUploadProfilePictureFromUrlResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          description: Fetched image exceeds the size limit
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/admin/reconcile:
    post:
      tags:
        - User Administration
      summary: Trigger manual Cognito→DB user reconciliation (Admin)
      description: |
        Manually syncs users from Cognito to the database (Story 1.2.5).

        **Authorization**: ORGANIZER role required
      operationId: reconcileUsers
      responses:
        '200':
          description: Reconciliation report
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserReconciliationReport'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/admin/sync-status:
    get:
      tags:
        - User Administration
      summary: Check Cognito↔DB sync status (Admin)
      description: |
        Compares Cognito and the database and reports drift (Story 1.2.5).

        **Authorization**: ORGANIZER role required
      operationId: getSyncStatus
      responses:
        '200':
          description: Sync status report
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserSyncStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/{username}:
    get:
      tags:
        - User Management
      summary: Get user details by username
      description: |
        Retrieve detailed information about a specific user.

        **Acceptance Criteria**: AC5

        **Story 1.16.2**: Uses username as identifier instead of UUID

        **Resource Expansion**: Supports ?include parameter

        **Performance**: <150ms (P95)

      operationId: getUserByUsername
      parameters:
        - name: username
          in: path
          description: Username (unique identifier)
          required: true
          schema:
            type: string
            pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
            example: john.doe
        - name: include
          in: query
          description: Comma-separated list of resources to include
          required: false
          schema:
            type: string
            example: 'company,roles'
      responses:
        '200':
          description: User details retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

    put:
      tags:
        - User Management
      summary: Update a user's profile by username (Organizer/Admin)
      description: |
        Full update of any user's profile by an organizer/admin.

        **Authorization**: ORGANIZER or ADMIN role required
      operationId: updateUserByUsername
      parameters:
        - name: username
          in: path
          description: Username (unique identifier)
          required: true
          schema:
            type: string
            pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
            example: john.doe
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateUserRequest'
      responses:
        '200':
          description: User updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

    delete:
      tags:
        - GDPR Compliance
      summary: Delete user and all associated data
      description: |
        Delete a user and cascade deletion across all domain services.

        **Acceptance Criteria**: AC11

        **Story 1.16.2**: Uses username as identifier instead of UUID

        **Authorization**: ADMIN role or self-deletion

        **Business Rules**:
        - Cannot delete last ORGANIZER user
        - Cascade deletion across Event, Speaker, Partner, Attendee services

        **Events Published**: UserDeletedEvent to EventBridge

        **Audit Logging**: GDPR compliance logging

      operationId: deleteUser
      parameters:
        - name: username
          in: path
          description: Username (unique identifier)
          required: true
          schema:
            type: string
            pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
            example: john.doe
      responses:
        '204':
          description: User deleted successfully
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/search:
    get:
      tags:
        - User Search
      summary: Search users with autocomplete
      description: |
        Search users by name or email with autocomplete functionality.

        **Acceptance Criteria**: AC4, AC11

        **Caching**:
        - Caffeine in-memory cache with 10-minute TTL
        - Cache key includes query, role filter, and limit
        - Automatic cache invalidation on user updates

        **Performance**:
        - Cached response: <50ms (P95)
        - Cache miss: <100ms (P95)

        **Default Results**: 20 users (configurable via limit parameter)

      operationId: searchUsers
      parameters:
        - name: query
          in: query
          description: Search query (minimum 1 character)
          required: true
          schema:
            type: string
            minLength: 1
            example: 'John'
        - name: role
          in: query
          description: Filter by specific role
          required: false
          schema:
            type: string
            enum: [ORGANIZER, SPEAKER, PARTNER, ATTENDEE]
        - name: limit
          in: query
          description: Maximum number of results (default 20)
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        '200':
          description: Search results returned successfully
          headers:
            X-Cache-Status:
              description: Cache hit status (HIT or MISS)
              schema:
                type: string
                enum: [HIT, MISS]
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/UserSearchResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/me/preferences:
    get:
      tags:
        - User Preferences
      summary: Get current user preferences
      description: |
        Retrieve preferences for the currently authenticated user.

        **Acceptance Criteria**: AC6

        Includes: theme, language, notification settings, quiet hours

      operationId: getUserPreferences
      responses:
        '200':
          description: Preferences retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserPreferences'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

    put:
      tags:
        - User Preferences
      summary: Update current user preferences
      description: |
        Update preferences for the currently authenticated user.

        **Acceptance Criteria**: AC6

        **Validation**: All preference values validated against allowed enums/formats

      operationId: updateUserPreferences
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserPreferences'
            examples:
              preferenceUpdate:
                summary: Update preferences
                value:
                  theme: dark
                  language: de
                  emailNotifications: true
                  notificationFrequency: daily_digest
                  quietHoursStart: '22:00'
                  quietHoursEnd: '07:00'
      responses:
        '200':
          description: Preferences updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserPreferences'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/me/settings:
    get:
      tags:
        - User Settings
      summary: Get current user settings
      description: |
        Retrieve account settings for the currently authenticated user.

        **Acceptance Criteria**: AC7

        Includes: privacy controls, account preferences, security settings

      operationId: getUserSettings
      responses:
        '200':
          description: Settings retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserSettings'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

    put:
      tags:
        - User Settings
      summary: Update current user settings
      description: |
        Update account settings for the currently authenticated user.

        **Acceptance Criteria**: AC7

        **Privacy Controls**: Profile visibility, contact information display
        **Security Settings**: Two-factor authentication enablement

      operationId: updateUserSettings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserSettings'
      responses:
        '200':
          description: Settings updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserSettings'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/me/additional-emails:
    post:
      tags:
        - User Account
      summary: Add an additional email address to the current user
      description: |
        Story 10.32 — register a new additional email on the caller's profile.
        Additional emails receive forwarded copies of mail addressed to this user
        (ok@/partner@/batbern{N}@ role fan-out, registration confirmations) and are
        accepted as authorised senders by the Story 10.26 SES forwarder Lambda.

        Cap: 5 per user (configurable via `batbern.user.additional-emails.max`).
        Uniqueness: global, case-insensitive, across BOTH primary emails on
        `user_profiles` AND additional emails on any user.
      operationId: addAdditionalEmail
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddAdditionalEmailRequest'
      responses:
        '201':
          description: Additional email created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AdditionalEmail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: |
            The email is already registered as a primary email on `user_profiles`
            or as an additional email on any user. Error code: `ADDITIONAL_EMAIL_DUPLICATE`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: |
            Per-user cap reached. Error code: `ADDITIONAL_EMAIL_LIMIT_REACHED`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/me/additional-emails/{email}:
    delete:
      tags:
        - User Account
      summary: Remove an additional email address from the current user
      description: |
        Story 10.32 — remove an additional email previously registered on the
        caller's profile. The primary email on `user_profiles` is NEVER affected
        by this endpoint.
      operationId: deleteAdditionalEmail
      parameters:
        - name: email
          in: path
          required: true
          description: |
            URL-encoded email address. Case-insensitive match against the caller's
            additional emails.
          schema:
            type: string
            format: email
          example: info@batbern.ch
      responses:
        '204':
          description: Additional email removed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No matching additional email on the caller's profile.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/me/additional-emails/{email}/resend-verification:
    post:
      tags:
        - User Account
      summary: Resend the verification email for an additional email address
      description: |
        Additional-email verification v2 — re-dispatch the signed verification
        link to one of the caller's own (still unverified) additional emails.
        Idempotent in effect: a 204 means a fresh email was queued. The token is
        regenerated on each call (stateless HMAC JWT, 48h TTL).
      operationId: resendAdditionalEmailVerification
      parameters:
        - name: email
          in: path
          required: true
          description: |
            URL-encoded email address. Case-insensitive match against the caller's
            additional emails.
          schema:
            type: string
            format: email
          example: info@batbern.ch
      responses:
        '204':
          description: A fresh verification email was dispatched.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No matching additional email on the caller's profile.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: |
            The email is already verified. Error code: `ALREADY_VERIFIED`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/additional-emails/verify:
    get:
      tags:
        - User Account
      summary: Check an additional-email verification token (no mutation)
      description: |
        Additional-email verification v2 — public, token-credentialed pre-flight
        check used by the `/verify-email` landing page. Validates the token's
        signature + expiry and returns the (masked) email and its current
        verification status WITHOUT mutating any state. Mail-scanner prefetches
        of the link land here (GET) and never confirm; the actual confirm is a
        separate POST. No JWT — the token IS the credential.
      operationId: checkAdditionalEmailVerification
      security: []
      parameters:
        - name: token
          in: query
          required: true
          description: The signed verification token from the email link.
          schema:
            type: string
      responses:
        '200':
          description: Token is valid; returns masked email + status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AdditionalEmailVerificationCheckResponse'
        '400':
          description: |
            Token is invalid (`TOKEN_INVALID`) or expired (`TOKEN_EXPIRED`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'

    post:
      tags:
        - User Account
      summary: Confirm an additional-email verification token (mutates)
      description: |
        Additional-email verification v2 — public, token-credentialed confirm.
        POST-only so mail-scanner prefetches (GET) cannot accidentally verify.
        On success sets `verified_at` and returns the masked email. Idempotent:
        a token for an already-verified row returns 200 with
        `alreadyVerified: true`. If the underlying row was deleted (or re-added
        with a new id) since the token was issued, returns 404. No JWT — the
        token IS the credential.
      operationId: confirmAdditionalEmailVerification
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConfirmAdditionalEmailVerificationRequest'
      responses:
        '200':
          description: Email verified (or already verified).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AdditionalEmailVerificationConfirmResponse'
        '400':
          description: |
            Token is invalid (`TOKEN_INVALID`) or expired (`TOKEN_EXPIRED`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: |
            The additional-email row referenced by the token no longer exists
            (deleted or re-added with a new id since issuance).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /users/{username}/roles:
    get:
      tags:
        - Role Management
      summary: Get user roles
      description: |
        Retrieve roles for a specific user.

        **Acceptance Criteria**: AC8

        **Story 1.16.2**: Uses username as identifier instead of UUID

        **Authorization**: ADMIN or ORGANIZER role required

      operationId: getUserRoles
      parameters:
        - name: username
          in: path
          description: Username (unique identifier)
          required: true
          schema:
            type: string
            pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
            example: john.doe
      responses:
        '200':
          description: Roles retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserRolesResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

    put:
      tags:
        - Role Management
      summary: Update user roles
      description: |
        Update roles for a specific user.

        **Acceptance Criteria**: AC8

        **Story 1.16.2**: Uses username as identifier instead of UUID

        **Authorization**: ADMIN or ORGANIZER role required

        **Business Rules**:
        - System must maintain at least 2 active ORGANIZER role users
        - Removing ORGANIZER role requires approval if only 2 remain

        **Events Published**: UserRoleChangedEvent to EventBridge

        **Cache Invalidation**: All user caches cleared on role update

      operationId: updateUserRoles
      parameters:
        - name: username
          in: path
          description: Username (unique identifier)
          required: true
          schema:
            type: string
            pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
            example: john.doe
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateUserRolesRequest'
      responses:
        '200':
          description: Roles updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserRolesResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/me/picture:
    delete:
      tags:
        - Profile Picture
      summary: Remove own profile picture
      description: |
        Remove the authenticated user's own profile picture.

        **Acceptance Criteria**: AC13

        Self-service counterpart to `DELETE /users/{username}/picture` (admin). Resolves the
        caller from the bearer token; idempotent in effect (clears the picture references).
      operationId: removeOwnProfilePicture
      responses:
        '204':
          description: Profile picture removed successfully
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/me/picture/presigned-url:
    post:
      tags:
        - Profile Picture
      summary: Request presigned URL for profile picture upload
      description: |
        Generate presigned S3 upload URL for current user's profile picture.

        **Acceptance Criteria**: AC10

        **File Constraints**:
        - Max size: 5 MB
        - Allowed formats: PNG, JPG, JPEG, SVG
        - Recommended dimensions: 400x400 to 1000x1000 pixels

        **Upload Process**:
        1. Client calls this endpoint to get presigned URL
        2. Client uploads directly to S3 using presigned URL
        3. Client calls /users/me/picture/confirm with file ID and extension

      operationId: requestProfilePictureUploadUrl
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProfilePictureUploadRequest'
      responses:
        '200':
          description: Presigned upload URL generated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PresignedUploadUrl'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/me/picture/confirm:
    post:
      tags:
        - Profile Picture
      summary: Confirm profile picture upload completion
      description: |
        Confirm that profile picture has been uploaded to S3 and associate with user.

        **Acceptance Criteria**: AC10

        Must be called after successful upload to presigned URL.

      operationId: confirmProfilePictureUpload
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProfilePictureUploadConfirmRequest'
      responses:
        '200':
          description: Upload confirmed successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProfilePictureUploadConfirmResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/{username}/picture/presigned-url:
    post:
      tags:
        - Profile Picture
      summary: Request presigned URL for user profile picture upload (Admin)
      description: |
        Admin endpoint to generate presigned S3 upload URL for a specific user's profile picture.

        **Authorization**: ORGANIZER role required

        **File Constraints**:
        - Max size: 5 MB
        - Allowed formats: PNG, JPG, JPEG, SVG

        **Upload Process**:
        1. Admin calls this endpoint to get presigned URL
        2. Admin uploads directly to S3 using presigned URL
        3. Admin calls /users/{username}/picture/confirm with file ID and extension

      operationId: requestProfilePictureUploadUrlForUser
      parameters:
        - name: username
          in: path
          description: Username (unique identifier)
          required: true
          schema:
            type: string
            pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
            example: john.doe
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProfilePictureUploadRequest'
      responses:
        '200':
          description: Presigned upload URL generated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PresignedUploadUrl'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/{username}/picture/confirm:
    post:
      tags:
        - Profile Picture
      summary: Confirm user profile picture upload completion (Admin)
      description: |
        Admin endpoint to confirm that profile picture has been uploaded to S3 for a specific user.

        **Authorization**: ORGANIZER role required

        Must be called after successful upload to presigned URL.

      operationId: confirmProfilePictureUploadForUser
      parameters:
        - name: username
          in: path
          description: Username (unique identifier)
          required: true
          schema:
            type: string
            pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
            example: john.doe
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProfilePictureUploadConfirmRequest'
      responses:
        '200':
          description: Upload confirmed successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProfilePictureUploadConfirmResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/{username}/picture:
    delete:
      tags:
        - Profile Picture
      summary: Remove user profile picture (Admin)
      description: |
        Admin endpoint to remove profile picture for a specific user.

        **Authorization**: ORGANIZER role required

        Clears profile picture URL and S3 key from user entity.

      operationId: removeProfilePictureForUser
      parameters:
        - name: username
          in: path
          description: Username (unique identifier)
          required: true
          schema:
            type: string
            pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
            example: john.doe
      responses:
        '204':
          description: Profile picture removed successfully
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/provision:
    post:
      tags:
        - Domain Integration
      summary: Provision a user with a role (Story 11.C.2 — AR13)
      description: |
        Idempotent endpoint for the canonical "create User + grant role" flow used by the
        Speaker Workflow Service (`SpeakerWorkflowService.transition()` at CONTACTED → READY).

        **ADR-009**: Decision 3 — Cognito provisioning. Cognito wiring is split across two
        endpoints per Story 11.E.2's Resolved Q#1 Variant B:

        - **READY (this endpoint)**: `AdminCreateUser` (silent shell, `MessageAction=SUPPRESS`)
          creates the Cognito user in `FORCE_CHANGE_PASSWORD` state. The temporary password
          passed to Cognito is internal-only and is **NOT** returned in the response.
        - **INVITED (sibling endpoint `/users/{username}/issue-invitation-credentials`)**:
          `AdminGetUser` + conditional `AdminSetUserPassword(Permanent=false)` issues the
          fresh temporary password embedded in the invitation email.

        SPEAKER role is granted via a row insert into PostgreSQL `user_roles` per ADR-001 —
        NOT via `AdminAddUserToGroup` (no Cognito groups exist; see Story 11.E.1 Resolved Q#1).

        **Behaviour**:
        - If User exists by email (case-insensitive lookup, matches `getOrCreateUser`):
          grants the requested role if not already held; returns existing `username` and `created=false`.
        - If User does not exist: creates User row, grants role, returns generated `username` and
          `created=true`. The username is auto-generated from email per the existing
          `getOrCreateUser` policy.
        - **Idempotent**: re-calling for an already-provisioned user is a no-op and returns
          the same `username` with `created=false`. The `role_assignments` UNIQUE constraint
          backs the idempotency guarantee at the DB layer.

        **Authorization**: ORGANIZER or ADMIN (service-to-service call from event-management-service
        propagates the ORGANIZER JWT per `microservices-http-clients.md`).

        **Performance**: <200ms (P95)

      operationId: provisionUserWithRole
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProvisionUserRequest'
            examples:
              speakerProvisioning:
                summary: Provision a speaker at CONTACTED → READY
                value:
                  email: jane.smith@example.com
                  firstName: Jane
                  lastName: Smith
                  role: SPEAKER
      responses:
        '200':
          description: User provisioned (created or already existed)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProvisionUserResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '502':
          description: |
            Cognito upstream error. Story 11.E.2 wired `AdminCreateUser` (silent) at READY,
            so a transient Cognito 5xx, throttle, or network failure during the call is
            mapped to `CognitoOperationException` → 502 here. Caller should retry; the
            provisioning is idempotent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      security:
        - BearerAuth: []

  /users/{username}/issue-invitation-credentials:
    post:
      tags:
        - Domain Integration
      summary: Issue (or skip) Cognito temp credentials at invitation time (Story 11.E.2 — AR15/FR9)
      description: |
        Service-to-service endpoint called by `SpeakerWorkflowService.runInvitedHook` at
        READY → INVITED. Delegates to Cognito's `AdminGetUser` + conditional
        `AdminSetUserPassword(Permanent=false)` to mint a fresh temporary password (when
        the user is `FORCE_CHANGE_PASSWORD` / `RESET_REQUIRED` / `UNCONFIRMED`) or to
        confirm that the existing password remains valid (when the user is `CONFIRMED`).

        **Per Resolved Q#1 Variant B + Q#4** of Story 11.E.2: credential issuance is
        deliberately decoupled from `/users/provision` (which only runs the silent
        `AdminCreateUser` shell). The two-endpoint design avoids storing the temp password
        anywhere between READY and INVITED.

        **Idempotent**: repeated calls are safe. `FRESH_TEMP_PASSWORD` branch overwrites the
        previous temp password (only the most recently issued one is valid). `USE_EXISTING_PASSWORD`
        branch is a pure read.

        **Authorization**: ORGANIZER or ADMIN (service-to-service call from
        event-management-service propagates the ORGANIZER JWT).

        **Performance**: <300ms (P95)

      operationId: issueInvitationCredentials
      parameters:
        - name: username
          in: path
          required: true
          description: Target user's username (Story 1.16.2)
          schema:
            type: string
            pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
            example: jane.smith
      responses:
        '200':
          description: Credentials issued or confirmed existing
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvitationCredentialsResponse'
              examples:
                freshTempPassword:
                  summary: User is FORCE_CHANGE_PASSWORD — fresh temp password issued
                  value:
                    temporaryPassword: "Tk7!aB2@nx9pQ4#z"
                    action: FRESH_TEMP_PASSWORD
                useExistingPassword:
                  summary: User is CONFIRMED — keep using current password
                  value:
                    temporaryPassword: null
                    action: USE_EXISTING_PASSWORD
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Cognito user is in a state that requires operator intervention (ARCHIVED,
            COMPROMISED, UNKNOWN). EMS should NOT silently retry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '502':
          description: |
            Upstream Cognito Admin SDK call failed. EMS should treat this as a transient
            upstream error (retry after a brief backoff) and abort the INVITED transition.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/{username}/profile:
    patch:
      tags:
        - User Management
      summary: Patch user profile fields (bio, profilePictureUrl) (Story 11.C.2 — AR14)
      description: |
        Narrow profile-patch endpoint scoped to `bio` and `profilePictureUrl`. Used by the
        consolidated `ContentSubmissionService` when an organizer (on behalf) or a speaker
        (self) submits content that includes a CV blurb or a portrait.

        **ADR-009 Decision 2 + ADR-007**: `bio` and `profilePictureUrl` live on the User
        entity (single source of truth). This endpoint overwrites the global values — there
        is no per-event snapshot.

        **Authorization**: ORGANIZER, ADMIN, or SPEAKER. If the principal has role SPEAKER but
        **not** ORGANIZER/ADMIN, the service enforces `currentUsername == pathVariable.username`
        and returns 403 otherwise. Prevents a SPEAKER from patching another speaker's profile.

        **Validation**: At least one of `bio` or `profilePictureUrl` must be non-null. Null
        fields are left unchanged.

      operationId: patchUserProfile
      parameters:
        - name: username
          in: path
          required: true
          description: Target user's username (Story 1.16.2)
          schema:
            type: string
            pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
            example: jane.smith
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchUserProfileRequest'
            examples:
              fullPatch:
                summary: Update bio and profile picture
                value:
                  bio: "Experienced security architect with 15 years in the industry."
                  profilePictureUrl: "https://cdn.batbern.ch/users/jane.smith.jpg"
              bioOnly:
                summary: Update bio only
                value:
                  bio: "Updated bio for the next event."
      responses:
        '200':
          description: Profile patched successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  /users/get-or-create:
    post:
      tags:
        - Domain Integration
      summary: Get or create user (for domain services, supports anonymous users)
      description: |
        Idempotent endpoint for domain services to get existing user or create new user.

        **ADR-005**: Anonymous Event Registration - creates users with cognito_id=NULL
        **Story 4.1.5a**: Architecture consolidation
        **Acceptance Criteria**: AC12

        **Use Cases**:
        - Event Service: Create anonymous user during public event registration (ADR-005)
        - Speaker Service: Create user account when speaker registers
        - Partner Service: Create user account for partner contact
        - Attendee Service: Create user account during event registration

        **Anonymous Users** (ADR-005):
        - When cognitoSync=false, creates user_profile with cognito_id=NULL
        - User can later create Cognito account which auto-links on first login
        - Email serves as unique identifier for anonymous users

        **Idempotency**: Safe to call multiple times with same email

        **Cognito Sync**: Optionally creates Cognito user if createIfMissing=true

        **Performance**: <200ms (P95)

      operationId: getOrCreateUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GetOrCreateUserRequest'
            examples:
              speakerRegistration:
                summary: Speaker registration
                value:
                  email: jane.smith@example.com
                  firstName: Jane
                  lastName: Smith
                  createIfMissing: true
                  cognitoSync: true
      responses:
        '200':
          description: User retrieved or created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GetOrCreateUserResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - BearerAuth: []

  # W2.2: Watch Authentication Endpoints (unauthenticated — code/token IS the credential)
  /watch/pair:
    post:
      operationId: pairWatch
      tags:
        - Watch Authentication
      summary: Exchange pairing code for pairing token (W2.2 AC2/AC3)
      description: |
        Exchanges a 6-digit pairing code (generated via POST /users/{username}/watch-pairing)
        for a long-lived pairing token. The code is single-use and cleared after exchange.

        **Unauthenticated** — the pairing code is the credential.

        Returns 400 if code is invalid, not found, or expired.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WatchPairingRequest'
            example:
              pairingCode: "482715"
      responses:
        '200':
          description: Pairing successful — returns pairing token and organizer details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WatchPairingResponse'
              example:
                pairingToken: "a1b2c3d4-1234567890-98765"
                organizerUsername: "marco.organizer"
                organizerFirstName: "Marco"
        '400':
          description: Code invalid, expired, or wrong format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security: []

  /watch/authenticate:
    post:
      operationId: authenticateWatch
      tags:
        - Watch Authentication
      summary: Exchange pairing token for short-lived JWT (W2.2 AC4/NFR16)
      description: |
        Exchanges a pairing token for a short-lived JWT (1-hour lifespan per NFR16).
        Called on app launch and auto-refreshed 10 minutes before expiry.

        **Unauthenticated** — the pairing token is the credential.

        Returns 401 if pairing token is invalid or pairing is incomplete.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WatchAuthRequest'
            example:
              pairingToken: "a1b2c3d4-1234567890-98765"
      responses:
        '200':
          description: Authentication successful — returns JWT and expiry
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WatchAuthResponse'
              example:
                jwt: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
                expiresAt: "2026-02-17T16:30:00"
        '401':
          description: Pairing token invalid or pairing incomplete
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security: []

components:
  schemas:
    AdminUploadProfilePictureFromUrlRequest:
      type: object
      required:
        - url
      properties:
        url:
          type: string
          description: Source image URL to fetch and upload
        filename:
          type: string
          description: Suggested base filename (extension is derived from the fetched content type)
          default: profile
    AdminUploadProfilePictureFromUrlResponse:
      type: object
      properties:
        profilePictureUrl:
          type: string
          description: CDN URL of the uploaded profile picture
    UserReconciliationReport:
      type: object
      description: Result of a manual Cognito→DB user reconciliation (Story 1.2.5)
      properties:
        orphanedUsersDeactivated:
          type: integer
          description: Users present in the DB but absent in Cognito, deactivated by this run
        missingUsersCreated:
          type: integer
          description: Users present in Cognito but absent in the DB, created by this run
        durationMs:
          type: integer
          format: int64
          description: Reconciliation duration in milliseconds
        errors:
          type: array
          items:
            type: string
        success:
          type: boolean
        message:
          type: string
    PublicOrganizerResponse:
      type: object
      description: Public organizer projection for the About page (no sensitive fields)
      properties:
        id:
          type: string
          description: Username (public identifier)
        firstName:
          type: string
        lastName:
          type: string
        email:
          type: string
        bio:
          type: string
        profilePictureUrl:
          type: string
        companyId:
          type: string
    UserSyncStatus:
      type: object
      description: Cognito↔DB sync status snapshot (Story 1.2.5)
      properties:
        cognitoUserCount:
          type: integer
        databaseUserCount:
          type: integer
        missingInDatabase:
          type: integer
        orphanedInDatabase:
          type: integer
        missingCognitoIds:
          type: array
          items:
            type: string
        inSync:
          type: boolean
        message:
          type: string
    # Story 11.C.1: Public user projection (anonymous lookup)
    PublicUserResponse:
      type: object
      description: |
        Narrow public projection of a user — exactly the fields the public
        archive page needs for portrait display. No email, no role list,
        no other PII. Mirrors PublicOrganizerResponse's projection.
      required:
        - username
      properties:
        username:
          type: string
          description: Public identifier (ADR-003)
          example: john.doe
        firstName:
          type: string
          nullable: true
          example: John
        lastName:
          type: string
          nullable: true
          example: Doe
        profilePictureUrl:
          type: string
          nullable: true
          format: uri
          description: CloudFront URL of the user's portrait, when set.
          example: https://cdn.batbern.ch/users/john.doe/profile.jpg

    # W2.2: Watch Authentication Schemas
    WatchPairingRequest:
      type: object
      required:
        - pairingCode
      properties:
        pairingCode:
          type: string
          pattern: '^\d{6}$'
          description: 6-digit numeric pairing code generated by the web frontend
          example: "482715"

    WatchPairingResponse:
      type: object
      required:
        - pairingToken
        - organizerUsername
        - organizerFirstName
      properties:
        pairingToken:
          type: string
          description: Long-lived token stored in Watch Keychain (NFR15)
        organizerUsername:
          type: string
          description: Organizer's username
        organizerFirstName:
          type: string
          description: Organizer's first name for display

    WatchAuthRequest:
      type: object
      required:
        - pairingToken
      properties:
        pairingToken:
          type: string
          description: Pairing token obtained from /watch/pair

    WatchAuthResponse:
      type: object
      required:
        - jwt
        - expiresAt
      properties:
        jwt:
          type: string
          description: Short-lived JWT for authenticated Watch API calls (1 hour, NFR16)
        expiresAt:
          type: string
          format: date-time
          description: JWT expiry timestamp (ISO 8601)

    UserResponse:
      type: object
      description: |
        User response with meaningful IDs.
        Story 1.16.2: id field contains username, not UUID.
      required:
        - id
        - email
        - firstName
        - lastName
        - roles
        - isActive
        - createdAt
        - updatedAt
      properties:
        id:
          type: string
          description: Username (unique identifier) - Story 1.16.2
          pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
          example: john.doe
        hasCognitoAccount:
          type: boolean
          description: True if this user has a linked Cognito account (UUID not exposed per ADR-003)
          example: true
        email:
          type: string
          format: email
          example: john.doe@example.com
        firstName:
          type: string
          example: John
        lastName:
          type: string
          example: Doe
        bio:
          type: string
          example: Software engineer passionate about cloud architecture
        companyId:
          type: string
          description: Company name (unique identifier) - Story 1.16.2
          example: Swiss IT Solutions AG
        roles:
          type: array
          items:
            type: string
            enum: [ORGANIZER, SPEAKER, PARTNER, ATTENDEE]
        profilePictureUrl:
          type: string
          format: uri
          example: https://cdn.batbern.ch/profile-pictures/550e8400.jpg
        active:
          type: boolean
          example: true
        createdAt:
          type: string
          format: date-time
          example: 2025-01-15T10:00:00Z
        updatedAt:
          type: string
          format: date-time
          example: 2025-01-15T10:00:00Z
        lastLoginAt:
          type: string
          format: date-time
          example: 2025-01-20T14:30:00Z
        termsAcceptedAt:
          type: string
          format: date-time
          nullable: true
          description: |
            Story 12.11: moment the user accepted the Terms of Service + Privacy Policy.
            null = consent not on record — the frontend onboarding gate blocks the user on
            /profile?onboarding=1 until accepted. Returned unconditionally (no ?include=
            needed) because the gate check must always be present. Set server-side only
            (write-once via UpdateUserRequest.termsAccepted).
          example: 2026-06-04T16:00:00Z
        company:
          $ref: '#/components/schemas/Company'
        preferences:
          $ref: '#/components/schemas/UserPreferences'
        settings:
          $ref: '#/components/schemas/UserSettings'
        additionalEmails:
          type: array
          description: |
            Story 10.32: additional email addresses registered on this profile.
            Receive forwarded copies of mail addressed to the user (ok@/partner@/batbern{N}@
            role fan-out, registration confirmations) and are accepted as authorised
            senders by the Story 10.26 forwarder Lambda. Always present; may be empty.
          items:
            $ref: '#/components/schemas/AdditionalEmail'

    AdditionalEmail:
      type: object
      description: Story 10.32 — one additional email address registered on a user profile.
      required:
        - email
        - createdAt
      properties:
        email:
          type: string
          format: email
          example: info@batbern.ch
        label:
          type: string
          nullable: true
          description: Optional free-text user hint (e.g. "Hostpoint shared mailbox").
          example: Hostpoint shared
        createdAt:
          type: string
          format: date-time
          example: 2026-05-22T10:00:00Z
        verifiedAt:
          type: string
          format: date-time
          nullable: true
          description: |
            Reserved for the v2 verification flow (Story 10.32 Resolved Decision #1).
            v1 always returns null.

    AddAdditionalEmailRequest:
      type: object
      required:
        - email
      properties:
        email:
          type: string
          format: email
          # RFC 5321 §4.5.3.1.3 — practical maximum for a valid mailbox path
          # is 254 characters. Without this cap, a 256+ char value passes
          # bean validation and crashes at INSERT into VARCHAR(255) → 500.
          # Review 2026-05-22 finding P1-3.
          maxLength: 254
          example: info@batbern.ch
        label:
          type: string
          nullable: true
          maxLength: 100
          example: Hostpoint shared

    AdditionalEmailVerificationCheckResponse:
      type: object
      description: |
        Additional-email verification v2 — GET-check response. Returns the masked
        email and current verification status without mutating state.
      required:
        - email
        - verified
      properties:
        email:
          type: string
          description: |
            Masked form of the additional email (e.g. `in***@example.com`). The
            full address is never returned on the public verify path.
          example: in***@batbern.ch
        verified:
          type: boolean
          description: True if the row's verified_at is already set.
          example: false

    ConfirmAdditionalEmailVerificationRequest:
      type: object
      required:
        - token
      properties:
        token:
          type: string
          description: The signed verification token from the email link.

    AdditionalEmailVerificationConfirmResponse:
      type: object
      description: |
        Additional-email verification v2 — POST-confirm response.
      required:
        - email
        - verified
        - alreadyVerified
      properties:
        email:
          type: string
          description: Masked form of the additional email.
          example: in***@batbern.ch
        verified:
          type: boolean
          description: Always true after a successful confirm.
          example: true
        alreadyVerified:
          type: boolean
          description: |
            True when the row was already verified before this call (idempotent
            re-confirm); false when this call set verified_at.
          example: false

    UserPreferences:
      type: object
      properties:
        theme:
          type: string
          enum: [light, dark, auto]
          default: auto
        language:
          type: string
          enum: [de, en, fr, it, rm, es, fi, nl, ja]
          default: de
        emailNotifications:
          type: boolean
          default: true
        inAppNotifications:
          type: boolean
          default: true
        pushNotifications:
          type: boolean
          default: false
        notificationFrequency:
          type: string
          enum: [immediate, daily_digest, weekly_digest]
          default: immediate
        qnaNotificationFrequency:
          type: string
          description: |
            How often this user is emailed about new questions in the Q&A of sessions
            where they are speaker / co-speaker / moderator (Story 15.7).
            `live` = bundled digest during the session (~15 min window),
            `daily` = at most once per 24h, `off` = never. Defaults to `live`.
          enum: ["live", "daily", "off"]
          default: "live"
        quietHoursStart:
          type: string
          pattern: '^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'
          example: '22:00'
        quietHoursEnd:
          type: string
          pattern: '^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'
          example: '07:00'

    UserSettings:
      type: object
      properties:
        profileVisibility:
          type: string
          enum: [public, members_only, private]
          default: members_only
        showEmail:
          type: boolean
          default: false
        showCompany:
          type: boolean
          default: true
        showActivityHistory:
          type: boolean
          default: false
        allowMessaging:
          type: boolean
          default: true
        allowCalendarSync:
          type: boolean
          default: false
        timezone:
          type: string
          example: Europe/Zurich
        twoFactorEnabled:
          type: boolean
          default: false

    UpdateUserRequest:
      type: object
      properties:
        firstName:
          type: string
          minLength: 2
          maxLength: 100
        lastName:
          type: string
          minLength: 2
          maxLength: 100
        email:
          type: string
          format: email
        bio:
          type: string
          maxLength: 5000
        companyId:
          type: string
          description: Company name (unique identifier, alphanumeric only)
          pattern: '^[a-zA-Z0-9]{1,12}$'
          maxLength: 12
          example: PostFinance
        termsAccepted:
          type: boolean
          description: |
            Story 12.11: accept the Terms of Service + Privacy Policy. WRITE-ONCE
            semantics — when true and no consent is on record, the server stamps
            terms_accepted_at with ITS OWN clock (the client never supplies the
            timestamp). When consent is already recorded, true is a no-op (the
            original timestamp is preserved). false or absent never changes or
            revokes recorded consent.
          example: true

    UserSearchResponse:
      type: object
      description: |
        User search result with meaningful IDs.
        Story 1.16.2: id field contains username, not UUID.
      required:
        - id
        - email
        - firstName
        - lastName
      properties:
        id:
          type: string
          description: Username (unique identifier) - Story 1.16.2
          pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
          example: john.doe
        email:
          type: string
        firstName:
          type: string
        lastName:
          type: string
        companyId:
          type: string
          description: Company name (unique identifier) - Story 1.16.2
          example: Swiss IT Solutions AG
        roles:
          type: array
          items:
            type: string
        profilePictureUrl:
          type: string
          format: uri

    CreateUserRequest:
      type: object
      description: Request body for creating a new user (Story 2.5.2 AC4)
      required:
        - email
        - firstName
        - lastName
      properties:
        email:
          type: string
          format: email
          description: User email address (must be unique)
          example: anna.mueller@example.com
        firstName:
          type: string
          minLength: 1
          maxLength: 100
          description: User first name
          example: Anna
        lastName:
          type: string
          minLength: 1
          maxLength: 100
          description: User last name
          example: Müller
        companyId:
          type: string
          description: Company name (unique identifier) - Story 1.16.2
          example: Swiss IT Solutions AG
        initialRoles:
          type: array
          description: Initial roles for the user (defaults to [ATTENDEE] if not specified)
          items:
            type: string
            enum: [ORGANIZER, SPEAKER, PARTNER, ATTENDEE]
          example: [ATTENDEE]
        bio:
          type: string
          maxLength: 5000
          description: User biography/description
          example: Software engineer passionate about cloud architecture

    ProvisionUserRequest:
      type: object
      description: |
        Request to provision a User with a role (Story 11.C.2 — AR13).
        Used by SpeakerWorkflowService.transition() at CONTACTED → READY.
      additionalProperties: false
      required:
        - email
        - role
      properties:
        email:
          type: string
          format: email
          description: Required. Lookup key for the existing-user path (case-insensitive).
          example: jane.smith@example.com
        firstName:
          type: string
          minLength: 1
          maxLength: 100
          description: |
            Optional. If absent, defaults are derived from the email local-part
            (e.g., `jane.smith@x.com` → firstName=`Jane`, lastName=`Smith`).
          example: Jane
        lastName:
          type: string
          minLength: 1
          maxLength: 100
          description: |
            Optional. If absent, defaults are derived from the email local-part.
            See `firstName`.
          example: Smith
        role:
          type: string
          description: |
            Role to grant. **Non-ADMIN callers may only grant SPEAKER via this endpoint.**
            ADMIN callers may grant any role. Server returns 400 if a non-ADMIN caller
            requests a non-SPEAKER role.
          enum: [ORGANIZER, SPEAKER, PARTNER, ATTENDEE]
          example: SPEAKER

    ProvisionUserResponse:
      type: object
      description: |
        Response from the user-provisioning endpoint. Story 11.E.2 (Resolved Q#1 Variant B)
        removed the `temporaryPassword` field — credential issuance is owned by the sibling
        `/users/{username}/issue-invitation-credentials` endpoint, which is called at
        READY → INVITED rather than at CONTACTED → READY.
      required:
        - username
        - created
      properties:
        username:
          type: string
          description: |
            Canonical username (existing or newly generated). No regex `pattern` constraint
            on the response — the server may emit legacy usernames that predate the current
            `firstname.lastname[.NN]` convention (e.g., hyphenated or apostrophed names).
            Pattern is enforced on `ProvisionUserRequest.username` only.
          example: jane.smith
        created:
          type: boolean
          description: True if a new User row was created; false if the user already existed.
          example: true

    InvitationCredentialsResponse:
      type: object
      description: |
        Response from `/users/{username}/issue-invitation-credentials` (Story 11.E.2 — AR15/FR9).
        Determines whether the invitation email renders the "temporary password" block or the
        "use your existing password" block.
      required:
        - action
      properties:
        temporaryPassword:
          type: string
          nullable: true
          description: |
            Fresh temporary password for the speaker's first login. Non-null when
            `action = "FRESH_TEMP_PASSWORD"`; null when `action = "USE_EXISTING_PASSWORD"`.
            Never persisted at rest in CUMS or EMS; the caller embeds it once in the
            invitation email and discards it from memory.
          example: "Tk7!aB2@nx9pQ4#z"
        action:
          type: string
          enum: ["FRESH_TEMP_PASSWORD", "USE_EXISTING_PASSWORD"]
          description: |
            Discriminator for the calling email service. Determines whether the
            invitation-email template renders the temp-password block or the
            "use your existing password" block.
          example: FRESH_TEMP_PASSWORD

    PatchUserProfileRequest:
      type: object
      description: |
        Patch the narrow profile fields owned by the consolidated ContentSubmissionService
        (Story 11.C.2 — AR14). At least one of `bio` or `profilePictureUrl` must be present.
        Null fields are left unchanged.
      additionalProperties: false
      properties:
        bio:
          type: string
          maxLength: 5000
          description: Updated user biography. Overwrites the global value per ADR-009 §6.7.
          example: "Experienced security architect with 15 years in the industry."
        profilePictureUrl:
          type: string
          maxLength: 2048
          description: |
            Updated profile picture URL (typically a CloudFront URL produced by an earlier
            presigned-upload flow). Modelled as a plain string (not `format: uri`) so the
            Bean Validation {@code @Size(max=2048)} constraint generated by the Spring
            OpenAPI generator applies cleanly.
          example: "https://cdn.batbern.ch/users/jane.smith.jpg"

    GetOrCreateUserRequest:
      type: object
      description: Request to get an existing user or create a new one. Supports both authenticated (Cognito) and anonymous users (ADR-005).
      required:
        - email
        - firstName
        - lastName
      properties:
        email:
          type: string
          format: email
          description: User email address. Used as unique identifier for anonymous users and for account linking.
          example: john.doe@example.com
        firstName:
          type: string
          description: User first name
          example: John
        lastName:
          type: string
          description: User last name
          example: Doe
        companyId:
          type: string
          description: Company name (unique identifier) - Story 1.16.2. Optional for anonymous event registration.
          example: Swiss IT Solutions AG
        createIfMissing:
          type: boolean
          description: If true, creates a new user if not found. If false, returns error if user doesn't exist.
          default: true
        cognitoSync:
          type: boolean
          description: |
            ADR-005: Controls whether to sync with AWS Cognito.
            - true (default): Creates/syncs with Cognito account (authenticated user)
            - false: Creates anonymous user with cognito_id=NULL (for event registration without account)
            When false, user profile is created without Cognito account and can be linked later via post-authentication trigger.
          default: true
          example: false

    GetOrCreateUserResponse:
      type: object
      required:
        - username
        - created
        - user
      properties:
        username:
          type: string
          description: Username (unique identifier) - Story 1.16.2
          pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
          example: john.doe
        created:
          type: boolean
          description: True if user was created, false if existing
        companyUpdated:
          type: boolean
          description: >-
            True if the caller supplied a companyId that differed from the
            existing user's stored company and the profile was updated as a
            result. Always false for newly-created users. Lets a registration
            flow tell the attendee their company on file was refreshed.
        cognitoUserId:
          type: string
          description: Cognito user ID (only if created)
        user:
          $ref: '#/components/schemas/UserResponse'

    Company:
      type: object
      description: |
        Company details with meaningful IDs.
        Story 1.16.2: id field contains company name, not UUID.
      properties:
        id:
          type: string
          description: Company name (unique identifier) - Story 1.16.2
          example: Swiss IT Solutions AG
        name:
          type: string
          description: Company name (same as id)
          example: Swiss IT Solutions AG
        displayName:
          type: string
        industry:
          type: string
        website:
          type: string
          format: uri

    PaginatedUserResponse:
      type: object
      required:
        - data
        - pagination
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/UserResponse'
        pagination:
          $ref: '#/components/schemas/PaginationMetadata'

    UserRolesResponse:
      type: object
      description: User roles response
      required:
        - username
        - roles
      properties:
        username:
          type: string
          description: Username (unique identifier) - Story 1.16.2
          pattern: '^[a-z]+\.[a-z]+(\.[0-9]+)?$'
          example: john.doe
        roles:
          type: array
          items:
            type: string
            enum: [ORGANIZER, SPEAKER, PARTNER, ATTENDEE]

    UpdateUserRolesRequest:
      type: object
      description: Request to update user roles
      required:
        - roles
      properties:
        roles:
          type: array
          items:
            type: string
            enum: [ORGANIZER, SPEAKER, PARTNER, ATTENDEE]
          minItems: 1

    ProfilePictureUploadRequest:
      type: object
      description: Request to generate presigned URL for profile picture upload
      required:
        - fileName
        - fileSize
      properties:
        fileName:
          type: string
          description: Original filename with extension
          maxLength: 255
          example: profile.jpg
        fileSize:
          type: integer
          description: |
            File size in bytes. The 5 MB ceiling is enforced server-side by
            ProfilePictureService (friendly `exceeds 5MB limit` 400) rather than a
            declarative `maximum`, preserving the pre-wiring contract + message.
          minimum: 1
          example: 1048576
        mimeType:
          type: string
          description: |
            Client-declared MIME type of the upload. Accepted for documentation-truth
            (the frontend sends `file.type`), but the server authoritatively derives the
            content type from the file extension — this value is not trusted or required.
          example: image/png

    PresignedUploadUrl:
      type: object
      description: Presigned URL response for S3 upload
      required:
        - uploadUrl
        - fileId
        - s3Key
        - fileExtension
        - expiresInMinutes
      properties:
        uploadUrl:
          type: string
          format: uri
          description: Presigned S3 upload URL
          example: https://s3.eu-central-1.amazonaws.com/bucket/...
        fileId:
          type: string
          description: Unique file identifier for confirmation
          example: f3e8d1a4-5b2c-4d6e-8a9b-1c2d3e4f5a6b
        s3Key:
          type: string
          description: S3 object key where file will be stored
          example: profile-pictures/2025/john.doe/profile-f3e8d1a4.jpg
        fileExtension:
          type: string
          description: File extension
          example: jpg
        expiresInMinutes:
          type: integer
          description: URL expiration time in minutes
          example: 15
        requiredHeaders:
          type: object
          additionalProperties:
            type: string
          description: Headers required for the upload request
          example:
            Content-Type: image/jpeg

    ProfilePictureUploadConfirmRequest:
      type: object
      description: Request to confirm profile picture upload completion
      required:
        - fileId
        - fileExtension
      properties:
        fileId:
          type: string
          description: File identifier from presigned URL response
          example: f3e8d1a4-5b2c-4d6e-8a9b-1c2d3e4f5a6b
        fileExtension:
          type: string
          description: File extension (png, jpg, jpeg, svg)
          example: jpg
        checksum:
          type: string
          description: Optional SHA256 checksum of uploaded file
          example: sha256=abc123...

    ProfilePictureUploadConfirmResponse:
      type: object
      description: Response after confirming profile picture upload
      required:
        - profilePictureUrl
      properties:
        profilePictureUrl:
          type: string
          format: uri
          description: CloudFront URL for the uploaded profile picture
          example: https://cdn.batbern.ch/profile-pictures/2025/john.doe/profile-f3e8d1a4.jpg

    PaginationMetadata:
      $ref: './_shared.openapi.yml#/components/schemas/PaginationMetadata'
    ErrorResponse:
      $ref: './_shared.openapi.yml#/components/schemas/ErrorResponse'
    PairingCodeResponse:
      type: object
      properties:
        pairingCode:
          type: string
          pattern: '^\d{6}$'
          example: '482910'
        expiresAt:
          type: string
          format: date-time
        hoursUntilExpiry:
          type: integer
          example: 24

    PairingStatusResponse:
      type: object
      properties:
        pairedWatches:
          type: array
          items:
            $ref: '#/components/schemas/PairedWatch'
        pendingCode:
          oneOf:
            - $ref: '#/components/schemas/PendingPairingCode'
            - type: 'null'

    PairedWatch:
      type: object
      required:
        - id
        - pairedAt
      properties:
        id:
          type: string
          format: uuid
          description: Stable UUID identifier used for unpair. Never null.
        deviceName:
          type: string
          nullable: true
          description: Device name, nullable (not set during pairing flow).
        pairedAt:
          type: string
          format: date-time

    PendingPairingCode:
      type: object
      properties:
        code:
          type: string
          pattern: '^\d{6}$'
        expiresAt:
          type: string
          format: date-time

  responses:
    BadRequest:
      description: Bad request - validation error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            validationError:
              value:
                error: VALIDATION_ERROR
                errorCode: BAD_REQUEST
                message: Invalid request data
                timestamp: 2025-01-15T10:30:00Z

    Unauthorized:
      description: Unauthorized - missing or invalid JWT token
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            missingToken:
              value:
                error: UNAUTHORIZED
                errorCode: UNAUTHORIZED
                message: Missing or invalid authentication token
                timestamp: 2025-01-15T10:30:00Z

    Forbidden:
      description: Forbidden - insufficient permissions
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            insufficientPermissions:
              value:
                error: FORBIDDEN
                errorCode: FORBIDDEN
                message: Insufficient permissions. Requires ADMIN or ORGANIZER role.
                timestamp: 2025-01-15T10:30:00Z

    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            userNotFound:
              value:
                error: RESOURCE_NOT_FOUND
                errorCode: NOT_FOUND
                message: User not found with username 'john.doe'
                timestamp: 2025-01-15T10:30:00Z

    Conflict:
      description: Conflict - resource already exists or business rule violation
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            duplicateEmail:
              value:
                error: CONFLICT
                errorCode: CONFLICT
                message: User with email 'john.doe@example.com' already exists
                timestamp: 2025-01-15T10:30:00Z
            minimumOrganizers:
              value:
                error: BUSINESS_RULE_VIOLATION
                errorCode: CONFLICT
                message: Cannot remove ORGANIZER role - minimum 2 organizers required
                timestamp: 2025-01-15T10:30:00Z

    InternalServerError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            serverError:
              value:
                error: INTERNAL_SERVER_ERROR
                errorCode: INTERNAL_ERROR
                message: An unexpected error occurred
                timestamp: 2025-01-15T10:30:00Z

  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: AWS Cognito JWT token

security:
  - BearerAuth: []
