openapi: 3.1.0
info:
  title: Dash Homelab Backend API
  version: 0.1.0
  license:
    name: Private
    identifier: LicenseRef-Private
servers:
  - url: /
security: []
tags:
  - name: Health
    description: Process and dependency health checks.
  - name: Dashboard
    description: Render-ready dashboard data.
  - name: Groups
    description: Dashboard service groups.
  - name: Services
    description: Service cards and launch URLs.
  - name: Layout
    description: Drag/drop ordering persistence.
  - name: Assets
    description: Uploaded service icon files.
  - name: Widgets
    description: Dashboard widget instances and data.
paths:
  /health:
    get:
      tags: [Health]
      operationId: health
      summary: Check backend health
      responses:
        "200":
          description: Backend and database are healthy.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }
        "503": { $ref: "#/components/responses/InternalError" }
        "400": { $ref: "#/components/responses/ValidationError" }
  /api/v1/dashboard:
    get:
      tags: [Dashboard]
      operationId: getDashboard
      summary: Get dashboard
      responses:
        "200":
          description: Full dashboard in render order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Dashboard" }
        "500": { $ref: "#/components/responses/InternalError" }
        "400": { $ref: "#/components/responses/ValidationError" }
  /api/v1/groups:
    get:
      tags: [Groups]
      operationId: listGroups
      summary: List groups
      responses:
        "200":
          description: Groups in sort order.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Group" }
        "400": { $ref: "#/components/responses/ValidationError" }
    post:
      tags: [Groups]
      operationId: createGroup
      summary: Create group
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateGroupRequest" }
      responses:
        "201":
          description: Created group.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Group" }
        "400": { $ref: "#/components/responses/ValidationError" }
  /api/v1/groups/{groupId}:
    parameters:
      - $ref: "#/components/parameters/GroupId"
    get:
      tags: [Groups]
      operationId: getGroup
      summary: Get group
      responses:
        "200":
          description: Group.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Group" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Groups]
      operationId: patchGroup
      summary: Patch group
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PatchGroupRequest" }
      responses:
        "200":
          description: Updated group.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Group" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Groups]
      operationId: deleteGroup
      summary: Delete group
      parameters:
        - name: moveServicesToUngrouped
          in: query
          schema: { type: boolean, default: false }
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /api/v1/services:
    get:
      tags: [Services]
      operationId: listServices
      summary: List services
      responses:
        "200":
          description: Services with URLs.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Service" }
        "400": { $ref: "#/components/responses/ValidationError" }
    post:
      tags: [Services]
      operationId: createService
      summary: Create service
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ServiceRequest" }
      responses:
        "201":
          description: Created service.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Service" }
        "400": { $ref: "#/components/responses/ValidationError" }
  /api/v1/services/{serviceId}:
    parameters:
      - $ref: "#/components/parameters/ServiceId"
    get:
      tags: [Services]
      operationId: getService
      summary: Get service
      responses:
        "200":
          description: Service.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Service" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Services]
      operationId: patchService
      summary: Patch service
      description: Replaces service fields and URL list atomically.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ServiceRequest" }
      responses:
        "200":
          description: Updated service.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Service" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Services]
      operationId: deleteService
      summary: Delete service
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/layout:
    put:
      tags: [Layout]
      operationId: putLayout
      summary: Update layout
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LayoutRequest" }
      responses:
        "200":
          description: Updated dashboard.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Dashboard" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/assets/icons:
    post:
      tags: [Assets]
      operationId: uploadIcon
      summary: Upload icon
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "201":
          description: Uploaded icon asset.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AssetFile" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "413": { $ref: "#/components/responses/UploadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
  /api/v1/widgets:
    get:
      tags: [Widgets]
      operationId: listWidgets
      summary: List widgets
      responses:
        "200":
          description: Widget instances.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/WidgetInstance" }
        "400": { $ref: "#/components/responses/ValidationError" }
    post:
      tags: [Widgets]
      operationId: createWidget
      summary: Create widget
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WidgetRequest" }
      responses:
        "201":
          description: Created widget.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WidgetInstance" }
        "400": { $ref: "#/components/responses/ValidationError" }
  /api/v1/widgets/{widgetId}:
    parameters:
      - $ref: "#/components/parameters/WidgetId"
    get:
      tags: [Widgets]
      operationId: getWidget
      summary: Get widget
      responses:
        "200":
          description: Widget.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WidgetInstance" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Widgets]
      operationId: patchWidget
      summary: Patch widget
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WidgetRequest" }
      responses:
        "200":
          description: Updated widget.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WidgetInstance" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Widgets]
      operationId: deleteWidget
      summary: Delete widget
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/widgets/{widgetId}/data:
    parameters:
      - $ref: "#/components/parameters/WidgetId"
    get:
      tags: [Widgets]
      operationId: getWidgetData
      summary: Get widget data
      responses:
        "200":
          description: Cached or refreshed widget data.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WidgetData" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/widgets/{widgetId}/refresh:
    parameters:
      - $ref: "#/components/parameters/WidgetId"
    post:
      tags: [Widgets]
      operationId: refreshWidget
      summary: Refresh widget
      responses:
        "200":
          description: Refreshed widget data.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WidgetData" }
        "404": { $ref: "#/components/responses/NotFound" }
