openapi: 3.1.0

info:
  title: FuzeFront Notification API
  version: 1.0.0
  description: >-
    The inbox behind the notification bell.

    ONE ROW PER RECIPIENT: fan-out happens at write time, so the read path is a
    single indexed scan and the badge is a partial-index count.

    Every user-facing operation derives the recipient from the VERIFIED bearer
    token and never from the request — there is no operation that accepts a user
    id, so there is nothing to tamper into another user's mailbox.

    `POST /internal/publish` is the one privileged operation: it names its
    recipients and is authenticated with a shared SERVICE token, not a user JWT.
    It is never reachable from the browser.
  license:
    name: MIT

servers:
  - url: /api/v1
    description: Same-origin, proxied by the host backend.

tags:
  - name: Inbox
    description: Reading and changing the state of one user's notifications.
  - name: Preferences
    description: Per-category, per-channel opt-in.
  - name: Internal
    description: Service-to-service publishing.

security:
  - bearerAuth: []

paths:
  /notifications:
    get:
      operationId: listNotifications
      tags: [Inbox]
      summary: List the caller's notifications
      description: >-
        Newest first, cursor-paginated on `createdAt`. Cursor rather than offset
        so a live arrival during paging cannot shift rows across page boundaries.
        Account-wide notifications (no organization) remain visible when an
        `organizationId` filter is applied — a security alert must not disappear
        because the user is looking at one workspace.
      parameters:
        - name: organizationId
          in: query
          schema: { type: string, format: uuid }
        - name: category
          in: query
          schema: { $ref: '#/components/schemas/NotificationCategory' }
        - name: status
          in: query
          schema: { type: string, enum: [unread, read, all], default: all }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
        - name: cursor
          in: query
          description: '`nextCursor` from the previous page.'
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: A page of notifications.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NotificationPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /notifications/unread-count:
    get:
      operationId: getUnreadCount
      tags: [Inbox]
      summary: Unread count for the badge
      parameters:
        - name: organizationId
          in: query
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The count.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [unread]
                properties:
                  unread: { type: integer, minimum: 0 }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /notifications/stream:
    get:
      operationId: streamNotifications
      tags: [Inbox]
      summary: Live notification stream (SSE)
      description: >-
        `text/event-stream`. Emits a `ready` event on connect, `notification`
        events as they arrive, and a comment heartbeat so proxies do not reap an
        idle connection.

        `EventSource` cannot set an Authorization header, so this operation also
        accepts the same bearer token as a `token` query parameter. Accepted here
        and only here: the stream is read-only and same-origin over TLS.

        A user is capped at a small number of concurrent streams; exceeding it
        yields an `error` event with `too_many_streams` and the connection closes.
      parameters:
        - name: token
          in: query
          description: Bearer token, for EventSource clients that cannot set headers.
          schema: { type: string }
      responses:
        '200':
          description: The event stream.
          content:
            text/event-stream:
              schema: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /notifications/read-all:
    post:
      operationId: markAllRead
      tags: [Inbox]
      summary: Mark every notification read
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScopeBody' }
      responses:
        '200': { $ref: '#/components/responses/UpdatedCount' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /notifications/seen:
    post:
      operationId: markAllSeen
      tags: [Inbox]
      summary: Mark every notification seen (clears the badge)
      description: >-
        SEEN is not READ. Opening the panel means the user has looked, so the
        badge clears; it does not mark each item read. Collapsing the two would
        silently discard the unread state the user came to act on.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScopeBody' }
      responses:
        '200': { $ref: '#/components/responses/UpdatedCount' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /notifications/preferences:
    get:
      operationId: getPreferences
      tags: [Preferences]
      summary: The full category x channel matrix
      description: >-
        Defaults overlaid with the user's explicit choices. The table is sparse —
        only explicit choices are stored — but the response is always complete.
      responses:
        '200': { $ref: '#/components/responses/Preferences' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    put:
      operationId: updatePreferences
      tags: [Preferences]
      summary: Update preferences
      description: >-
        Immutable pairs are ignored rather than rejected — in-app `security`
        cannot be switched off, and failing the whole save because the client
        echoed it back would be hostile.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [preferences]
              properties:
                preferences:
                  type: array
                  items: { $ref: '#/components/schemas/PreferenceUpdate' }
      responses:
        '200': { $ref: '#/components/responses/Preferences' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /notifications/{id}/read:
    post:
      operationId: markRead
      tags: [Inbox]
      summary: Mark one notification read
      description: Idempotent — marking an already-read notification succeeds.
      parameters: [{ $ref: '#/components/parameters/NotificationId' }]
      responses:
        '200': { $ref: '#/components/responses/Ok' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /notifications/{id}/unread:
    post:
      operationId: markUnread
      tags: [Inbox]
      summary: Mark one notification unread
      parameters: [{ $ref: '#/components/parameters/NotificationId' }]
      responses:
        '200': { $ref: '#/components/responses/Ok' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /notifications/{id}:
    delete:
      operationId: archiveNotification
      tags: [Inbox]
      summary: Archive one notification
      description: >-
        A soft archive, not a delete. The dedupe index is scoped to live rows, so
        archiving frees the producer's key for a legitimate later re-send.
      parameters: [{ $ref: '#/components/parameters/NotificationId' }]
      responses:
        '200': { $ref: '#/components/responses/Ok' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /notifications/internal/publish:
    post:
      operationId: publishNotification
      tags: [Internal]
      summary: Fan one event out to named recipients
      description: >-
        SERVICE-TO-SERVICE ONLY, authenticated with the shared internal token.
        Never reachable from the browser, and fails closed (503) when no internal
        token is configured.

        Recipients are explicit. This service does not resolve "everyone in
        organization X" itself: membership lives in the platform database, and
        duplicating that query here would create a second, drifting source of
        truth for who is a member. The calling service, which already knows,
        expands it.

        `dedupeKey` is suffixed per recipient, so one producer key deduplicates
        each person's copy independently. A retried producer is a no-op.
      security:
        - internalToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PublishRequest' }
      responses:
        '201':
          description: Fan-out result.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [created, deduped, suppressed, ids]
                properties:
                  created: { type: integer, minimum: 0 }
                  deduped:
                    type: integer
                    minimum: 0
                    description: Recipients whose copy already existed.
                  suppressed:
                    type: integer
                    minimum: 0
                    description: Recipients who opted out of this category.
                  ids:
                    type: array
                    items: { type: string, format: uuid }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503':
          description: Internal publishing is not configured.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    internalToken:
      type: http
      scheme: bearer
      description: Shared service token. Never issued to a browser.

  parameters:
    NotificationId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }

  responses:
    Ok:
      description: Success.
      content:
        application/json:
          schema:
            type: object
            additionalProperties: false
            required: [ok]
            properties:
              ok: { type: boolean }
    UpdatedCount:
      description: Number of rows changed.
      content:
        application/json:
          schema:
            type: object
            additionalProperties: false
            required: [updated]
            properties:
              updated: { type: integer, minimum: 0 }
    Preferences:
      description: The full preference matrix.
      content:
        application/json:
          schema:
            type: object
            additionalProperties: false
            required: [preferences]
            properties:
              preferences: { $ref: '#/components/schemas/PreferenceMap' }
    BadRequest:
      description: Invalid request.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: >-
        No such notification for THIS caller. A notification belonging to
        someone else is reported identically, so an id cannot be probed.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
        code: { type: string }
        details: { type: object }

    NotificationCategory:
      type: string
      enum: [system, billing, security, app, social]

    NotificationSeverity:
      type: string
      enum: [info, success, warning, error]

    NotificationChannel:
      type: string
      enum: [in_app, email, sms, push]
      description: >-
        Only `in_app` is delivered by this service today. The others are recorded
        so the existing email-service / sms-service can consume them without a
        schema change.

    Notification:
      type: object
      additionalProperties: false
      required: [id, type, category, severity, title, data, createdAt]
      properties:
        id: { type: string, format: uuid }
        type:
          type: string
          description: Dotted event type, e.g. `billing.invoice.paid`.
        category: { $ref: '#/components/schemas/NotificationCategory' }
        severity: { $ref: '#/components/schemas/NotificationSeverity' }
        title: { type: string }
        body: { type: [string, 'null'] }
        actionUrl: { type: [string, 'null'] }
        actionLabel: { type: [string, 'null'] }
        data: { type: object }
        organizationId: { type: [string, 'null'], format: uuid }
        appId: { type: [string, 'null'], format: uuid }
        readAt: { type: [string, 'null'], format: date-time }
        seenAt: { type: [string, 'null'], format: date-time }
        createdAt: { type: string, format: date-time }

    NotificationPage:
      type: object
      additionalProperties: false
      required: [notifications, nextCursor]
      properties:
        notifications:
          type: array
          items: { $ref: '#/components/schemas/Notification' }
        nextCursor:
          type: [string, 'null']
          format: date-time
          description: Pass as `cursor` for the next page; null on the last page.

    ScopeBody:
      type: object
      additionalProperties: false
      properties:
        organizationId: { type: string, format: uuid }

    PreferenceUpdate:
      type: object
      additionalProperties: false
      required: [category, channel, enabled]
      properties:
        category: { $ref: '#/components/schemas/NotificationCategory' }
        channel: { $ref: '#/components/schemas/NotificationChannel' }
        enabled: { type: boolean }

    PreferenceMap:
      type: object
      description: category -> channel -> enabled.
      additionalProperties:
        type: object
        additionalProperties: { type: boolean }

    PublishRequest:
      type: object
      additionalProperties: false
      required: [recipients, type, title]
      properties:
        recipients:
          type: array
          minItems: 1
          maxItems: 1000
          items: { type: string, format: uuid }
        type: { type: string, maxLength: 128 }
        title: { type: string, maxLength: 200 }
        body: { type: string, maxLength: 4000 }
        category: { $ref: '#/components/schemas/NotificationCategory' }
        severity: { $ref: '#/components/schemas/NotificationSeverity' }
        organizationId: { type: string, format: uuid }
        appId: { type: string, format: uuid }
        actionUrl: { type: string, maxLength: 500 }
        actionLabel: { type: string, maxLength: 80 }
        data: { type: object }
        dedupeKey:
          type: string
          maxLength: 150
          description: Suffixed per recipient before storage.
        expiresAt: { type: string, format: date-time }
