openapi: 3.1.0
info:
  # Do not change the title, if the title changes, the import paths will be broken
  title: Api
  version: 0.1.0
  description: Tx OS API specification
servers:
  - url: /api
    description: Base API path
tags:
  - name: health
    description: Health operations
  - name: auth
    description: Authentication
  - name: apps
    description: App management
  - name: services
    description: Internal services (khidmati)
  - name: orders
    description: Service orders workflow
  - name: notifications
    description: Notifications
  - name: users
    description: User management
  - name: roles
    description: Role assignment
  - name: stats
    description: Dashboard stats
  - name: storage
    description: Object storage upload and serving endpoints
  - name: audit
    description: Admin audit log
  - name: system
    description: System metadata (version, update availability)

paths:
  /healthz:
    get:
      operationId: healthCheck
      tags: [health]
      summary: Health check
      responses:
        "200":
          description: Healthy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthStatus"

  /system/version:
    get:
      operationId: getSystemVersion
      tags: [system]
      summary: Get current and latest system version
      description: |
        Returns the local build version (read from version.json embedded at
        build time) and, if `LATEST_VERSION_URL` is configured on the
        server, the latest published version fetched from that URL. Admin
        only. Never performs an update — pure read.
      responses:
        "200":
          description: System version info
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SystemVersionResponse"

  # Auth
  /auth/register:
    post:
      operationId: register
      tags: [auth]
      summary: Register a new user
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RegisterBody"
      responses:
        "201":
          description: Registered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthUser"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /auth/login:
    post:
      operationId: login
      tags: [auth]
      summary: Login
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LoginBody"
      responses:
        "200":
          description: Logged in
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthUser"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /auth/logout:
    post:
      operationId: logout
      tags: [auth]
      summary: Logout
      responses:
        "200":
          description: Logged out
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"

  /auth/forgot-password:
    post:
      operationId: forgotPassword
      tags: [auth]
      summary: Request a password reset link
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ForgotPasswordBody"
      responses:
        "200":
          description: Reset request accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ForgotPasswordResponse"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /auth/reset-password:
    post:
      operationId: resetPassword
      tags: [auth]
      summary: Set a new password using a reset token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ResetPasswordBody"
      responses:
        "200":
          description: Password updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
        "400":
          description: Invalid or expired token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /auth/reset-password/verify:
    post:
      operationId: verifyResetToken
      tags: [auth]
      summary: Check whether a reset token is valid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VerifyResetTokenBody"
      responses:
        "200":
          description: Token check result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerifyResetTokenResponse"

  /auth/admin/users/{id}/issue-reset-link:
    post:
      operationId: adminIssueResetLink
      tags: [auth]
      summary: Admin generates a one-time password reset link for a user
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Reset link issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminResetLinkResponse"
        "403":
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: User not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /auth/me:
    get:
      operationId: getMe
      tags: [auth]
      summary: Get current user
      responses:
        "200":
          description: Current user
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthUser"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /auth/me/language:
    patch:
      operationId: updateLanguage
      tags: [auth]
      summary: Update preferred language
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateLanguageBody"
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthUser"

  /auth/me/clock-style:
    patch:
      operationId: updateClockStyle
      tags: [auth]
      summary: Update preferred home-screen clock style
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateClockStyleBody"
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthUser"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /auth/me/notification-preferences:
    patch:
      operationId: updateNotificationPreferences
      tags: [auth]
      summary: Update notification sound, vibration & mute preferences
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateNotificationPreferencesBody"
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthUser"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /auth/me/clock-hour12:
    patch:
      operationId: updateClockHour12
      tags: [auth]
      summary: Update preferred 12/24-hour clock format
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateClockHour12Body"
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthUser"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # Apps
  /apps:
    get:
      operationId: listApps
      tags: [apps]
      summary: List all active apps
      responses:
        "200":
          description: Apps list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/App"

    post:
      operationId: createApp
      tags: [apps]
      summary: Create a new app (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateAppBody"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/App"

  /apps/{id}:
    get:
      operationId: getApp
      tags: [apps]
      summary: Get app by ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: App
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/App"

    patch:
      operationId: updateApp
      tags: [apps]
      summary: Update app (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateAppBody"
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/App"

    delete:
      operationId: deleteApp
      tags: [apps]
      summary: Delete app (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
        - name: force
          in: query
          required: false
          description: Force deletion of an app that has dependent records (records an audit log entry).
          schema:
            type: boolean
            default: false
      responses:
        "204":
          description: Deleted
        "409":
          description: App has dependent records; pass force=true to confirm deletion.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AppDeletionConflict"

  /apps/{id}/permissions:
    get:
      operationId: listAppPermissions
      tags: [apps]
      summary: List the permissions that gate this app (admin)
      description: |
        Returns the permissions currently required for users to see this app
        in their launcher. An app with no rows here is unrestricted (visible
        to anyone). When more than one permission is configured, holding any
        one of them is sufficient.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Permissions gating the app
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Permission"
        "404":
          description: App not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    post:
      operationId: addAppPermission
      tags: [apps]
      summary: Add a required permission to this app (admin)
      description: |
        Adds a row to `app_permissions` for the (app_id, permission_id) pair.
        Uses `ON CONFLICT DO NOTHING` against the composite primary key so
        re-adding an existing pair is a no-op.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AddAppPermissionBody"
      responses:
        "201":
          description: Permission requirement added (or already present)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AppPermissionLink"
        "404":
          description: App or permission not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /apps/{id}/permissions/impact-preview:
    post:
      operationId: previewAppPermissionsImpact
      tags: [apps]
      summary: Preview the impact of changing the permissions required for an app (admin)
      description: |
        Given a candidate set of permission IDs that would gate this app,
        returns how many users would lose visibility of the app (excluding
        admins, who always see every app), the count of users who currently
        see the app, and the groups that grant the app via `group_apps`. A
        member of any returned group always sees the app regardless of
        permission changes, so listing them lets the admin see which
        populations are protected from the loss.

        Mirrors `POST /roles/{id}/permissions/impact-preview` so the admin
        UI can show the same kind of warning before committing the change.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AppPermissionsImpactBody"
      responses:
        "200":
          description: Impact preview for the proposed permission set
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AppPermissionsImpact"
        "400":
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: App not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /apps/{id}/permissions/{permissionId}:
    delete:
      operationId: removeAppPermission
      tags: [apps]
      summary: Remove a required permission from this app (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
        - name: permissionId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "204":
          description: Removed (or no row existed for the pair)
        "404":
          description: App not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /apps/{id}/open:
    post:
      operationId: logAppOpen
      tags: [apps]
      summary: Log an app open event for the current user
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "204":
          description: Logged
        "404":
          description: App not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /me/app-order:
    put:
      operationId: updateMyAppOrder
      tags: [apps]
      summary: Set the current user's preferred home apps order
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateMyAppOrderBody"
      responses:
        "200":
          description: Updated apps in new order
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/App"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # Settings
  /settings:
    get:
      operationId: getAppSettings
      tags: [settings]
      summary: Get application settings (public)
      responses:
        "200":
          description: Current settings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AppSettings"
    patch:
      operationId: updateAppSettings
      tags: [settings]
      summary: Update application settings (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateAppSettingsBody"
      responses:
        "200":
          description: Updated settings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AppSettings"
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # Storage
  /storage/uploads/request-url:
    post:
      operationId: requestUploadUrl
      tags: [storage]
      summary: Request a presigned URL for file upload
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RequestUploadUrlBody"
      responses:
        "200":
          description: Presigned upload URL generated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RequestUploadUrlResponse"
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # Services
  /services:
    get:
      operationId: listServices
      tags: [services]
      summary: List all services
      responses:
        "200":
          description: Services list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Service"

    post:
      operationId: createService
      tags: [services]
      summary: Create a service (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateServiceBody"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Service"

  /services/reorder:
    patch:
      operationId: reorderServices
      tags: [services]
      summary: Replace the global service sort order (admin)
      description: |
        Persists a new global `sortOrder` for the service catalogue by
        applying the index of each id in `orderedIds`. Admin-only.
        The body must contain every existing service id exactly once;
        partial reorders are rejected with 400 so the client can never
        accidentally drop services from the sort plan.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReorderServicesBody"
      responses:
        "204":
          description: Sort order updated
        "400":
          description: Validation error or mismatched id set
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /services/{id}:
    get:
      operationId: getService
      tags: [services]
      summary: Get service by ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Service
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Service"

    patch:
      operationId: updateService
      tags: [services]
      summary: Update service (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateServiceBody"
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Service"

    delete:
      operationId: deleteService
      tags: [services]
      summary: Delete service (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
        - name: force
          in: query
          required: false
          description: Force deletion of a service that has existing orders (records an audit log entry).
          schema:
            type: boolean
            default: false
      responses:
        "204":
          description: Deleted
        "409":
          description: Service has dependent records; pass force=true to confirm deletion.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceDeletionConflict"

  /orders:
    post:
      operationId: createServiceOrder
      tags: [orders]
      summary: Place a service order
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateServiceOrderBody"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceOrder"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /orders/my:
    get:
      operationId: listMyServiceOrders
      tags: [orders]
      summary: List orders placed by the current user
      responses:
        "200":
          description: My orders
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ServiceOrder"

  /orders/incoming:
    get:
      operationId: listIncomingServiceOrders
      tags: [orders]
      summary: List active orders for receivers (requires orders.receive permission). Returns pending+unassigned orders plus orders assigned to me that are not completed/cancelled.
      responses:
        "200":
          description: Incoming orders
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ServiceOrder"
        "403":
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /orders/{id}/status:
    patch:
      operationId: updateServiceOrderStatus
      tags: [orders]
      summary: Update order status. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time); pending/received are restore-from-cancelled transitions allowed for owner or admin.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateServiceOrderStatusBody"
      responses:
        "200":
          description: Updated order
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceOrder"
        "400":
          description: Invalid transition or validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Forbidden — caller is not the assignee, owner, or admin per the transition rules
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Order not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /orders/{id}/confirm-receipt:
    patch:
      operationId: confirmServiceOrderReceipt
      tags: [orders]
      summary: Receiver atomically claims a pending order (sets status=received, assigned_to=current user). Requires orders.receive permission. Returns 409 already_claimed if the order is no longer pending+unassigned.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Order received
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceOrder"
        "409":
          description: Invalid transition
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /service-categories:
    get:
      operationId: listServiceCategories
      tags: [services]
      summary: List service categories
      responses:
        "200":
          description: Categories
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ServiceCategory"

  # Notifications
  /notifications:
    get:
      operationId: listNotifications
      tags: [notifications]
      summary: List notifications for current user
      responses:
        "200":
          description: Notifications
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Notification"

  /notifications/{id}/read:
    patch:
      operationId: markNotificationRead
      tags: [notifications]
      summary: Mark notification as read
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Marked as read
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Notification"

  /notifications/read-all:
    post:
      operationId: markAllNotificationsRead
      tags: [notifications]
      summary: Mark all notifications as read
      responses:
        "200":
          description: Done
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"

  /push/vapid-public-key:
    get:
      operationId: getPushVapidPublicKey
      tags: [notifications]
      summary: Get the server's VAPID public key for Web Push subscriptions
      responses:
        "200":
          description: VAPID public key
          content:
            application/json:
              schema:
                type: object
                properties:
                  publicKey:
                    type: string
                required: [publicKey]
        "503":
          description: Web Push not configured on this server
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /push/subscribe:
    post:
      operationId: subscribePush
      tags: [notifications]
      summary: Register a Web Push subscription for the current user
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                endpoint:
                  type: string
                keys:
                  type: object
                  properties:
                    p256dh:
                      type: string
                    auth:
                      type: string
                  required: [p256dh, auth]
              required: [endpoint, keys]
      responses:
        "200":
          description: Subscribed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
    delete:
      operationId: deletePushSubscription
      tags: [notifications]
      summary: Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe)
      parameters:
        - in: query
          name: endpoint
          required: true
          schema:
            type: string
          description: The push endpoint URL to unsubscribe
      responses:
        "200":
          description: Unsubscribed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"

  /push/unsubscribe:
    post:
      operationId: unsubscribePush
      tags: [notifications]
      summary: Remove a Web Push subscription for the current user
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                endpoint:
                  type: string
              required: [endpoint]
      responses:
        "200":
          description: Unsubscribed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"

  # Users (admin)
  /users:
    get:
      operationId: listUsers
      tags: [users]
      summary: List all users (admin)
      parameters:
        - in: query
          name: q
          required: false
          schema:
            type: string
          description: Free-text search across username, email, and display names
        - in: query
          name: groupId
          required: false
          schema:
            type: integer
          description: Filter to users that belong to the given group
        - in: query
          name: status
          required: false
          schema:
            type: string
            enum: ["active", "disabled"]
          description: Filter by active/disabled state
      responses:
        "200":
          description: Users
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/UserProfile"

    post:
      operationId: createUser
      tags: [users]
      summary: Create a user (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateUserBody"
      responses:
        "201":
          description: Created user
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserProfile"
        "409":
          description: Username already taken

  /users/{id}:
    get:
      operationId: getUser
      tags: [users]
      summary: Get user by ID (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: User
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserProfile"

    patch:
      operationId: updateUser
      tags: [users]
      summary: Update user (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateUserBody"
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserProfile"

    delete:
      operationId: deleteUser
      tags: [users]
      summary: Delete user (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
        - name: force
          in: query
          required: false
          description: Force deletion of a user that owns dependent records (records an audit log entry).
          schema:
            type: boolean
            default: false
      responses:
        "204":
          description: Deleted
        "409":
          description: User has dependent records; pass force=true to confirm deletion.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserDeletionConflict"

  /orders/bulk-delete:
    post:
      operationId: bulkDeleteServiceOrders
      tags: [orders]
      summary: Delete multiple service orders in one request
      description: |
        Deletes several orders in one round-trip. Authorization is still
        enforced per id using the same rules as DELETE /orders/{id}, so the
        client cannot bypass the permission check by batching. Returns a
        summary of which ids were deleted and which were rejected.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BulkDeleteServiceOrdersBody"
      responses:
        "200":
          description: Bulk delete summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BulkDeleteServiceOrdersResponse"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /orders/{id}:
    delete:
      operationId: deleteServiceOrder
      tags: [orders]
      summary: Delete a service order
      description: |
        Permanently deletes the order. Allowed when:
          - the caller is an admin (any order, any status); or
          - the caller owns the order AND it is in a terminal status
            (completed/cancelled); or
          - the caller holds the `orders.receive` permission AND the order
            is currently visible in their own incoming view — i.e. an
            unclaimed pending order, or one they have themselves claimed
            and is still active (received/preparing). Receivers cannot
            delete another receiver's claimed order, nor terminal orders.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "204":
          description: Deleted
        "403":
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Order not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /users/{id}/roles:
    post:
      operationId: addUserRole
      tags: [roles]
      summary: Idempotently add a role to a user (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AddUserRoleBody"
      responses:
        "200":
          description: Role added (or already present)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserProfile"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: User or role not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /users/{id}/roles/{roleName}:
    delete:
      operationId: removeUserRole
      tags: [roles]
      summary: Idempotently remove a role from a user (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
        - name: roleName
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Role removed (or already absent)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserProfile"
        "404":
          description: User not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # Permissions (admin)
  /permissions:
    get:
      operationId: listPermissions
      tags: [roles]
      summary: List all available permissions (admin)
      responses:
        "200":
          description: Permissions
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Permission"
        "403":
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # Roles (admin)
  /roles:
    get:
      operationId: listRoles
      tags: [users]
      summary: List roles (admin)
      responses:
        "200":
          description: Roles
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Role"
    post:
      operationId: createRole
      tags: [users]
      summary: Create role (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateRoleBody"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Role"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Name already taken
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /roles/{id}:
    patch:
      operationId: updateRole
      tags: [users]
      summary: Update role name and descriptions (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateRoleBody"
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Role"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Name already taken
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      operationId: deleteRole
      tags: [users]
      summary: Delete role (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "204":
          description: Deleted
        "400":
          description: Cannot delete a system role
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Role is in use
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RoleDeletionConflict"

  /roles/{id}/usage:
    get:
      operationId: getRoleUsage
      tags: [roles]
      summary: Count users and groups currently assigned to a role (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Counts of users and groups using the role
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RoleUsage"
        "404":
          description: Role not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /roles/{id}/audit:
    get:
      operationId: getRolePermissionAudit
      tags: [roles]
      summary: List recent permission-change audit entries for a role (admin)
      description: |
        Returns permission-change audit records for the given role, newest
        first. Supports offset-based pagination via `limit`/`offset` and
        optional filters by acting user (`actorUserId`) and a date range
        (`from`/`to`, inclusive UTC days). The response includes the total
        matching count and the next offset to load (or `null` when the end
        has been reached). Each entry captures the actor (if known), the
        previous permission IDs, the new permission IDs, and the timestamp.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 10
        - in: query
          name: offset
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
        - in: query
          name: actorUserId
          required: false
          schema:
            type: integer
            minimum: 0
          description: |
            Restrict results to entries authored by the given user. Use
            `0` or omit to return entries from any actor.
        - in: query
          name: from
          required: false
          schema:
            type: string
            format: date
          description: Start date (inclusive, YYYY-MM-DD UTC).
        - in: query
          name: to
          required: false
          schema:
            type: string
            format: date
          description: End date (inclusive, YYYY-MM-DD UTC).
      responses:
        "200":
          description: A page of permission-change audit entries for the role
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RolePermissionAuditList"
        "400":
          description: Invalid filter parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Role not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /roles/{id}/audit.csv:
    get:
      operationId: exportRolePermissionAuditCsv
      tags: [roles]
      summary: Download permission-change audit entries for a role as CSV (admin)
      description: |
        Returns the same audit rows as `GET /roles/{id}/audit` but as a
        downloadable CSV file with permission IDs resolved to names.
        Honors the optional `actorUserId`, `from`, and `to` filters
        identically. Pagination parameters are NOT accepted: the file
        contains all matching rows up to a server-side cap (currently
        10,000 — narrow the date range for a longer history).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
        - in: query
          name: actorUserId
          required: false
          schema:
            type: integer
            minimum: 0
          description: |
            Restrict results to entries authored by the given user. Use
            `0` or omit to return entries from any actor.
        - in: query
          name: from
          required: false
          schema:
            type: string
            format: date
          description: Start date (inclusive, YYYY-MM-DD UTC).
        - in: query
          name: to
          required: false
          schema:
            type: string
            format: date
          description: End date (inclusive, YYYY-MM-DD UTC).
      responses:
        "200":
          description: |
            UTF-8 CSV (with BOM) containing columns: timestamp,
            actor_username, added_permissions, removed_permissions.
          content:
            text/csv:
              schema:
                type: string
                format: binary
        "400":
          description: Invalid filter parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Role not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /roles/{id}/permissions/impact-preview:
    post:
      operationId: previewRolePermissionsImpact
      tags: [roles]
      summary: Preview the impact of changing a role's permission set (admin)
      description: |
        Given a candidate set of permission IDs for the role, returns the list of
        permissions that would be removed and, for each one, how many users would
        lose the permission (because they hold this role directly or via a group
        and have no other role granting the permission), plus the groups that
        currently grant this role.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RolePermissionsImpactBody"
      responses:
        "200":
          description: Impact preview for the proposed permission set
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RolePermissionsImpact"
        "400":
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Role was not found. Unknown permission IDs in the request body are tolerated and simply do not appear in `removed`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /roles/{id}/permissions:
    get:
      operationId: getRolePermissions
      tags: [roles]
      summary: List permissions assigned to a role (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Permissions assigned to the role
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Permission"
        "404":
          description: Role not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    put:
      operationId: replaceRolePermissions
      tags: [roles]
      summary: Atomically replace the permissions assigned to a role (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReplaceRolePermissionsBody"
      responses:
        "200":
          description: Updated permissions list for the role
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Permission"
        "400":
          description: Cannot modify a system role's permissions, or invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Role or one of the permissions was not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # Groups (admin)
  /groups:
    get:
      operationId: listGroups
      tags: [users]
      summary: List groups (admin)
      parameters:
        - in: query
          name: q
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Groups
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Group"
    post:
      operationId: createGroup
      tags: [users]
      summary: Create group (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateGroupBody"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Group"

  /groups/{id}:
    get:
      operationId: getGroup
      tags: [users]
      summary: Get group with members and apps (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Group detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GroupDetail"
        "404":
          description: Not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

    patch:
      operationId: updateGroup
      tags: [users]
      summary: Update group (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateGroupBody"
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GroupDetail"

    delete:
      operationId: deleteGroup
      tags: [users]
      summary: Delete group (admin)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
        - name: force
          in: query
          required: false
          description: Force deletion of a non-empty group (records an audit log entry).
          schema:
            type: boolean
            default: false
      responses:
        "204":
          description: Deleted
        "400":
          description: Cannot delete a system group
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Group is not empty; pass force=true to confirm deletion.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GroupDeletionConflict"

  /users/{id}/audit:
    get:
      operationId: getUserPermissionAudit
      tags: [users]
      summary: List recent permission-change audit entries for a user (admin)
      description: |
        Returns permission-change audit records for the given user, newest
        first. Mirrors the role audit endpoint shape and supports the same
        `limit`/`offset`/`actorUserId`/`from`/`to` filters. Each entry
        captures the actor (if known), the previous and new id sets, and
        the change kind (`user.roles` or `user.groups`).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 10
        - in: query
          name: offset
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
        - in: query
          name: actorUserId
          required: false
          schema:
            type: integer
            minimum: 0
        - in: query
          name: from
          required: false
          schema:
            type: string
            format: date
        - in: query
          name: to
          required: false
          schema:
            type: string
            format: date
      responses:
        "200":
          description: A page of permission-change audit entries for the user
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PermissionAuditList"
        "400":
          description: Invalid filter parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: User not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /groups/{id}/audit:
    get:
      operationId: getGroupPermissionAudit
      tags: [users]
      summary: List recent permission-change audit entries for a group (admin)
      description: |
        Returns permission-change audit records for the given group, newest
        first. Captures changes to the group's user, role and app sets via
        the discriminator field `changeKind`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 10
        - in: query
          name: offset
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
        - in: query
          name: actorUserId
          required: false
          schema:
            type: integer
            minimum: 0
        - in: query
          name: from
          required: false
          schema:
            type: string
            format: date
        - in: query
          name: to
          required: false
          schema:
            type: string
            format: date
      responses:
        "200":
          description: A page of permission-change audit entries for the group
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PermissionAuditList"
        "400":
          description: Invalid filter parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Group not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /apps/{id}/audit:
    get:
      operationId: getAppPermissionAudit
      tags: [apps]
      summary: List recent permission-change audit entries for an app (admin)
      description: |
        Returns permission-change audit records for the given app, newest
        first. Captures changes to the set of permissions required to open
        the app (`changeKind: app.permissions`).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 10
        - in: query
          name: offset
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
        - in: query
          name: actorUserId
          required: false
          schema:
            type: integer
            minimum: 0
        - in: query
          name: from
          required: false
          schema:
            type: string
            format: date
        - in: query
          name: to
          required: false
          schema:
            type: string
            format: date
      responses:
        "200":
          description: A page of permission-change audit entries for the app
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PermissionAuditList"
        "400":
          description: Invalid filter parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: App not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # Stats
  /stats/home:
    get:
      operationId: getHomeStats
      tags: [stats]
      summary: Get home screen stats
      responses:
        "200":
          description: Stats
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HomeStats"

  /stats/admin:
    get:
      operationId: getAdminStats
      tags: [stats]
      summary: Get admin dashboard trend stats
      parameters:
        - in: query
          name: range
          required: false
          schema:
            type: string
            enum: ["7d", "30d", "90d", "custom"]
            default: "7d"
          description: Time range for trend stats. Use "custom" with from/to.
        - in: query
          name: from
          required: false
          schema:
            type: string
            format: date
          description: Start date (inclusive, YYYY-MM-DD UTC) when range=custom
        - in: query
          name: to
          required: false
          schema:
            type: string
            format: date
          description: End date (inclusive, YYYY-MM-DD UTC) when range=custom
      responses:
        "200":
          description: Admin stats
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminStats"
        "400":
          description: Invalid range parameters (e.g. range=custom with missing/malformed from/to or inverted dates)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /stats/admin/app-opens/by-app/{appId}:
    get:
      operationId: getAdminAppOpensByApp
      tags: [stats]
      summary: Recent opens of a single app within the selected range
      parameters:
        - in: path
          name: appId
          required: true
          schema:
            type: integer
        - in: query
          name: range
          required: false
          schema:
            type: string
            enum: ["7d", "30d", "90d", "custom"]
            default: "7d"
        - in: query
          name: from
          required: false
          schema:
            type: string
            format: date
        - in: query
          name: to
          required: false
          schema:
            type: string
            format: date
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 100
        - in: query
          name: offset
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        "200":
          description: Recent opens for the app
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminAppOpensByApp"
        "400":
          description: Invalid range parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: App not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /stats/admin/app-opens/by-user/{userId}:
    get:
      operationId: getAdminAppOpensByUser
      tags: [stats]
      summary: Recent app opens by a single user within the selected range
      parameters:
        - in: path
          name: userId
          required: true
          schema:
            type: integer
        - in: query
          name: range
          required: false
          schema:
            type: string
            enum: ["7d", "30d", "90d", "custom"]
            default: "7d"
        - in: query
          name: from
          required: false
          schema:
            type: string
            format: date
        - in: query
          name: to
          required: false
          schema:
            type: string
            format: date
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 100
        - in: query
          name: offset
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        "200":
          description: Recent app opens by the user
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminAppOpensByUser"
        "400":
          description: Invalid range parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: User not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /admin/audit-logs:
    get:
      operationId: listAuditLogs
      tags: [audit]
      summary: List audit log entries (admin)
      description: |
        Returns recent entries from the audit log, newest first. Admin only.
        Filter by action (exact match) and/or a date range (inclusive).
      parameters:
        - in: query
          name: action
          required: false
          schema:
            type: string
          description: Exact action name to filter by (e.g. "group.force_delete").
        - in: query
          name: forcedOnly
          required: false
          schema:
            type: boolean
            default: false
          description: |
            When true, restrict results to forced deletions only — i.e. rows
            with action `group.force_delete`, `user.force_delete`,
            `app.force_delete`, `service.force_delete`, or any
            `group.delete`/`user.delete`/`app.delete` row whose metadata
            contains `force: true`. Overrides the `action` filter when set.
        - in: query
          name: from
          required: false
          schema:
            type: string
            format: date
          description: Start date (inclusive, YYYY-MM-DD UTC).
        - in: query
          name: to
          required: false
          schema:
            type: string
            format: date
          description: End date (inclusive, YYYY-MM-DD UTC).
        - in: query
          name: targetType
          required: false
          schema:
            type: string
          description: |
            Restrict results to entries whose `target_type` exactly matches
            (e.g. "group", "app", "role", "user", "service", "settings").
            Combines with `targetId` and the other filters.
        - in: query
          name: targetId
          required: false
          schema:
            type: integer
            minimum: 1
          description: |
            Restrict results to entries whose `target_id` exactly matches.
            Typically used together with `targetType` to focus on the history
            of a single entity.
        - in: query
          name: actorUserId
          required: false
          schema:
            type: integer
            minimum: 1
          description: |
            Restrict results to entries written by a specific acting user
            (matches `actor_user_id`). Combines with the other filters.
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - in: query
          name: offset
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        "200":
          description: Page of audit log entries
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditLogList"
        "400":
          description: Invalid filter parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /admin/audit-logs/export:
    get:
      operationId: exportAuditLogsCsv
      tags: [audit]
      summary: Export filtered audit log as CSV (admin)
      description: |
        Streams the filtered audit log as a CSV download.
        Honors the same `action`, `forcedOnly`, `from`, `to`, `targetType`,
        `targetId`, and `actorUserId` filters as the list endpoint, so an
        export always matches whatever the admin is currently viewing.
        Columns: action, actor_username, target_type, target_id, created_at, metadata.
        Capped at 50,000 rows per export to bound response size.
      parameters:
        - in: query
          name: action
          required: false
          schema:
            type: string
          description: Exact action name to filter by (e.g. "group.force_delete").
        - in: query
          name: forcedOnly
          required: false
          schema:
            type: boolean
            default: false
          description: |
            When true, restrict the export to forced deletions only.
            See the list endpoint for the exact set of matching rows.
            Overrides the `action` filter when set.
        - in: query
          name: from
          required: false
          schema:
            type: string
            format: date
          description: Start date (inclusive, YYYY-MM-DD UTC).
        - in: query
          name: to
          required: false
          schema:
            type: string
            format: date
          description: End date (inclusive, YYYY-MM-DD UTC).
        - in: query
          name: targetType
          required: false
          schema:
            type: string
          description: |
            Restrict the export to entries whose `target_type` exactly matches.
            Combines with `targetId` and the other filters.
        - in: query
          name: targetId
          required: false
          schema:
            type: integer
            minimum: 1
          description: |
            Restrict the export to entries whose `target_id` exactly matches.
        - in: query
          name: actorUserId
          required: false
          schema:
            type: integer
            minimum: 1
          description: |
            Restrict the export to entries written by a specific acting user.
      responses:
        "200":
          description: CSV file download
          content:
            text/csv:
              schema:
                type: string
                format: binary
        "400":
          description: Invalid filter parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /admin/apps/{id}/dependents/groups:
    get:
      operationId: getAdminAppDependentGroups
      tags: [apps]
      summary: List groups granting access to an app
      description: |
        Returns the groups behind the inline "<n> groups" badge on the
        admin Apps row, paginated. Admin only.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: offset
          required: false
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Paged list of groups granting the app
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AppDependentGroupsPage"
        "404":
          description: App not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /admin/apps/{id}/dependents/restrictions:
    get:
      operationId: getAdminAppDependentRestrictions
      tags: [apps]
      summary: List permission restrictions on an app
      description: |
        Returns the legacy permissions gating an app, with the role names
        currently holding each permission, paginated. Admin only.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: offset
          required: false
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Paged list of restrictions on the app
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AppDependentRestrictionsPage"
        "404":
          description: App not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /admin/apps/{id}/dependents/opens:
    get:
      operationId: getAdminAppDependentOpens
      tags: [apps]
      summary: List recent opens for an app (no time filter)
      description: |
        Returns every recorded open for the app newest-first, paginated.
        Unlike /stats/admin/app-opens/by-app/{appId}, this endpoint does
        not apply a date range so the badge total and the list match.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: offset
          required: false
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Paged list of opens
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AppDependentOpensPage"
        "404":
          description: App not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /admin/services/{id}/dependents/orders:
    get:
      operationId: getAdminServiceDependentOrders
      tags: [services]
      summary: List orders placed against a service
      description: |
        Returns the service orders behind the inline "<n> orders" badge
        on the admin Services row, paginated, newest-first. Admin only.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: offset
          required: false
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Paged list of orders for the service
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceDependentOrdersPage"
        "404":
          description: Service not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ----- Notes (in-app messaging) -----
  /notes/my:
    get:
      operationId: listMyNotesAlias
      tags: [notes]
      summary: Alias of GET /notes — list the caller's own notes
      parameters:
        - in: query
          name: archived
          required: false
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: The caller's notes
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/MyNote" }
  /notes/sent:
    get:
      operationId: listSentNotes
      tags: [notes]
      summary: List notes the caller has sent
      responses:
        "200":
          description: Sent notes with per-recipient status
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/SentNote" }
  /notes/received:
    get:
      operationId: listReceivedNotes
      tags: [notes]
      summary: List notes sent to the caller
      parameters:
        - in: query
          name: archived
          required: false
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: Received notes with sender + status
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ReceivedNote" }
  /notes/{id}:
    get:
      operationId: getNoteDetail
      tags: [notes]
      summary: Get a note's full thread (note + recipients + replies)
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Note thread, scoped to caller (sender, recipient, or admin)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NoteThread" }
        "403":
          description: Not a participant
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: Note not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /notes/{id}/send:
    post:
      operationId: sendNote
      tags: [notes]
      summary: Owner sends an existing note to a list of recipients
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recipientUserIds]
              properties:
                recipientUserIds:
                  type: array
                  items: { type: integer }
      responses:
        "200":
          description: Sent (idempotent on existing recipients)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendNoteResult" }
  /notes/{id}/read:
    post:
      operationId: markNoteRead
      tags: [notes]
      summary: Recipient marks their copy of a note read
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Marked read
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SuccessResponse" }
  /notes/{id}/archive:
    post:
      operationId: archiveReceivedNote
      tags: [notes]
      summary: Recipient archives or unarchives their copy of a note
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [archived]
              properties:
                archived: { type: boolean }
      responses:
        "200":
          description: Archive state updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SuccessResponse" }
  /notes/{id}/reply:
    post:
      operationId: replyToNote
      tags: [notes]
      summary: Sender or recipient adds a reply to a note's thread
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string }
                recipientUserId:
                  type: integer
                  description: Required when the note owner replies and there is more than one recipient.
      responses:
        "201":
          description: Reply created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NoteReply" }

  # ----- Note folders (user-defined) -----
  /note-folders:
    get:
      operationId: listNoteFolders
      tags: [notes]
      summary: List the caller's note folders with per-tab note counts
      parameters:
        - in: query
          name: archived
          required: false
          description: Tab scope for noteCount (false = My Notes, true = Archive)
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: Folders owned by the caller
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/NoteFolder" }
    post:
      operationId: createNoteFolder
      tags: [notes]
      summary: Create a new note folder owned by the caller
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 80 }
      responses:
        "201":
          description: Folder created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NoteFolder" }
        "400":
          description: Invalid folder name (empty or too long)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: Duplicate folder name for this user
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /note-folders/{id}:
    patch:
      operationId: updateNoteFolder
      tags: [notes]
      summary: Rename a note folder
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
        - in: query
          name: archived
          required: false
          description: Tab scope for the returned noteCount (false = My Notes, true = Archive)
          schema: { type: boolean, default: false }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 80 }
      responses:
        "200":
          description: Folder updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NoteFolder" }
        "404":
          description: Folder not found or not owned by caller
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: Duplicate folder name for this user
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
    delete:
      operationId: deleteNoteFolder
      tags: [notes]
      summary: Delete a note folder. Notes inside are preserved (folderId set to null).
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Folder deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SuccessResponse" }

  /admin/users/{id}/dependents/notes:
    get:
      operationId: getAdminUserDependentNotes
      tags: [users]
      summary: List notes owned by a user
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: offset
          required: false
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Paged list of notes
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserDependentNotesPage"
        "404":
          description: User not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /admin/users/{id}/dependents/orders:
    get:
      operationId: getAdminUserDependentOrders
      tags: [users]
      summary: List service orders placed by a user
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: offset
          required: false
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Paged list of orders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserDependentOrdersPage"
        "404":
          description: User not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

components:
  schemas:
    HealthStatus:
      type: object
      properties:
        status:
          type: string
      required:
        - status

    SystemVersionInfo:
      type: object
      properties:
        version:
          type: string
        build:
          type: string
      required:
        - version
        - build

    SystemVersionResponse:
      type: object
      properties:
        current:
          $ref: "#/components/schemas/SystemVersionInfo"
        latest:
          oneOf:
            - $ref: "#/components/schemas/SystemVersionInfo"
            - type: "null"
        status:
          type: string
          enum: [up-to-date, update-available, not-configured, error]
        errorMessage:
          type: ["string", "null"]
        checkedAt:
          type: ["string", "null"]
          format: date-time
        configured:
          type: boolean
        startedAt:
          type: string
          format: date-time
          description: |
            ISO-8601 timestamp captured when the API process booted.
            Frozen for the lifetime of the process — used by the admin
            System Updates panel as visible proof that the container
            actually restarted (independent of build/version changes).
      required:
        - current
        - latest
        - status
        - errorMessage
        - checkedAt
        - configured
        - startedAt

    ErrorResponse:
      type: object
      properties:
        error:
          type: string
      required:
        - error

    SuccessResponse:
      type: object
      properties:
        success:
          type: boolean
      required:
        - success

    RegisterBody:
      type: object
      properties:
        username:
          type: string
        email:
          type: string
        password:
          type: string
        displayNameAr:
          type: ["string", "null"]
        displayNameEn:
          type: ["string", "null"]
        preferredLanguage:
          type: string
          default: "ar"
      required:
        - username
        - email
        - password

    CreateUserBody:
      type: object
      properties:
        username:
          type: string
        email:
          type: string
        password:
          type: string
        displayNameAr:
          type: ["string", "null"]
        displayNameEn:
          type: ["string", "null"]
        preferredLanguage:
          type: string
          default: "ar"
        groupIds:
          type: array
          description: Initial group memberships in addition to the auto-assigned Everyone group.
          items:
            type: integer
      required:
        - username
        - email
        - password

    LoginBody:
      type: object
      properties:
        username:
          type: string
        password:
          type: string
      required:
        - username
        - password

    ForgotPasswordBody:
      type: object
      properties:
        identifier:
          type: string
          description: Username or email
      required:
        - identifier

    ForgotPasswordResponse:
      type: object
      properties:
        success:
          type: boolean
      required:
        - success

    ResetPasswordBody:
      type: object
      properties:
        token:
          type: string
        newPassword:
          type: string
          minLength: 6
      required:
        - token
        - newPassword

    VerifyResetTokenBody:
      type: object
      properties:
        token:
          type: string
      required:
        - token

    AdminResetLinkResponse:
      type: object
      properties:
        resetUrl:
          type: string
        expiresAt:
          type: string
          format: date-time
      required:
        - resetUrl
        - expiresAt

    VerifyResetTokenResponse:
      type: object
      properties:
        valid:
          type: boolean
      required:
        - valid

    UpdateLanguageBody:
      type: object
      properties:
        language:
          type: string
      required:
        - language

    ClockStyle:
      type: string
      enum: [full, digital, digital-no-seconds, analog, minimal]
      description: Per-user home-screen clock style preference

    UpdateClockStyleBody:
      type: object
      properties:
        clockStyle:
          oneOf:
            - $ref: "#/components/schemas/ClockStyle"
            - type: "null"
          description: Clock style; pass null to clear and use the default
      required:
        - clockStyle

    NotificationSoundId:
      type: string
      enum: [ding, chime, bell, knock, pop, alert, beep, soft]

    UpdateNotificationPreferencesBody:
      type: object
      properties:
        notificationSoundOrder:
          $ref: "#/components/schemas/NotificationSoundId"
        notificationSoundMeeting:
          $ref: "#/components/schemas/NotificationSoundId"
        notificationSoundNote:
          $ref: "#/components/schemas/NotificationSoundId"
        notifyOrdersEnabled:
          type: boolean
        notifyMeetingsEnabled:
          type: boolean
        notifyNotesEnabled:
          type: boolean
        vibrationEnabledOrder:
          type: boolean
        vibrationEnabledMeeting:
          type: boolean
        vibrationEnabledNote:
          type: boolean
        notificationsMuted:
          type: boolean

    UpdateClockHour12Body:
      type: object
      properties:
        clockHour12:
          type: ["boolean", "null"]
          description: Whether to use 12-hour (AM/PM) clock format; null clears and uses the default (24-hour)
      required:
        - clockHour12

    AuthUser:
      type: object
      properties:
        id:
          type: integer
        username:
          type: string
        email:
          type: string
        displayNameAr:
          type: ["string", "null"]
        displayNameEn:
          type: ["string", "null"]
        preferredLanguage:
          type: string
        clockStyle:
          oneOf:
            - $ref: "#/components/schemas/ClockStyle"
            - type: "null"
        clockHour12:
          type: ["boolean", "null"]
        avatarUrl:
          type: ["string", "null"]
        isActive:
          type: boolean
        notificationSoundOrder:
          $ref: "#/components/schemas/NotificationSoundId"
        notificationSoundMeeting:
          $ref: "#/components/schemas/NotificationSoundId"
        notificationSoundNote:
          $ref: "#/components/schemas/NotificationSoundId"
        notifyOrdersEnabled:
          type: boolean
        notifyMeetingsEnabled:
          type: boolean
        notifyNotesEnabled:
          type: boolean
        vibrationEnabledOrder:
          type: boolean
        vibrationEnabledMeeting:
          type: boolean
        vibrationEnabledNote:
          type: boolean
        notificationsMuted:
          type: boolean
        roles:
          type: array
          items:
            type: string
        groups:
          type: array
          items:
            $ref: "#/components/schemas/GroupSummary"
        createdAt:
          type: string
          format: date-time
      required:
        - id
        - username
        - email
        - preferredLanguage
        - isActive
        - notificationSoundOrder
        - notificationSoundMeeting
        - notificationSoundNote
        - notifyOrdersEnabled
        - notifyMeetingsEnabled
        - notifyNotesEnabled
        - vibrationEnabledOrder
        - vibrationEnabledMeeting
        - vibrationEnabledNote
        - notificationsMuted
        - roles
        - groups
        - createdAt

    UserProfile:
      type: object
      properties:
        id:
          type: integer
        username:
          type: string
        email:
          type: string
        displayNameAr:
          type: ["string", "null"]
        displayNameEn:
          type: ["string", "null"]
        preferredLanguage:
          type: string
        clockStyle:
          oneOf:
            - $ref: "#/components/schemas/ClockStyle"
            - type: "null"
        clockHour12:
          type: ["boolean", "null"]
        avatarUrl:
          type: ["string", "null"]
        isActive:
          type: boolean
        roles:
          type: array
          items:
            type: string
        directRoles:
          type: array
          items:
            type: string
          description: |
            Roles assigned directly to the user (user_roles table only). The
            `roles` field above is the effective set (direct + inherited via
            group membership). The admin Users page uses the difference to
            mark a role as "inherited from group X" and disables the toggle
            so the admin doesn't try to remove a role that won't disappear.
        groups:
          type: array
          items:
            $ref: "#/components/schemas/GroupSummary"
        createdAt:
          type: string
          format: date-time
        noteCount:
          type: integer
          description: |
            Dependent record counts. Populated only by the admin list endpoint
            (GET /users) so the delete dialog can warn before the first click.
            Omitted from single-user responses (GET /users/:id, PATCH, etc).
        orderCount:
          type: integer
          description: |
            Number of service orders owned by this user. Populated only by the
            admin list endpoint (GET /users).
      required:
        - id
        - username
        - email
        - preferredLanguage
        - isActive
        - roles
        - groups
        - createdAt

    UpdateUserBody:
      type: object
      properties:
        displayNameAr:
          type: ["string", "null"]
        displayNameEn:
          type: ["string", "null"]
        isActive:
          type: boolean
        preferredLanguage:
          type: string
        groupIds:
          type: array
          items:
            type: integer
          description: If provided, replaces the user's group memberships

    AddUserRoleBody:
      type: object
      properties:
        roleName:
          type: string
          minLength: 1
      required:
        - roleName

    Role:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]
        isSystem:
          type: boolean
        createdAt:
          type: string
          format: date-time
        userCount:
          type: integer
          description: |
            Number of users assigned this role. Populated only by the admin
            list endpoint (GET /admin/roles) so the admin list can show the
            same dependency counts inline as the Apps/Services/Users/Groups
            panels. Omitted from create/update responses.
        groupCount:
          type: integer
          description: |
            Number of groups granting this role. Populated only by the admin
            list endpoint (GET /admin/roles).
      required:
        - id
        - name
        - isSystem
        - createdAt

    CreateRoleBody:
      type: object
      properties:
        name:
          type: string
          minLength: 1
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]
      required:
        - name

    UpdateRoleBody:
      type: object
      properties:
        name:
          type: string
          minLength: 1
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]

    RoleUsage:
      type: object
      properties:
        userCount:
          type: integer
        groupCount:
          type: integer
      required:
        - userCount
        - groupCount

    RoleDeletionConflict:
      type: object
      properties:
        error:
          type: string
        userCount:
          type: integer
        groupCount:
          type: integer
      required:
        - error
        - userCount
        - groupCount

    Permission:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]
      required:
        - id
        - name

    AddAppPermissionBody:
      type: object
      properties:
        permissionId:
          type: integer
          description: ID of the permission to require for this app.
      required:
        - permissionId

    AppPermissionLink:
      type: object
      properties:
        appId:
          type: integer
        permissionId:
          type: integer
      required:
        - appId
        - permissionId

    AppPermissionsImpactBody:
      type: object
      properties:
        permissionIds:
          type: array
          items:
            type: integer
          description: Candidate set of permission IDs that would gate this app. Pass an empty array to model "remove every requirement" (i.e. make the app unrestricted).
      required:
        - permissionIds

    AppPermissionImpactGroup:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
      required:
        - id
        - name

    AppPermissionsImpact:
      type: object
      properties:
        affectedUserCount:
          type: integer
          description: Distinct non-admin users who currently see the app and would lose visibility under the candidate permission set. Already accounts for users who keep access via `group_apps` group-grants.
        currentlyVisibleUserCount:
          type: integer
          description: Distinct non-admin users who currently see the app under the existing permission set. Useful for showing the warning as a fraction (e.g. "12 of 80 will lose access").
        groups:
          type: array
          items:
            $ref: "#/components/schemas/AppPermissionImpactGroup"
          description: Groups that currently grant this app via `group_apps`. Members of any of these groups keep access regardless of permission changes.
        noChange:
          type: boolean
          description: True when the candidate set is identical to the current permission set so no change would actually be saved.
      required:
        - affectedUserCount
        - currentlyVisibleUserCount
        - groups
        - noChange

    ReplaceRolePermissionsBody:
      type: object
      properties:
        permissionIds:
          type: array
          items:
            type: integer
          description: Full set of permission IDs the role should grant. Existing assignments not present here are removed.
      required:
        - permissionIds

    RolePermissionsImpactBody:
      type: object
      properties:
        permissionIds:
          type: array
          items:
            type: integer
          description: Candidate set of permission IDs to evaluate against the role's current assignments.
      required:
        - permissionIds

    RolePermissionImpactGroup:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
      required:
        - id
        - name

    RolePermissionImpactItem:
      type: object
      properties:
        permissionId:
          type: integer
        permissionName:
          type: string
        userCount:
          type: integer
          description: Distinct users who currently have this permission via this role and would lose it (no other role they hold grants the permission).
        groupCount:
          type: integer
          description: Number of groups that currently grant this role.
        groups:
          type: array
          items:
            $ref: "#/components/schemas/RolePermissionImpactGroup"
          description: Groups that currently grant this role and therefore propagate the removal to their members.
      required:
        - permissionId
        - permissionName
        - userCount
        - groupCount
        - groups

    RolePermissionsImpact:
      type: object
      properties:
        removed:
          type: array
          items:
            $ref: "#/components/schemas/RolePermissionImpactItem"
          description: One entry per permission that is currently assigned to the role but not in the candidate set.
        totalAffectedUsers:
          type: integer
          description: Distinct users that would lose at least one permission across all removals.
      required:
        - removed
        - totalAffectedUsers

    GroupSummary:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]
      required:
        - id
        - name

    Group:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]
        isSystem:
          type: boolean
        memberCount:
          type: integer
        appCount:
          type: integer
        roleCount:
          type: integer
        createdAt:
          type: string
          format: date-time
      required:
        - id
        - name
        - isSystem
        - memberCount
        - appCount
        - roleCount
        - createdAt

    GroupDeletionConflict:
      type: object
      properties:
        error:
          type: string
        memberCount:
          type: integer
        appCount:
          type: integer
        roleCount:
          type: integer
      required:
        - error
        - memberCount
        - appCount
        - roleCount

    UserDeletionConflict:
      type: object
      properties:
        error:
          type: string
        noteCount:
          type: integer
        orderCount:
          type: integer
      required:
        - error
        - noteCount
        - orderCount

    AppDeletionConflict:
      type: object
      properties:
        error:
          type: string
        groupCount:
          type: integer
        restrictionCount:
          type: integer
        openCount:
          type: integer
      required:
        - error
        - groupCount
        - restrictionCount
        - openCount

    ServiceDeletionConflict:
      type: object
      properties:
        error:
          type: string
        orderCount:
          type: integer
      required:
        - error
        - orderCount

    GroupDetail:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]
        isSystem:
          type: boolean
        appIds:
          type: array
          items:
            type: integer
        roleIds:
          type: array
          items:
            type: integer
        userIds:
          type: array
          items:
            type: integer
        createdAt:
          type: string
          format: date-time
      required:
        - id
        - name
        - isSystem
        - appIds
        - roleIds
        - userIds
        - createdAt

    CreateGroupBody:
      type: object
      properties:
        name:
          type: string
          minLength: 1
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]
        appIds:
          type: array
          items:
            type: integer
        roleIds:
          type: array
          items:
            type: integer
        userIds:
          type: array
          items:
            type: integer
      required:
        - name

    UpdateGroupBody:
      type: object
      properties:
        name:
          type: string
          minLength: 1
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]
        appIds:
          type: array
          items:
            type: integer
        roleIds:
          type: array
          items:
            type: integer
        userIds:
          type: array
          items:
            type: integer

    App:
      type: object
      properties:
        id:
          type: integer
        slug:
          type: string
        nameAr:
          type: string
        nameEn:
          type: string
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]
        iconName:
          type: string
        imageUrl:
          type: ["string", "null"]
          description: |
            Optional uploaded image used in place of the Lucide icon on the
            launcher tile. Stored as the object-storage path returned by the
            upload presigner (e.g. "/objects/<id>").
        route:
          type: string
        externalUrl:
          type: ["string", "null"]
          description: |
            For openMode "external_tab" or "external_iframe": the URL the
            launcher should open. Ignored when openMode is "internal".
        openMode:
          type: string
          enum: [internal, external_tab, external_iframe]
          description: |
            How the launcher opens this app: "internal" navigates to `route`
            inside the SPA; "external_tab" opens externalUrl in a new tab;
            "external_iframe" navigates to /embedded/:id, which renders
            externalUrl inside an iframe shell.
        color:
          type: string
        isActive:
          type: boolean
        isSystem:
          type: boolean
        sortOrder:
          type: integer
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        groupCount:
          type: integer
          description: |
            Number of groups granting access to this app. Populated only by the
            admin list endpoint (GET /admin/apps) so the delete dialog can warn
            before the first click. Omitted from non-admin and single-app
            responses.
        restrictionCount:
          type: integer
          description: |
            Number of legacy permission restrictions for this app. Populated
            only by the admin list endpoint (GET /admin/apps).
        openCount:
          type: integer
          description: |
            Number of recorded app-open events for this app. Populated only by
            the admin list endpoint (GET /admin/apps).
      required:
        - id
        - slug
        - nameAr
        - nameEn
        - iconName
        - route
        - openMode
        - color
        - isActive
        - isSystem
        - sortOrder
        - createdAt
        - updatedAt

    CreateAppBody:
      type: object
      properties:
        slug:
          type: string
        nameAr:
          type: string
        nameEn:
          type: string
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]
        iconName:
          type: string
        imageUrl:
          type: ["string", "null"]
        route:
          type: string
        externalUrl:
          type: ["string", "null"]
        openMode:
          type: string
          enum: [internal, external_tab, external_iframe]
        color:
          type: string
        isActive:
          type: boolean
        isSystem:
          type: boolean
        sortOrder:
          type: integer
        permissionIds:
          type: array
          description: Optional set of permission IDs to gate the new app on. Inserted with onConflictDoNothing so duplicates are tolerated.
          items:
            type: integer
      required:
        - slug
        - nameAr
        - nameEn
        - iconName
        - route
        - color

    UpdateAppBody:
      type: object
      properties:
        nameAr:
          type: string
        nameEn:
          type: string
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]
        iconName:
          type: string
        imageUrl:
          type: ["string", "null"]
        route:
          type: string
        externalUrl:
          type: ["string", "null"]
        openMode:
          type: string
          enum: [internal, external_tab, external_iframe]
        color:
          type: string
        isActive:
          type: boolean
        sortOrder:
          type: integer

    Service:
      type: object
      properties:
        id:
          type: integer
        categoryId:
          type: ["integer", "null"]
        nameAr:
          type: string
        nameEn:
          type: string
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]
        imageUrl:
          type: ["string", "null"]
        price:
          type: ["string", "null"]
        isAvailable:
          type: boolean
        sortOrder:
          type: integer
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        orderCount:
          type: integer
          description: |
            Number of service_orders rows referencing this service. Populated
            only by the list endpoint (GET /services) so the admin delete
            dialog can warn before the first click. Omitted from single-service
            responses.
      required:
        - id
        - nameAr
        - nameEn
        - isAvailable
        - sortOrder
        - createdAt
        - updatedAt

    CreateServiceBody:
      type: object
      properties:
        categoryId:
          type: ["integer", "null"]
        nameAr:
          type: string
        nameEn:
          type: string
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]
        imageUrl:
          type: ["string", "null"]
        price:
          type: ["string", "null"]
        isAvailable:
          type: boolean
        sortOrder:
          type: integer
      required:
        - nameAr
        - nameEn

    UpdateServiceBody:
      type: object
      properties:
        categoryId:
          type: ["integer", "null"]
        nameAr:
          type: string
        nameEn:
          type: string
        descriptionAr:
          type: ["string", "null"]
        descriptionEn:
          type: ["string", "null"]
        imageUrl:
          type: ["string", "null"]
        price:
          type: ["string", "null"]
        isAvailable:
          type: boolean
        sortOrder:
          type: integer

    ReorderServicesBody:
      type: object
      description: |
        New global service order. `orderedIds` must contain every
        existing service id exactly once. The server sets each
        service's `sortOrder` to its position in the list (0-based),
        so a later GET /services returns them in the same order.
      properties:
        orderedIds:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            type: integer
            minimum: 1
      required:
        - orderedIds

    ServiceCategory:
      type: object
      properties:
        id:
          type: integer
        nameAr:
          type: string
        nameEn:
          type: string
        sortOrder:
          type: integer
        createdAt:
          type: string
          format: date-time
      required:
        - id
        - nameAr
        - nameEn
        - sortOrder
        - createdAt

    ServiceOrderUser:
      type: object
      properties:
        id:
          type: integer
        username:
          type: string
        displayNameAr:
          type: ["string", "null"]
        displayNameEn:
          type: ["string", "null"]
        avatarUrl:
          type: ["string", "null"]
      required:
        - id
        - username

    ServiceOrderService:
      type: object
      properties:
        id:
          type: integer
        nameAr:
          type: string
        nameEn:
          type: string
        imageUrl:
          type: ["string", "null"]
      required:
        - id
        - nameAr
        - nameEn

    ServiceOrderStatus:
      type: string
      enum: [pending, received, preparing, completed, cancelled]

    ServiceOrder:
      type: object
      properties:
        id:
          type: integer
        serviceId:
          type: integer
        userId:
          type: integer
        assignedTo:
          type: ["integer", "null"]
        notes:
          type: ["string", "null"]
        status:
          $ref: "#/components/schemas/ServiceOrderStatus"
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        service:
          $ref: "#/components/schemas/ServiceOrderService"
        requester:
          oneOf:
            - $ref: "#/components/schemas/ServiceOrderUser"
            - type: "null"
        assignee:
          oneOf:
            - $ref: "#/components/schemas/ServiceOrderUser"
            - type: "null"
      required:
        - id
        - serviceId
        - userId
        - status
        - createdAt
        - updatedAt
        - service

    CreateServiceOrderBody:
      type: object
      properties:
        serviceId:
          type: integer
        notes:
          type: ["string", "null"]
          maxLength: 500
      required:
        - serviceId

    UpdateServiceOrderStatusBody:
      type: object
      properties:
        status:
          type: string
          enum: [pending, received, preparing, completed, cancelled]
      required:
        - status

    BulkDeleteServiceOrdersBody:
      type: object
      properties:
        ids:
          type: array
          items:
            type: integer
          minItems: 1
          maxItems: 200
      required:
        - ids

    BulkDeleteServiceOrdersResponse:
      type: object
      properties:
        deletedIds:
          type: array
          items:
            type: integer
          description: Ids that were successfully deleted.
        failedIds:
          type: array
          items:
            type: integer
          description: Ids that were skipped (not found, or caller is not authorized).
      required:
        - deletedIds
        - failedIds

    Notification:
      type: object
      properties:
        id:
          type: integer
        userId:
          type: integer
        titleAr:
          type: string
        titleEn:
          type: string
        bodyAr:
          type: ["string", "null"]
        bodyEn:
          type: ["string", "null"]
        type:
          type: string
        isRead:
          type: boolean
        relatedId:
          type: ["integer", "null"]
        relatedType:
          type: ["string", "null"]
        createdAt:
          type: string
          format: date-time
      required:
        - id
        - userId
        - titleAr
        - titleEn
        - type
        - isRead
        - createdAt

    AppSettings:
      type: object
      required: [siteNameAr, siteNameEn, registrationOpen, footerTextAr, footerTextEn]
      properties:
        siteNameAr:
          type: string
        siteNameEn:
          type: string
        registrationOpen:
          type: boolean
        footerTextAr:
          type: string
        footerTextEn:
          type: string
        updatedAt:
          type: string
          format: date-time

    UpdateMyAppOrderBody:
      type: object
      required: [order]
      properties:
        order:
          type: array
          items:
            type: integer
          description: App IDs in the desired display order

    UpdateAppSettingsBody:
      type: object
      properties:
        siteNameAr:
          type: string
          minLength: 1
          maxLength: 200
        siteNameEn:
          type: string
          minLength: 1
          maxLength: 200
        registrationOpen:
          type: boolean
        footerTextAr:
          type: string
          minLength: 1
          maxLength: 300
        footerTextEn:
          type: string
          minLength: 1
          maxLength: 300

    RequestUploadUrlBody:
      type: object
      required: [name, size, contentType]
      properties:
        name:
          type: string
          minLength: 1
        size:
          type: integer
          minimum: 1
        contentType:
          type: string
          minLength: 1

    RequestUploadUrlResponse:
      type: object
      required: [uploadURL, objectPath]
      properties:
        uploadURL:
          type: string
          minLength: 1
          description: >
            Presigned upload destination. May be either an absolute URL
            (S3 driver) or a same-origin path starting with `/` (local
            driver) — the browser resolves the latter against
            `window.location.origin` so uploads always target the host
            serving the SPA.
        objectPath:
          type: string
        metadata:
          $ref: "#/components/schemas/RequestUploadUrlBody"

    HomeStats:
      type: object
      properties:
        totalApps:
          type: integer
        totalServices:
          type: integer
        unreadNotifications:
          type: integer
        totalUsers:
          type: integer
      required:
        - totalApps
        - totalServices
        - unreadNotifications
        - totalUsers

    AdminStats:
      type: object
      properties:
        range:
          type: string
          enum: ["7d", "30d", "90d", "custom"]
        rangeDays:
          type: integer
        rangeFrom:
          type: string
          description: Start date (YYYY-MM-DD UTC) of the selected window
        rangeTo:
          type: string
          description: End date (YYYY-MM-DD UTC) of the selected window
        newUsersInRange:
          type: integer
        newUsersPrevRange:
          type: integer
        activeServices:
          type: integer
        inactiveServices:
          type: integer
        signupsByDay:
          type: array
          items:
            type: object
            properties:
              date:
                type: string
              count:
                type: integer
            required:
              - date
              - count
        appOpensByDay:
          type: array
          items:
            type: object
            properties:
              date:
                type: string
              count:
                type: integer
            required:
              - date
              - count
        appOpensInRange:
          type: integer
        appOpensPrevRange:
          type: integer
        servicesCreatedByDay:
          type: array
          items:
            type: object
            properties:
              date:
                type: string
              count:
                type: integer
            required:
              - date
              - count
        servicesCreatedInRange:
          type: integer
        topApps:
          type: array
          items:
            $ref: "#/components/schemas/TopAppItem"
        mostActiveUsers:
          type: array
          items:
            $ref: "#/components/schemas/TopUserItem"
      required:
        - range
        - rangeDays
        - newUsersInRange
        - newUsersPrevRange
        - activeServices
        - inactiveServices
        - signupsByDay
        - appOpensByDay
        - appOpensInRange
        - appOpensPrevRange
        - servicesCreatedByDay
        - servicesCreatedInRange
        - topApps
        - mostActiveUsers

    TopAppItem:
      type: object
      properties:
        appId:
          type: integer
        slug:
          type: string
        nameAr:
          type: string
        nameEn:
          type: string
        iconName:
          type: string
        color:
          type: string
        count:
          type: integer
      required:
        - appId
        - slug
        - nameAr
        - nameEn
        - iconName
        - color
        - count

    AppOpenByAppEntry:
      type: object
      properties:
        id:
          type: integer
        createdAt:
          type: string
          format: date-time
        userId:
          type: integer
        username:
          type: string
        displayNameAr:
          type: ["string", "null"]
        displayNameEn:
          type: ["string", "null"]
        avatarUrl:
          type: ["string", "null"]
      required:
        - id
        - createdAt
        - userId
        - username

    AdminAppOpensByApp:
      type: object
      properties:
        appId:
          type: integer
        slug:
          type: string
        nameAr:
          type: string
        nameEn:
          type: string
        iconName:
          type: string
        color:
          type: string
        range:
          type: string
          enum: ["7d", "30d", "90d", "custom"]
        rangeDays:
          type: integer
        rangeFrom:
          type: string
        rangeTo:
          type: string
        totalCount:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        nextOffset:
          type: ["integer", "null"]
        opens:
          type: array
          items:
            $ref: "#/components/schemas/AppOpenByAppEntry"
      required:
        - appId
        - slug
        - nameAr
        - nameEn
        - iconName
        - color
        - range
        - rangeDays
        - rangeFrom
        - rangeTo
        - totalCount
        - limit
        - offset
        - nextOffset
        - opens

    AppOpenByUserEntry:
      type: object
      properties:
        id:
          type: integer
        createdAt:
          type: string
          format: date-time
        appId:
          type: integer
        slug:
          type: string
        nameAr:
          type: string
        nameEn:
          type: string
        iconName:
          type: string
        color:
          type: string
      required:
        - id
        - createdAt
        - appId
        - slug
        - nameAr
        - nameEn
        - iconName
        - color

    AdminAppOpensByUser:
      type: object
      properties:
        userId:
          type: integer
        username:
          type: string
        displayNameAr:
          type: ["string", "null"]
        displayNameEn:
          type: ["string", "null"]
        avatarUrl:
          type: ["string", "null"]
        range:
          type: string
          enum: ["7d", "30d", "90d", "custom"]
        rangeDays:
          type: integer
        rangeFrom:
          type: string
        rangeTo:
          type: string
        totalCount:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        nextOffset:
          type: ["integer", "null"]
        opens:
          type: array
          items:
            $ref: "#/components/schemas/AppOpenByUserEntry"
      required:
        - userId
        - username
        - range
        - rangeDays
        - rangeFrom
        - rangeTo
        - totalCount
        - limit
        - offset
        - nextOffset
        - opens

    # ===== Dependent-item drill-ins =====
    # Each "<n> X" badge on the admin Apps / Services / Users rows is now
    # clickable; the click loads one of the schemas below. They share the
    # same envelope (totalCount + limit/offset/nextOffset) so the frontend
    # can use a single LoadMoreSection for them all.

    AppDependentGroupItem:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        descriptionAr: { type: ["string", "null"] }
        descriptionEn: { type: ["string", "null"] }
      required: [id, name, descriptionAr, descriptionEn]

    AppDependentGroupsPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/AppDependentGroupItem"
        totalCount: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
        nextOffset: { type: ["integer", "null"] }
      required: [items, totalCount, limit, offset, nextOffset]

    AppDependentRestrictionItem:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        descriptionAr: { type: ["string", "null"] }
        descriptionEn: { type: ["string", "null"] }
        roles:
          type: array
          description: Names of roles currently holding this permission.
          items: { type: string }
      required: [id, name, descriptionAr, descriptionEn, roles]

    AppDependentRestrictionsPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/AppDependentRestrictionItem"
        totalCount: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
        nextOffset: { type: ["integer", "null"] }
      required: [items, totalCount, limit, offset, nextOffset]

    AppDependentOpenItem:
      type: object
      properties:
        id: { type: integer }
        createdAt: { type: string, format: date-time }
        userId: { type: integer }
        username: { type: string }
        displayNameAr: { type: ["string", "null"] }
        displayNameEn: { type: ["string", "null"] }
        avatarUrl: { type: ["string", "null"] }
      required: [id, createdAt, userId, username, displayNameAr, displayNameEn, avatarUrl]

    AppDependentOpensPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/AppDependentOpenItem"
        totalCount: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
        nextOffset: { type: ["integer", "null"] }
      required: [items, totalCount, limit, offset, nextOffset]

    ServiceDependentOrderItem:
      type: object
      properties:
        id: { type: integer }
        status: { type: string }
        notes: { type: ["string", "null"] }
        createdAt: { type: string, format: date-time }
        userId: { type: integer }
        username: { type: string }
        displayNameAr: { type: ["string", "null"] }
        displayNameEn: { type: ["string", "null"] }
      required: [id, status, notes, createdAt, userId, username, displayNameAr, displayNameEn]

    ServiceDependentOrdersPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/ServiceDependentOrderItem"
        totalCount: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
        nextOffset: { type: ["integer", "null"] }
      required: [items, totalCount, limit, offset, nextOffset]

    UserDependentNoteItem:
      type: object
      properties:
        id: { type: integer }
        title: { type: string }
        isPinned: { type: boolean }
        isArchived: { type: boolean }
        updatedAt: { type: string, format: date-time }
      required: [id, title, isPinned, isArchived, updatedAt]

    NoteRecipientStatus:
      type: string
      enum: [unread, read, replied, archived]

    NoteUserSummary:
      type: object
      properties:
        id: { type: integer }
        username: { type: string }
        displayNameAr: { type: ["string", "null"] }
        displayNameEn: { type: ["string", "null"] }
        avatarUrl: { type: ["string", "null"] }
        isActive: { type: boolean }
      required: [id, username, displayNameAr, displayNameEn]

    NoteRecipientSummary:
      type: object
      properties:
        id: { type: integer }
        noteId: { type: integer }
        recipientUserId: { type: integer }
        status: { $ref: "#/components/schemas/NoteRecipientStatus" }
        sentAt: { type: string, format: date-time }
        readAt: { type: ["string", "null"], format: date-time }
        archivedAt: { type: ["string", "null"], format: date-time }
        recipient:
          oneOf:
            - $ref: "#/components/schemas/NoteUserSummary"
            - type: "null"
      required:
        [id, recipientUserId, status, sentAt, readAt, archivedAt]

    MyNote:
      type: object
      description: A note owned by the caller (My Notes / Archive view).
      properties:
        id: { type: integer }
        userId: { type: integer }
        title: { type: string }
        content: { type: string }
        color: { type: string }
        isPinned: { type: boolean }
        isArchived: { type: boolean }
        folderId:
          type: ["integer", "null"]
          description: Owning folder id, or null for Unfiled.
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        labelIds:
          type: array
          items: { type: integer }
      required:
        [id, userId, title, content, color, isPinned, isArchived, folderId,
         createdAt, updatedAt, labelIds]

    NoteFolder:
      type: object
      properties:
        id: { type: integer }
        userId: { type: integer }
        name: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        noteCount:
          type: integer
          description: Number of notes in this folder, scoped to the requested tab (active vs archived).
      required: [id, userId, name, createdAt, updatedAt, noteCount]

    SentNote:
      type: object
      properties:
        id: { type: integer }
        userId: { type: integer }
        title: { type: string }
        content: { type: string }
        color: { type: string }
        isPinned: { type: boolean }
        isArchived: { type: boolean }
        folderId:
          type: ["integer", "null"]
          description: Owning folder id, or null for Unfiled.
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        recipients:
          type: array
          items: { $ref: "#/components/schemas/NoteRecipientSummary" }
        replyCount: { type: integer }
      required:
        [id, userId, title, content, color, createdAt, updatedAt, recipients, replyCount]

    ReceivedNote:
      type: object
      properties:
        id: { type: integer }
        title: { type: string }
        content: { type: string }
        color: { type: string }
        folderId:
          type: ["integer", "null"]
          description: Owning folder id on the recipient's side, or null for Unfiled.
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        sender:
          oneOf:
            - $ref: "#/components/schemas/NoteUserSummary"
            - type: "null"
        recipientRowId: { type: integer }
        status: { $ref: "#/components/schemas/NoteRecipientStatus" }
        sentAt: { type: string, format: date-time }
        readAt: { type: ["string", "null"], format: date-time }
        archivedAt: { type: ["string", "null"], format: date-time }
      required:
        [id, title, content, color, createdAt, updatedAt, recipientRowId, status,
         sentAt, readAt, archivedAt]

    NoteReply:
      type: object
      properties:
        id: { type: integer }
        noteId: { type: integer }
        senderUserId: { type: integer }
        recipientUserId: { type: integer }
        content: { type: string }
        createdAt: { type: string, format: date-time }
        sender:
          oneOf:
            - $ref: "#/components/schemas/NoteUserSummary"
            - type: "null"
      required:
        [id, noteId, senderUserId, recipientUserId, content, createdAt]

    NoteThread:
      type: object
      properties:
        id: { type: integer }
        title: { type: string }
        content: { type: string }
        color: { type: string }
        createdAt: { type: ["string", "null"], format: date-time }
        updatedAt: { type: ["string", "null"], format: date-time }
        senderUserId: { type: integer }
        sender:
          oneOf:
            - $ref: "#/components/schemas/NoteUserSummary"
            - type: "null"
        isOwner: { type: boolean }
        isAdmin: { type: boolean }
        myStatus:
          oneOf:
            - $ref: "#/components/schemas/NoteRecipientStatus"
            - type: "null"
        recipients:
          type: array
          items: { $ref: "#/components/schemas/NoteRecipientSummary" }
        replies:
          type: array
          items: { $ref: "#/components/schemas/NoteReply" }
      required:
        [id, title, content, color, senderUserId, isOwner, isAdmin, myStatus,
         recipients, replies]

    SendNoteResult:
      type: object
      properties:
        success: { type: boolean }
        sent: { type: integer }
      required: [success, sent]

    UserDependentNotesPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/UserDependentNoteItem"
        totalCount: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
        nextOffset: { type: ["integer", "null"] }
      required: [items, totalCount, limit, offset, nextOffset]

    UserDependentOrderItem:
      type: object
      properties:
        id: { type: integer }
        status: { type: string }
        notes: { type: ["string", "null"] }
        createdAt: { type: string, format: date-time }
        serviceId: { type: integer }
        serviceNameAr: { type: string }
        serviceNameEn: { type: string }
      required: [id, status, notes, createdAt, serviceId, serviceNameAr, serviceNameEn]

    UserDependentOrdersPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/UserDependentOrderItem"
        totalCount: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
        nextOffset: { type: ["integer", "null"] }
      required: [items, totalCount, limit, offset, nextOffset]

    TopUserItem:
      type: object
      properties:
        userId:
          type: integer
        username:
          type: string
        displayNameAr:
          type: ["string", "null"]
        displayNameEn:
          type: ["string", "null"]
        avatarUrl:
          type: ["string", "null"]
        count:
          type: integer
      required:
        - userId
        - username
        - count

    AuditLogActor:
      type: object
      properties:
        id:
          type: integer
        username:
          type: string
        displayNameAr:
          type: ["string", "null"]
        displayNameEn:
          type: ["string", "null"]
        avatarUrl:
          type: ["string", "null"]
      required:
        - id
        - username

    RolePermissionAuditList:
      type: object
      properties:
        entries:
          type: array
          items:
            $ref: "#/components/schemas/RolePermissionAuditEntry"
        totalCount:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        nextOffset:
          type: ["integer", "null"]
      required:
        - entries
        - totalCount
        - limit
        - offset
        - nextOffset

    RolePermissionAuditEntry:
      type: object
      properties:
        id:
          type: integer
        roleId:
          type: integer
        previousPermissionIds:
          type: array
          items:
            type: integer
        newPermissionIds:
          type: array
          items:
            type: integer
        addedPermissionIds:
          type: array
          items:
            type: integer
        removedPermissionIds:
          type: array
          items:
            type: integer
        createdAt:
          type: string
          format: date-time
        actor:
          oneOf:
            - $ref: "#/components/schemas/AuditLogActor"
            - type: "null"
      required:
        - id
        - roleId
        - previousPermissionIds
        - newPermissionIds
        - addedPermissionIds
        - removedPermissionIds
        - createdAt
        - actor

    PermissionAuditList:
      type: object
      properties:
        entries:
          type: array
          items:
            $ref: "#/components/schemas/PermissionAuditEntry"
        totalCount:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        nextOffset:
          type: ["integer", "null"]
      required:
        - entries
        - totalCount
        - limit
        - offset
        - nextOffset

    PermissionAuditEntry:
      type: object
      properties:
        id:
          type: integer
        targetKind:
          type: string
          enum: [user, group, app]
        targetId:
          type: integer
        changeKind:
          type: string
          enum:
            - user.roles
            - user.groups
            - group.users
            - group.roles
            - group.apps
            - app.permissions
        previousIds:
          type: array
          items:
            type: integer
        newIds:
          type: array
          items:
            type: integer
        addedIds:
          type: array
          items:
            type: integer
        removedIds:
          type: array
          items:
            type: integer
        createdAt:
          type: string
          format: date-time
        actor:
          oneOf:
            - $ref: "#/components/schemas/AuditLogActor"
            - type: "null"
      required:
        - id
        - targetKind
        - targetId
        - changeKind
        - previousIds
        - newIds
        - addedIds
        - removedIds
        - createdAt
        - actor

    AuditLogEntry:
      type: object
      properties:
        id:
          type: integer
        action:
          type: string
        targetType:
          type: string
        targetId:
          type: ["integer", "null"]
        metadata:
          type: ["object", "null"]
          additionalProperties: true
        createdAt:
          type: string
          format: date-time
        actor:
          oneOf:
            - $ref: "#/components/schemas/AuditLogActor"
            - type: "null"
      required:
        - id
        - action
        - targetType
        - targetId
        - metadata
        - createdAt
        - actor

    AuditLogList:
      type: object
      properties:
        entries:
          type: array
          items:
            $ref: "#/components/schemas/AuditLogEntry"
        totalCount:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        nextOffset:
          type: ["integer", "null"]
        actions:
          type: array
          items:
            type: string
          description: All distinct action names present in the audit log (for building filter UI).
      required:
        - entries
        - totalCount
        - limit
        - offset
        - nextOffset
        - actions