components:
  parameters:
    GroupId:
      name: groupId
      in: path
      required: true
      schema: { type: string, format: uuid }
    ServiceId:
      name: serviceId
      in: path
      required: true
      schema: { type: string, format: uuid }
    WidgetId:
      name: widgetId
      in: path
      required: true
      schema: { type: string, format: uuid }
  responses:
    ValidationError:
      description: Validation error.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Conflict:
      description: Operation conflicts with current state.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    UploadTooLarge:
      description: Uploaded file is too large.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    UnsupportedMediaType:
      description: Uploaded file type is unsupported.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    InternalError:
      description: Internal server error.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
  schemas:
    Dashboard:
      type: object
      required: [groups, ungroupedServices, widgets]
      properties:
        groups:
          type: array
          items: { $ref: "#/components/schemas/Group" }
        ungroupedServices:
          type: array
          items: { $ref: "#/components/schemas/Service" }
        widgets:
          type: array
          items: { $ref: "#/components/schemas/WidgetInstance" }
    Group:
      type: object
      required: [id, name, sortOrder, collapsed, services, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        name: { type: string, minLength: 1, maxLength: 80 }
        sortOrder: { type: integer }
        collapsed: { type: boolean }
        services:
          type: array
          items: { $ref: "#/components/schemas/Service" }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    Service:
      type: object
      required: [id, groupId, name, iconUrl, iconAssetId, sortOrder, urls, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        groupId: { type: [string, "null"], format: uuid }
        name: { type: string, minLength: 1, maxLength: 80 }
        iconUrl: { type: [string, "null"], format: uri }
        iconAssetId: { type: [string, "null"], format: uuid }
        sortOrder: { type: integer }
        urls:
          type: array
          items: { $ref: "#/components/schemas/ServiceUrl" }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    ServiceUrl:
      type: object
      required: [id, label, kind, url, sortOrder, isPrimary]
      properties:
        id: { type: string, format: uuid }
        label: { type: string, minLength: 1, maxLength: 40 }
        kind: { type: string, enum: [local, external, custom] }
        url: { type: string, format: uri }
        sortOrder: { type: integer }
        isPrimary: { type: boolean }
    WidgetInstance:
      type: object
      required: [id, type, title, enabled, sortOrder, config, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        type: { type: string, enum: [clock, image, pihole, memos, immich] }
        title: { type: string, minLength: 1, maxLength: 80 }
        enabled: { type: boolean }
        sortOrder: { type: integer }
        config: { type: object, additionalProperties: true, description: "Pi-hole apiToken is masked in responses." }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    WidgetData:
      type: object
      required: [widgetId, status]
      properties:
        widgetId: { type: string, format: uuid }
        status: { type: string, enum: [fresh, stale, error] }
        data: { type: object, additionalProperties: true }
        error: { type: [string, "null"] }
        fetchedAt: { type: [string, "null"], format: date-time }
        expiresAt: { type: [string, "null"], format: date-time }
    AssetFile:
      type: object
      required: [id, originalName, storedName, mimeType, sizeBytes, publicPath, createdAt]
      properties:
        id: { type: string, format: uuid }
        originalName: { type: string }
        storedName: { type: string }
        mimeType: { type: string }
        sizeBytes: { type: integer }
        publicPath: { type: string }
        createdAt: { type: string, format: date-time }
    CreateGroupRequest:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 80 }
    PatchGroupRequest:
      type: object
      properties:
        name: { type: string, minLength: 1, maxLength: 80 }
        collapsed: { type: boolean }
    ServiceRequest:
      type: object
      required: [name, urls]
      properties:
        groupId: { type: [string, "null"], format: uuid }
        name: { type: string, minLength: 1, maxLength: 80 }
        iconUrl: { type: [string, "null"], format: uri }
        iconAssetId: { type: [string, "null"], format: uuid }
        urls:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/ServiceUrlInput" }
    ServiceUrlInput:
      type: object
      required: [label, kind, url]
      properties:
        id: { type: string, format: uuid }
        label: { type: string, minLength: 1, maxLength: 40 }
        kind: { type: string, enum: [local, external, custom] }
        url: { type: string, format: uri }
        isPrimary: { type: boolean, default: false }
    LayoutRequest:
      type: object
      required: [groupIds, widgetIds, ungroupedServiceIds, groupServices]
      properties:
        groupIds:
          type: array
          items: { type: string, format: uuid }
        widgetIds:
          type: array
          items: { type: string, format: uuid }
        ungroupedServiceIds:
          type: array
          items: { type: string, format: uuid }
        groupServices:
          type: object
          additionalProperties:
            type: array
            items: { type: string, format: uuid }
    WidgetRequest:
      type: object
      required: [type, title, config]
      properties:
        type: { type: string, enum: [clock, image, pihole, memos, immich] }
        title: { type: string, minLength: 1, maxLength: 80 }
        enabled: { type: boolean, default: true }
        config:
          oneOf:
            - $ref: "#/components/schemas/ClockWidgetConfig"
            - $ref: "#/components/schemas/ImageWidgetConfig"
            - $ref: "#/components/schemas/PiHoleWidgetConfig"
            - $ref: "#/components/schemas/MemosWidgetConfig"
            - $ref: "#/components/schemas/ImmichWidgetConfig"
    ClockWidgetConfig:
      type: object
      properties:
        timezones:
          type: array
          items: { type: string }
    ImageWidgetConfig:
      type: object
      required: [imageUrl]
      properties:
        imageUrl: { type: string, format: uri }
        linkUrl: { type: [string, "null"], format: uri }
    PiHoleWidgetConfig:
      type: object
      required: [baseUrl, apiToken]
      properties:
        baseUrl: { type: string, format: uri }
        apiToken: { type: string, writeOnly: true }
    MemosWidgetConfig:
      type: object
      required: [baseUrl, apiToken]
      properties:
        baseUrl: { type: string, format: uri }
        apiToken: { type: string, writeOnly: true }
        pageSize: { type: integer, default: 5 }
    ImmichWidgetConfig:
      type: object
      required: [baseUrl, apiKey]
      properties:
        baseUrl: { type: string, format: uri }
        apiKey: { type: string, writeOnly: true }
    ErrorResponse:
      type: object
      required: [code, message, details]
      properties:
        code:
          type: string
          enum:
            - validation_error
            - not_found
            - conflict
            - upload_too_large
            - unsupported_media_type
            - widget_fetch_failed
            - internal_error
        message: { type: string }
        details: { type: ["object", "null"], additionalProperties: true }
