openapi: 3.0.3
info:
  title: Easerix Notifications API
  description: |
    Platform notifications (backend/services/shared/notifications): the
    device registry, the per-user notification feed, category preferences,
    and a live SSE stream of the unread count.

    Product services do NOT call this service directly. They emit domain
    events to the platform event outbox (backend/packages/eventkit) inside
    their own transactions; the notifications consumer fans those events out
    into per-user feed rows. The feed row in Postgres is the durable
    deliverable; a push is a best-effort doorbell on top of it, so a provider
    outage never loses a notification. With no push provider configured the
    service still records and serves everything and logs what it would send.

    Notifications are a platform capability rather than a tool, so /v1 routes
    require a valid session but no RequireTool gate. Responses are camelCase.
  version: 2.0.0
  contact:
    name: Perizer Labs
    url: https://easerix.com
servers:
  - url: https://notifications-api.easerix.com
    description: Production
tags:
  - name: system
    description: Health and liveness
  - name: devices
    description: Push device registry
  - name: feed
    description: The notification feed
  - name: preferences
    description: Per-category opt-outs

security:
  - bearer: []

paths:
  /health:
    get:
      operationId: health
      tags: [system]
      summary: Liveness probe
      description: Unauthenticated liveness check used by the platform and monitors.
      security: []
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                type: object

  /v1/devices:
    post:
      operationId: registerDevice
      tags: [devices]
      summary: Register this install for push
      description: >-
        Idempotent on the token — apps re-register on every launch. A token
        that reappears under a different user moves to that user rather than
        duplicating, so a shared device never receives the previous
        signed-in user's notifications.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, platform]
              properties:
                token: { type: string }
                platform: { type: string, enum: [ios, android] }
                appVersion: { type: string }
                locale: { type: string }
      responses:
        "200":
          description: Device registered
          content:
            application/json:
              schema:
                type: object
                required: [device]
                properties:
                  device: { $ref: "#/components/schemas/Device" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/devices/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      operationId: deleteDevice
      tags: [devices]
      summary: Stop pushing to this install
      description: Called on sign-out. Owner-scoped.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/notifications:
    get:
      operationId: listNotifications
      tags: [feed]
      summary: The caller's feed
      description: >-
        Org-scoped, newest first, limit 200, with the unread count for the
        badge. Pass unread=true for unread only.
      parameters:
        - name: unread
          in: query
          schema: { type: string, enum: ["true"] }
          description: Unread only when exactly "true"
      responses:
        "200":
          description: Feed
          content:
            application/json:
              schema:
                type: object
                required: [notifications, unread]
                properties:
                  notifications:
                    type: array
                    maxItems: 200
                    items: { $ref: "#/components/schemas/Notification" }
                  unread: { type: integer, format: int64 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/notifications/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    patch:
      operationId: markNotificationRead
      tags: [feed]
      summary: Mark read or unread
      description: Returns the updated notification.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                read: { type: boolean, description: Omitted or false marks unread }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [notification]
                properties:
                  notification: { $ref: "#/components/schemas/Notification" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/notifications/read-all:
    post:
      operationId: readAllNotifications
      tags: [feed]
      summary: Clear the badge
      description: Marks every unread item in the active org read.
      responses:
        "200":
          description: All read
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/preferences:
    get:
      operationId: listPreferences
      tags: [preferences]
      summary: Category preferences
      description: >-
        The full matrix of known categories. Absence of a stored row means
        enabled, so a new category starts on for everyone without a backfill.
      responses:
        "200":
          description: Preferences
          content:
            application/json:
              schema:
                type: object
                required: [preferences]
                properties:
                  preferences:
                    type: array
                    items: { $ref: "#/components/schemas/Preference" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    put:
      operationId: setPreference
      tags: [preferences]
      summary: Toggle one category
      description: Single-category upsert; returns the full matrix.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [category, enabled]
              properties:
                category:
                  type: string
                  enum:
                    [
                      forms.submission,
                      sign.awaiting_you,
                      sign.activity,
                      croncrunch.reminder,
                      tasks.assigned,
                      tasks.comment,
                      notes.comment,
                    ]
                enabled: { type: boolean }
      responses:
        "200":
          description: Full preference matrix after the change
          content:
            application/json:
              schema:
                type: object
                required: [preferences]
                properties:
                  preferences:
                    type: array
                    items: { $ref: "#/components/schemas/Preference" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/stream:
    get:
      operationId: streamNotifications
      tags: [feed]
      summary: Live unread stream (SSE)
      description: >-
        A Server-Sent Events stream of the caller's unread count for the active
        org. The connection emits `data: {"unread": N}` immediately on connect
        and again whenever a new feed row lands, plus `: ping` heartbeat
        comments. The client updates its badge and refetches the feed on each
        data event. One-way and stateless by design — any replica can serve any
        client. Product notifications reach the feed asynchronously via the
        platform event outbox (eventkit), not a direct call to this service.
      responses:
        "200":
          description: An event stream
          content:
            text/event-stream:
              schema:
                type: string
                example: 'data: {"unread": 3}'
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
  schemas:
    Device:
      type: object
      required: [id, platform, appVersion, lastSeenAt, created]
      properties:
        id: { type: string, format: uuid }
        platform: { type: string, enum: [ios, android] }
        appVersion: { type: string }
        lastSeenAt: { type: string, format: date-time }
        created: { type: string, format: date-time }
    Notification:
      type: object
      required: [id, category, title, body, deepLink, entityType, entityId, created]
      properties:
        id: { type: string, format: uuid }
        category: { type: string }
        title: { type: string }
        body: { type: string }
        deepLink: { type: string }
        entityType: { type: string }
        entityId: { type: string }
        readAt: { type: string, format: date-time, nullable: true }
        created: { type: string, format: date-time }
    Preference:
      type: object
      required: [category, enabled]
      properties:
        category: { type: string }
        enabled: { type: boolean }
    Message:
      type: object
      required: [message]
      properties:
        message: { type: string }
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
  responses:
    BadRequest:
      description: Invalid or incomplete request body
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid bearer token
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found or not owned by the caller
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ServerError:
      description: Internal error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
