openapi: 3.0.3
info:
  title: Easerix Croncrunch API
  description: |
    Time tracking (backend/services/products/croncrunch): clients, projects,
    time entries, a server-held running timer, and report summaries.
    Responses are camelCase, hand-built DTOs. All /v1 routes require a bearer
    JWT with org claims and the "croncrunch" tool enabled (authkit).
    Authored from the shipping Gin handlers — this spec records reality.
  version: 1.0.0
  contact:
    name: Perizer Labs
    url: https://easerix.com
servers:
  - url: https://api.easerix.com/croncrunch
    description: Production
tags:
  - name: system
    description: Health and liveness
  - name: clients
    description: Billing clients
  - name: projects
    description: Projects time is tracked against
  - name: entries
    description: Time entries
  - name: timer
    description: The single server-held running timer
  - name: reports
    description: Aggregated reporting
  - name: monitoring
    description: >-
      Opt-in desktop monitoring: per-user capture settings, activity events
      (foreground app/window), and screenshots. Everything is disabled until
      the user turns it on; enabling records consentAt.

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/clients:
    get:
      operationId: listClients
      tags: [clients]
      summary: List clients
      description: >-
        Clients the caller can read: their own (active-org-scoped; legacy rows
        without org_id stay visible) plus any shared with the organization by a
        teammate. Ordered by name ascending.
      parameters:
        - name: scope
          in: query
          description: >-
            all (default) — everything readable · mine — only the caller's own
            · shared — only teammates' org-shared clients (excludes own).
          schema:
            type: string
            enum: [all, mine, shared]
        - name: crm_company_id
          in: query
          description: Only clients linked to this CRM company (the CRM company page reads the linkage this way).
          schema:
            type: string
      responses:
        "200":
          description: Clients
          content:
            application/json:
              schema:
                type: object
                required: [clients]
                properties:
                  clients:
                    type: array
                    items: { $ref: "#/components/schemas/Client" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    post:
      operationId: createClient
      tags: [clients]
      summary: Create a client
      description: Creates a client. Color defaults to "#c53a17" when omitted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                color: { type: string }
      responses:
        "201":
          description: Client created
          content:
            application/json:
              schema:
                type: object
                required: [client]
                properties:
                  client: { $ref: "#/components/schemas/Client" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/clients/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    patch:
      operationId: updateClient
      tags: [clients]
      summary: Rename, recolor, or share a client
      description: >-
        Owner-only. A client a teammate shared with the organization is
        readable but not editable — it answers 403, not 404.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                color: { type: string }
                visibility: { type: string, enum: [private, org] }
                crmCompanyId:
                  type: string
                  description: Link to a CRM company id; "" clears the link.
      responses:
        "200":
          description: Updated client
          content:
            application/json:
              schema:
                type: object
                required: [client]
                properties:
                  client: { $ref: "#/components/schemas/Client" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deleteClient
      tags: [clients]
      summary: Delete a client
      description: >-
        Owner-only. A client owned by nobody the caller can see reads as not
        found; one shared with them by a teammate answers 403.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/projects:
    get:
      operationId: listProjects
      tags: [projects]
      summary: List projects
      description: >-
        Projects the caller can read: their own (active-org-scoped; legacy rows
        without org_id stay visible) plus any shared with the organization by a
        teammate. Ordered by name ascending, with client names resolved.
      parameters:
        - name: scope
          in: query
          description: >-
            all (default) — everything readable · mine — only the caller's own
            · shared — only teammates' org-shared projects (excludes own).
          schema:
            type: string
            enum: [all, mine, shared]
      responses:
        "200":
          description: Projects
          content:
            application/json:
              schema:
                type: object
                required: [projects]
                properties:
                  projects:
                    type: array
                    items: { $ref: "#/components/schemas/Project" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    post:
      operationId: createProject
      tags: [projects]
      summary: Create a project
      description: >-
        Creates a project. Defaults: color "#16233f", billable true. clientId is
        optional and not validated against ownership.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                color: { type: string }
                clientId:
                  type: string
                  nullable: true
                hourlyRate: { type: number }
                billable: { type: boolean }
                budgetSeconds:
                  type: integer
                  description: Optional all-time hours budget; values <= 0 mean no budget
      responses:
        "201":
          description: Project created
          content:
            application/json:
              schema:
                type: object
                required: [project]
                properties:
                  project: { $ref: "#/components/schemas/Project" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/projects/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    patch:
      operationId: updateProject
      tags: [projects]
      summary: Edit or share a project
      description: >-
        Owner-only. A project a teammate shared with the organization can be
        read and logged against, but not edited — it answers 403, not 404.
        Sending clientId as an empty string clears the client.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                color: { type: string }
                clientId:
                  type: string
                  nullable: true
                hourlyRate: { type: number }
                billable: { type: boolean }
                visibility: { type: string, enum: [private, org] }
                budgetSeconds:
                  type: integer
                  description: All-time hours budget; 0 (or negative) clears it
      responses:
        "200":
          description: Updated project
          content:
            application/json:
              schema:
                type: object
                required: [project]
                properties:
                  project: { $ref: "#/components/schemas/Project" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deleteProject
      tags: [projects]
      summary: Delete a project
      description: >-
        Owner-only. A project shared with the caller by a teammate answers 403.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/time-entries:
    get:
      operationId: listEntries
      tags: [entries]
      summary: List time entries
      description: >-
        The caller's OWN entries only — sharing a project never exposes anyone's
        hours, so there is no scope lens here. Ordered started_at descending,
        hard limit 500. from/to filter on started_at; tagId filters to entries
        carrying that tag.
      parameters:
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: projectId, in: query, schema: { type: string } }
        - { name: tagId, in: query, schema: { type: string } }
      responses:
        "200":
          description: Entries (max 500)
          content:
            application/json:
              schema:
                type: object
                required: [entries]
                properties:
                  entries:
                    type: array
                    maxItems: 500
                    items: { $ref: "#/components/schemas/TimeEntry" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    post:
      operationId: createEntry
      tags: [entries]
      summary: Log a time entry
      description: >-
        Logs time against any project the caller can read — their own or one
        shared with the organization by a teammate. The entry belongs to the
        caller and stays private to them. startedAt defaults to now. With
        endedAt, duration is derived; with only durationSeconds,
        endedAt = startedAt + duration. One of endedAt or durationSeconds is
        required (timers start via /v1/timer/start). Timestamps are strict
        RFC3339. isBillable defaults true.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [projectId]
              properties:
                projectId: { type: string }
                description: { type: string }
                startedAt: { type: string, format: date-time }
                endedAt: { type: string, format: date-time }
                durationSeconds: { type: integer }
                isBillable: { type: boolean }
                tagIds:
                  type: array
                  items: { type: string }
                  description: Tags to attach; every id must be the caller's
      responses:
        "201":
          description: Entry created
          content:
            application/json:
              schema:
                type: object
                required: [entry]
                properties:
                  entry: { $ref: "#/components/schemas/TimeEntry" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/time-entries/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    patch:
      operationId: updateEntry
      tags: [entries]
      summary: Update an entry
      description: >-
        Partial update. projectId must be readable by the caller (their own or
        an org-shared project). startedAt/endedAt
        are strict RFC3339; durationSeconds recomputes endedAt from startedAt
        (rejected on a running timer). endedAt must not precede startedAt;
        duration_seconds is recomputed whenever times change.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                description: { type: string }
                projectId: { type: string }
                isBillable: { type: boolean }
                startedAt: { type: string, format: date-time }
                endedAt: { type: string, format: date-time }
                durationSeconds: { type: integer }
                tagIds:
                  type: array
                  items: { type: string }
                  description: Replaces the entry's tag set; [] clears it
      responses:
        "200":
          description: Updated entry
          content:
            application/json:
              schema:
                type: object
                required: [entry]
                properties:
                  entry: { $ref: "#/components/schemas/TimeEntry" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteEntry
      tags: [entries]
      summary: Delete an entry
      description: Owner-scoped delete.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/timer:
    get:
      operationId: getTimer
      tags: [timer]
      summary: The running timer
      description: The caller's running entry with a live durationSeconds, or null when nothing is running.
      responses:
        "200":
          description: Running timer or null
          content:
            application/json:
              schema:
                type: object
                required: [timer]
                properties:
                  timer:
                    allOf: [{ $ref: "#/components/schemas/TimeEntry" }]
                    nullable: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }

  /v1/timer/start:
    post:
      operationId: startTimer
      tags: [timer]
      summary: Start the timer
      description: >-
        Stops any running timer first, then starts a new running entry on any
        project the caller can read — their own or one shared with the
        organization. Responds 200 (not 201) with the running entry, which
        belongs to the caller.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [projectId]
              properties:
                projectId: { type: string }
                description: { type: string }
      responses:
        "200":
          description: The running entry
          content:
            application/json:
              schema:
                type: object
                required: [timer]
                properties:
                  timer: { $ref: "#/components/schemas/TimeEntry" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/timer/stop:
    post:
      operationId: stopTimer
      tags: [timer]
      summary: Stop the timer
      description: >-
        Stops the running entry and returns it finalized, or entry null when
        nothing was running. An optional endedAt backdates the stop (clamped to
        the entry's start, rejected if in the future) so clients can discard
        trailing idle time.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                endedAt: { type: string, format: date-time }
      responses:
        "200":
          description: The stopped entry or null
          content:
            application/json:
              schema:
                type: object
                required: [entry]
                properties:
                  entry:
                    allOf: [{ $ref: "#/components/schemas/TimeEntry" }]
                    nullable: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }

  /v1/reports/summary:
    get:
      operationId: reportsSummary
      tags: [reports]
      summary: Time summary
      description: >-
        Totals over the caller's OWN completed entries only (running excluded);
        sharing a project never adds a teammate's hours here. amount sums
        seconds/3600 x project hourlyRate over billable entries. byProject and
        byDay arrays are unordered.
      parameters:
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Aggregates
          content:
            application/json:
              schema:
                type: object
                required: [totalSeconds, billableSeconds, amount, byProject, byDay]
                properties:
                  totalSeconds: { type: integer }
                  billableSeconds: { type: integer }
                  amount: { type: number }
                  byProject:
                    type: array
                    items:
                      type: object
                      required: [projectId, name, color, seconds]
                      properties:
                        projectId: { type: string }
                        name: { type: string, description: Empty string for a deleted project }
                        color: { type: string }
                        seconds: { type: integer }
                  byDay:
                    type: array
                    items:
                      type: object
                      required: [date, seconds]
                      properties:
                        date: { type: string, description: "UTC day, YYYY-MM-DD" }
                        seconds: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }

  /v1/tags:
    get:
      operationId: listTags
      tags: [entries]
      summary: List tags
      description: The caller's tags in the org window, ordered by name ascending.
      responses:
        "200":
          description: Tags
          content:
            application/json:
              schema:
                type: object
                required: [tags]
                properties:
                  tags:
                    type: array
                    items: { $ref: "#/components/schemas/Tag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    post:
      operationId: createTag
      tags: [entries]
      summary: Create a tag
      description: >-
        Creates a tag. Names are unique per caller (case-insensitive); color
        defaults to "#5b4a7d".
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                color: { type: string }
      responses:
        "201":
          description: Tag created
          content:
            application/json:
              schema:
                type: object
                required: [tag]
                properties:
                  tag: { $ref: "#/components/schemas/Tag" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/tags/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    patch:
      operationId: updateTag
      tags: [entries]
      summary: Rename or recolor a tag
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                color: { type: string }
      responses:
        "200":
          description: Updated tag
          content:
            application/json:
              schema:
                type: object
                required: [tag]
                properties:
                  tag: { $ref: "#/components/schemas/Tag" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deleteTag
      tags: [entries]
      summary: Delete a tag
      description: Removes the tag and detaches it from every entry.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/preferences:
    get:
      operationId: getPreferences
      tags: [reports]
      summary: Display preferences
      description: >-
        The caller's per-user display/tracking preferences. Defaults when never
        configured: weekStart monday, roundingMinutes 0, currency USD.
      responses:
        "200":
          description: Preferences
          content:
            application/json:
              schema:
                type: object
                required: [preferences]
                properties:
                  preferences: { $ref: "#/components/schemas/UserPreferences" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    put:
      operationId: updatePreferences
      tags: [reports]
      summary: Update display preferences
      description: >-
        Partial upsert. weekStart is monday or sunday; roundingMinutes one of
        0, 5, 6, 10, 15, 30 (display-level rounding); currency a 3-letter ISO
        code. Preferences are per-user, not per-org.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                weekStart: { type: string, enum: [monday, sunday] }
                roundingMinutes: { type: integer, enum: [0, 5, 6, 10, 15, 30] }
                currency: { type: string, minLength: 3, maxLength: 3 }
      responses:
        "200":
          description: Updated preferences
          content:
            application/json:
              schema:
                type: object
                required: [preferences]
                properties:
                  preferences: { $ref: "#/components/schemas/UserPreferences" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/monitoring/settings:
    get:
      operationId: getMonitoringSettings
      tags: [monitoring]
      summary: Monitoring settings
      description: The caller's capture settings. Defaults to everything disabled with a 300s interval when never configured.
      responses:
        "200":
          description: Settings
          content:
            application/json:
              schema:
                type: object
                required: [settings]
                properties:
                  settings: { $ref: "#/components/schemas/MonitoringSettings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    put:
      operationId: updateMonitoringSettings
      tags: [monitoring]
      summary: Update monitoring settings
      description: >-
        Partial upsert. screenshotIntervalSeconds must be 60-1800. consentAt is
        stamped the first time either capture channel is enabled.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                screenshotsEnabled: { type: boolean }
                screenshotIntervalSeconds: { type: integer, minimum: 60, maximum: 1800 }
                activityEnabled: { type: boolean }
      responses:
        "200":
          description: Updated settings
          content:
            application/json:
              schema:
                type: object
                required: [settings]
                properties:
                  settings: { $ref: "#/components/schemas/MonitoringSettings" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/activity/events:
    get:
      operationId: listActivityEvents
      tags: [monitoring]
      summary: List activity events
      description: Events ordered started_at descending, hard limit 2000. from/to filter on started_at.
      parameters:
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Events (max 2000)
          content:
            application/json:
              schema:
                type: object
                required: [events]
                properties:
                  events:
                    type: array
                    maxItems: 2000
                    items: { $ref: "#/components/schemas/ActivityEvent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    post:
      operationId: createActivityEvents
      tags: [monitoring]
      summary: Record activity events
      description: >-
        Batch insert (max 500 per call) from the desktop app. Rejected with 403
        unless the caller has activityEnabled. entryId, when given, must belong
        to the caller. appName is truncated to 200 chars, windowTitle to 500.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [events]
              properties:
                events:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: object
                    required: [startedAt, endedAt]
                    properties:
                      appName: { type: string }
                      windowTitle: { type: string }
                      startedAt: { type: string, format: date-time }
                      endedAt: { type: string, format: date-time }
                      entryId: { type: string, nullable: true }
      responses:
        "201":
          description: Events stored
          content:
            application/json:
              schema:
                type: object
                required: [created]
                properties:
                  created: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/screenshots:
    get:
      operationId: listScreenshots
      tags: [monitoring]
      summary: List screenshots
      description: >-
        Screenshot records ordered captured_at descending, hard limit 500.
        from/to filter on captured_at. url is a presigned GET link valid ~10
        minutes, or null when storage is not configured.
      parameters:
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Screenshots (max 500)
          content:
            application/json:
              schema:
                type: object
                required: [screenshots]
                properties:
                  screenshots:
                    type: array
                    maxItems: 500
                    items: { $ref: "#/components/schemas/Screenshot" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    post:
      operationId: uploadScreenshot
      tags: [monitoring]
      summary: Upload a screenshot
      description: >-
        Multipart upload from the desktop app. Rejected with 403 unless the
        caller has screenshotsEnabled, and 503 when R2 storage is not
        configured. file must be image/jpeg, image/png, or image/webp, at most
        5 MB. capturedAt defaults to now; entryId must belong to the caller.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
                capturedAt: { type: string, format: date-time }
                entryId: { type: string }
      responses:
        "201":
          description: Screenshot stored
          content:
            application/json:
              schema:
                type: object
                required: [screenshot]
                properties:
                  screenshot: { $ref: "#/components/schemas/Screenshot" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }
        "503":
          description: Screenshot storage not configured
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/screenshots/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      operationId: deleteScreenshot
      tags: [monitoring]
      summary: Delete a screenshot
      description: >-
        Owner-scoped delete of the record plus a best-effort delete of the
        stored object. Users can always delete their own captures.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
  schemas:
    Client:
      type: object
      required: [id, name, color, created, visibility, ownerId]
      properties:
        id: { type: string }
        name: { type: string }
        color: { type: string }
        created: { type: string, format: date-time }
        visibility:
          type: string
          description: private = owner only · org = readable by everyone in the organization
        ownerId: { type: string }
        crmCompanyId:
          type: string
          nullable: true
          description: >-
            Optional link to the canonical CRM company (easerix://crm/company/<id>) —
            link, don't merge (docs/crm/RESEARCH.md §3a). Opaque here; CRM enforces
            access at resolve time.
    Project:
      type: object
      required: [id, name, color, hourlyRate, billable, created, visibility, ownerId]
      properties:
        id: { type: string }
        name: { type: string }
        color: { type: string }
        hourlyRate: { type: number }
        billable: { type: boolean }
        clientId: { type: string, nullable: true }
        clientName:
          type: string
          nullable: true
          description: Null when the client is private to another member of the organization
        created: { type: string, format: date-time }
        visibility:
          type: string
          description: >-
            private = owner only · org = readable by everyone in the
            organization, who may log time against it but not edit it
        ownerId: { type: string }
        budgetSeconds:
          type: integer
          nullable: true
          description: Optional all-time hours budget (null = no budget)
    TimeEntry:
      type: object
      required: [id, projectId, description, startedAt, durationSeconds, isRunning, isBillable, billingStatus]
      properties:
        id: { type: string }
        projectId: { type: string }
        projectName: { type: string, nullable: true }
        projectColor: { type: string, nullable: true }
        clientName: { type: string, nullable: true }
        description: { type: string }
        startedAt: { type: string, format: date-time }
        endedAt: { type: string, format: date-time, nullable: true }
        durationSeconds:
          type: integer
          description: Live-computed while running
        isRunning: { type: boolean }
        isBillable: { type: boolean }
        billingStatus:
          type: string
          description: Free text; the service only writes "unbilled"
        tags:
          type: array
          description: >-
            Present on list/create/update responses (always an array there);
            timer endpoints omit it.
          items: { $ref: "#/components/schemas/Tag" }
    Tag:
      type: object
      required: [id, name, color, created]
      properties:
        id: { type: string }
        name: { type: string }
        color: { type: string }
        created: { type: string, format: date-time }
    UserPreferences:
      type: object
      required: [weekStart, roundingMinutes, currency]
      properties:
        weekStart: { type: string, enum: [monday, sunday] }
        roundingMinutes: { type: integer }
        currency: { type: string }
    MonitoringSettings:
      type: object
      required: [screenshotsEnabled, screenshotIntervalSeconds, activityEnabled]
      properties:
        screenshotsEnabled: { type: boolean }
        screenshotIntervalSeconds: { type: integer }
        activityEnabled: { type: boolean }
        consentAt:
          type: string
          format: date-time
          nullable: true
          description: When the user first enabled any capture channel
    ActivityEvent:
      type: object
      required: [id, appName, windowTitle, startedAt, endedAt]
      properties:
        id: { type: string }
        entryId: { type: string, nullable: true }
        appName: { type: string }
        windowTitle: { type: string }
        startedAt: { type: string, format: date-time }
        endedAt: { type: string, format: date-time }
    Screenshot:
      type: object
      required: [id, contentType, sizeBytes, capturedAt]
      properties:
        id: { type: string }
        entryId: { type: string, nullable: true }
        contentType: { type: string }
        sizeBytes: { type: integer }
        capturedAt: { type: string, format: date-time }
        url:
          type: string
          nullable: true
          description: Presigned GET URL valid ~10 minutes, null when storage is disabled
    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/invalid bearer token, or a token without org context
        ("token missing org context — refresh your session")
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ToolDisabled:
      description: The croncrunch tool is turned off for the caller's organization
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: >-
        Either the croncrunch tool is turned off for the caller's organization,
        or the client/project is shared with the caller by a teammate — visible,
        but only its owner can change it.
      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" }
