openapi: 3.1.0

info:
  title: UnitLook Public API
  version: 1.5.0
  description: |
    REST API for integrating with UnitLook — ticketing, projects, work logs, and more.

    ## Authentication

    All requests require HTTP Basic Auth using UnitLook application credentials.
    The API resolves your tenant and user context automatically — you never need to pass
    internal identifiers like `TenantId` or `AppUserId` in request bodies.

    ```
    Authorization: Basic base64(email:password)
    ```

    ## Errors

    Errors are returned as a JSON object with an `error` field:

    ```json
    { "error": "Ticket title must not be empty." }
    ```

  contact:
    name: UnitLook Support
    url: https://unitlook.com
  license:
    name: Proprietary

servers:
  - url: https://api.unitlook.com/api/v1
    description: Production
  - url: https://api-staging.unitlook.com/api/v1
    description: Staging

security:
  - basicAuth: []

tags:
  - name: Tickets
    description: Create and manage support tickets.
  - name: Ticket Thread
    description: Read and write ticket comments and workflow actions.
  - name: Projects
    description: Create and manage projects.
  - name: Project Members
    description: Manage project team membership.
  - name: Work Logs
    description: Log and retrieve time entries for tickets and projects.
  - name: Planning
    description: Schedule and manage planned work assignments.

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------

paths:

  # ── Tickets ──────────────────────────────────────────────────────────────

  /tickets:
    get:
      operationId: listTickets
      tags: [Tickets]
      summary: List tickets
      description: |
        Returns a filtered list of tickets visible to the authenticated user.

        Employees see all tickets for their tenant.
        Client users see only tickets where they are the partner owner or a participant.
      parameters:
        - $ref: '#/components/parameters/search'
        - $ref: '#/components/parameters/open'
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/partnerId'
        - $ref: '#/components/parameters/assignedTo'
        - $ref: '#/components/parameters/onlyMine'
        - $ref: '#/components/parameters/dateFrom'
        - $ref: '#/components/parameters/dateTo'
      responses:
        '200':
          description: List of tickets.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TicketSummary'
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      operationId: createTicket
      tags: [Tickets]
      summary: Create ticket
      description: |
        Creates a new ticket and opens its thread.

        A sequential ticket number (e.g. `DEMO-01043`) is generated automatically.
        An email notification is sent to all participants.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTicketRequest'
      responses:
        '201':
          description: Ticket created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketCreatedResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /tickets/{id}:
    parameters:
      - $ref: '#/components/parameters/ticketId'

    get:
      operationId: getTicket
      tags: [Tickets]
      summary: Get ticket
      description: Returns full details for a single ticket. Returns 404 if not found or not accessible.
      responses:
        '200':
          description: Ticket details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      operationId: updateTicket
      tags: [Tickets]
      summary: Update ticket
      description: |
        Updates ticket metadata — title, category, priority, project, assignment, due date.

        Status changes are not supported here — use `POST /tickets/{id}/comments` with
        a workflow action instead.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateTicketRequest'
      responses:
        '200':
          description: Ticket updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketUpdatedResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # ── Ticket Thread ─────────────────────────────────────────────────────────

  /tickets/{id}/comments:
    parameters:
      - $ref: '#/components/parameters/ticketId'

    get:
      operationId: getTicketThread
      tags: [Ticket Thread]
      summary: Get ticket thread
      description: |
        Returns the full chronological thread — all comments, workflow actions,
        and system events. Internal comments are visible only to employees.
      responses:
        '200':
          description: Thread entries.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ThreadEntry'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      operationId: addComment
      tags: [Ticket Thread]
      summary: Add comment or action
      description: |
        Adds a message or triggers a workflow action on the ticket.

        Use `action: "comment"` for a plain message. Use other action codes to
        drive the ticket lifecycle (resolve, assign, close, etc.).

        An email notification is sent to all participants with `receiveEmail: true`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddCommentRequest'
      responses:
        '201':
          description: Comment added.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CommentAddedResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

        '404':
          $ref: '#/components/responses/NotFound'

  /tickets/{id}/work-logs:
    parameters:
      - $ref: '#/components/parameters/ticketId'
    get:
      operationId: listTicketWorkLogs
      tags: [Work Logs]
      summary: List work logs for a ticket
      description: Returns all time entries logged against a specific ticket (all employees).
      responses:
        '200':
          description: Work log entries for the ticket.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TicketWorkLogEntry'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # ── Work Logs ────────────────────────────────────────────────────────────

  /work-logs:
    get:
      operationId: listWorkLogs
      tags: [Work Logs]
      summary: List my work logs
      description: Returns time entries for the authenticated user within a date range.
      parameters:
        - name: dateFrom
          in: query
          required: true
          schema:
            type: string
            format: date
            example: "2025-06-01"
        - name: dateTo
          in: query
          required: true
          schema:
            type: string
            format: date
            example: "2025-06-30"
      responses:
        '200':
          description: List of work log entries.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/WorkLogEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      operationId: createWorkLog
      tags: [Work Logs]
      summary: Create work log entry
      description: Logs time against a ticket and/or project. Returns 400 if the entry period is locked.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWorkLogRequest'
      responses:
        '201':
          description: Work log created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkLogEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /work-logs/summary:
    get:
      operationId: getWorkLogSummary
      tags: [Work Logs]
      summary: Daily hours summary
      description: Returns total logged hours per day for the authenticated user in the given range.
      parameters:
        - name: dateFrom
          in: query
          required: true
          schema:
            type: string
            format: date
        - name: dateTo
          in: query
          required: true
          schema:
            type: string
            format: date
      responses:
        '200':
          description: Daily totals.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/WorkLogDaySummary'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /work-logs/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Work log entry ID.
        schema:
          type: integer

    patch:
      operationId: updateWorkLog
      tags: [Work Logs]
      summary: Update work log entry
      description: Updates an existing time entry. Locked entries cannot be modified.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateWorkLogRequest'
      responses:
        '200':
          description: Work log updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkLogEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      operationId: deleteWorkLog
      tags: [Work Logs]
      summary: Delete work log entry
      description: Soft-deletes a time entry. Locked entries cannot be deleted.
      responses:
        '200':
          description: Work log deleted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkLogDeletedResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

        '404':
          $ref: '#/components/responses/NotFound'

  # ── Planning ─────────────────────────────────────────────────────────────

  /plans:
    get:
      operationId: listPlans
      tags: [Planning]
      summary: List planned assignments
      description: |
        Returns planned work blocks for one or more employees in a date range.

        If `userIds` is omitted, returns plans for the authenticated user only.
        Managers with planning permissions can pass comma-separated employee IDs.
      parameters:
        - name: dateFrom
          in: query
          required: true
          schema:
            type: string
            format: date
            example: "2025-06-01"
        - name: dateTo
          in: query
          required: true
          schema:
            type: string
            format: date
            example: "2025-06-30"
        - name: userIds
          in: query
          description: Comma-separated employee user IDs, e.g. `1,5,12`. Defaults to the authenticated user.
          schema:
            type: string
            example: "1,5,12"
      responses:
        '200':
          description: Planned assignments.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PlanEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      operationId: createPlan
      tags: [Planning]
      summary: Create plan entry
      description: Creates a new planned work block for an employee.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePlanRequest'
      responses:
        '201':
          description: Plan created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlanCreatedResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /plans/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Plan entry ID.
        schema:
          type: integer

    get:
      operationId: getPlan
      tags: [Planning]
      summary: Get plan entry
      description: Returns a single planned assignment by ID.
      responses:
        '200':
          description: Plan details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlanEntry'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      operationId: updatePlan
      tags: [Planning]
      summary: Update plan entry
      description: Updates title, description, or time range. Only the creator or assigned employee can modify.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdatePlanRequest'
      responses:
        '200':
          description: Plan updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlanUpdatedResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      operationId: deletePlan
      tags: [Planning]
      summary: Delete plan entry
      description: Soft-deletes a planned assignment. Only the creator or assigned employee can delete.
      responses:
        '200':
          description: Plan deleted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlanDeletedResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # ── Projects ─────────────────────────────────────────────────────────────

  /projects:
    get:
      operationId: listProjects
      tags: [Projects]
      summary: List projects
      description: Returns a filtered list of projects visible to the authenticated user.
      parameters:
        - name: search
          in: query
          description: Full-text search in name and description.
          schema:
            type: string
        - name: open
          in: query
          description: "`true` = open projects only. `false` = closed/terminal only. Omit for all."
          schema:
            type: boolean
        - name: partnerId
          in: query
          description: Filter by client/partner ID.
          schema:
            type: integer
        - name: projectTypeId
          in: query
          description: Filter by project type ID.
          schema:
            type: integer
        - name: onlyMine
          in: query
          description: "`true` = only projects where I am a team member."
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: List of projects.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ProjectSummary'
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      operationId: createProject
      tags: [Projects]
      summary: Create project
      description: Creates a new project. The creator is added to the team by default.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateProjectRequest'
      responses:
        '201':
          description: Project created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectCreatedResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /projects/{id}:
    parameters:
      - $ref: '#/components/parameters/projectPathId'

    get:
      operationId: getProject
      tags: [Projects]
      summary: Get project
      description: Returns full details for a single project.
      responses:
        '200':
          description: Project details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      operationId: updateProject
      tags: [Projects]
      summary: Update project
      description: Updates project metadata including status.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateProjectRequest'
      responses:
        '200':
          description: Project updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectUpdatedResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      operationId: deleteProject
      tags: [Projects]
      summary: Delete project
      description: Soft-deletes a project. The project is marked as deleted, not physically removed.
      responses:
        '200':
          description: Project deleted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectDeletedResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /projects/{id}/members:
    parameters:
      - $ref: '#/components/parameters/projectPathId'

    get:
      operationId: listProjectMembers
      tags: [Project Members]
      summary: List project members
      description: Returns all team members for the project, including inactive members.
      responses:
        '200':
          description: Project team members.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ProjectMember'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      operationId: addProjectMember
      tags: [Project Members]
      summary: Add project member
      description: Adds an employee to the project team, or reactivates an inactive member.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddProjectMemberRequest'
      responses:
        '201':
          description: Member added.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectMemberAddedResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /projects/{id}/members/{userId}:
    parameters:
      - $ref: '#/components/parameters/projectPathId'
      - name: userId
        in: path
        required: true
        description: Employee user ID to remove from the project team.
        schema:
          type: integer

    delete:
      operationId: removeProjectMember
      tags: [Project Members]
      summary: Remove project member
      description: Soft-removes a member from the project team (sets inactive).
      responses:
        '200':
          description: Member removed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectMemberRemovedResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

# ---------------------------------------------------------------------------
# Components
# ---------------------------------------------------------------------------

components:

  securitySchemes:
    basicAuth:
      type: http
      scheme: basic
      description: UnitLook application credentials (email + password).

  # ── Reusable parameters ───────────────────────────────────────────────────

  parameters:
    ticketId:
      name: id
      in: path
      required: true
      description: Ticket ID.
      schema:
        type: integer

    projectPathId:
      name: id
      in: path
      required: true
      description: Project ID.
      schema:
        type: integer

    search:
      name: search
      in: query
      description: Full-text search in title, description, and ticket number.
      schema:
        type: string

    open:
      name: open
      in: query
      description: "`true` = open tickets only. `false` = closed/cancelled only. Omit for all."
      schema:
        type: boolean

    projectId:
      name: projectId
      in: query
      description: Filter by project ID.
      schema:
        type: integer

    partnerId:
      name: partnerId
      in: query
      description: Filter by client/partner ID.
      schema:
        type: integer

    assignedTo:
      name: assignedTo
      in: query
      description: Filter by assigned employee ID.
      schema:
        type: integer

    onlyMine:
      name: onlyMine
      in: query
      description: "`true` = only tickets where I am holder or owner."
      schema:
        type: boolean
        default: false

    dateFrom:
      name: dateFrom
      in: query
      description: Created-at filter, from date (ISO 8601).
      schema:
        type: string
        format: date
        example: "2025-01-01"

    dateTo:
      name: dateTo
      in: query
      description: Created-at filter, to date (ISO 8601).
      schema:
        type: string
        format: date
        example: "2025-12-31"

  # ── Schemas ───────────────────────────────────────────────────────────────

  schemas:

    # Request schemas

    CreateTicketRequest:
      type: object
      required: [title]
      properties:
        title:
          type: string
          maxLength: 500
          description: Ticket title.
          example: "Invoice import fails on special characters"
        description:
          type: string
          nullable: true
          description: Opening message body (plain text or HTML).
          example: "When importing files with ä, ö, ü the job throws a 500 error."
        kind:
          type: string
          enum: [client, internal]
          default: client
          description: "`client` = visible to the client, `internal` = employees only."
        projectId:
          type: integer
          nullable: true
          description: Project to associate the ticket with.
        partnerId:
          type: integer
          nullable: true
          description: Client/partner this ticket belongs to.
        partnerContactUserId:
          type: integer
          nullable: true
          description: "Partner-side contact user ID. **Required for `kind: client`**."
        assignedToUserId:
          type: integer
          nullable: true
          description: Employee to assign the ticket to.
        priority:
          type: string
          enum: [low, normal, high, critical]
          nullable: true
          description: Ticket priority.
        dueDate:
          type: string
          format: date
          nullable: true
          description: Target resolution date (ISO 8601).
          example: "2025-07-01"
        estimatedHours:
          type: number
          format: float
          nullable: true
          description: Estimated effort in hours.
          example: 4.0

    UpdateTicketRequest:
      type: object
      required: [title]
      properties:
        title:
          type: string
          maxLength: 500
        projectId:
          type: integer
          nullable: true
        partnerId:
          type: integer
          nullable: true
        partnerContactUserId:
          type: integer
          nullable: true
        assignedToUserId:
          type: integer
          nullable: true
        priority:
          type: string
          enum: [low, normal, high, critical]
          nullable: true
        dueDate:
          type: string
          format: date
          nullable: true
        estimatedHours:
          type: number
          format: float
          nullable: true

    AddCommentRequest:
      type: object
      required: [action]
      properties:
        action:
          type: string
          enum: [comment, resolve, confirm, reject, close, cancel, reopen, assign]
          description: |
            Workflow action to perform.

            | Action    | Effect |
            |-----------|--------|
            | `comment` | Plain message, no status change. |
            | `resolve` | Mark as resolved, send to client for confirmation. |
            | `confirm` | Client confirms resolution. |
            | `reject`  | Client rejects resolution, ticket returns to employee. |
            | `close`   | Admin closes confirmed ticket. Terminal. |
            | `cancel`  | Admin cancels ticket. Terminal. |
            | `reopen`  | Reopen from any terminal status. |
            | `assign`  | Reassign to a different employee. Requires `assignToUserId`. |
        body:
          type: string
          nullable: true
          description: Message text. Optional for pure workflow actions (e.g. close).
          example: "Fixed in deployment 2.4.1."
        internal:
          type: boolean
          default: false
          description: "`true` = visible to employees only."
        assignToUserId:
          type: integer
          nullable: true
          description: "New assigned employee. Required for `action: assign`."

    # Response schemas

    TicketSummary:
      type: object
      properties:
        id:
          type: integer
        number:
          type: string
          description: Human-readable ticket number.
          example: "DEMO-01042"
        title:
          type: string
        status:
          $ref: '#/components/schemas/StatusRef'
        priority:
          $ref: '#/components/schemas/PriorityRef'
          nullable: true
        kind:
          type: string
          enum: [client, internal]
        project:
          $ref: '#/components/schemas/NamedRef'
          nullable: true
        partner:
          $ref: '#/components/schemas/NamedRef'
          nullable: true
        assignedTo:
          $ref: '#/components/schemas/UserRef'
          nullable: true
        estimatedHours:
          type: number
          nullable: true
        dueDate:
          type: string
          format: date
          nullable: true
        commentCount:
          type: integer
        createdAt:
          type: string
          format: date-time
        modifiedAt:
          type: string
          format: date-time
          nullable: true

    TicketDetail:
      allOf:
        - $ref: '#/components/schemas/TicketSummary'
        - type: object
          properties:
            description:
              type: string
              nullable: true
              description: Opening message body.
            partnerContact:
              $ref: '#/components/schemas/UserRef'
              nullable: true
            internalOwner:
              $ref: '#/components/schemas/UserRef'
              nullable: true

    TicketCreatedResponse:
      type: object
      properties:
        id:
          type: integer
        number:
          type: string
          example: "DEMO-01043"
        status:
          type: string
          example: "new"
        createdAt:
          type: string
          format: date-time

    TicketUpdatedResponse:
      type: object
      properties:
        id:
          type: integer
        number:
          type: string
        modifiedAt:
          type: string
          format: date-time

    ThreadEntry:
      type: object
      properties:
        id:
          type: integer
        action:
          type: string
          enum: [open, comment, assign, resolve, confirm, reject, close, cancel, reopen, edit]
        actionType:
          type: string
          enum: [comment, workflow, system]
        body:
          type: string
          nullable: true
        internal:
          type: boolean
        resultingStatus:
          type: string
          nullable: true
          description: New ticket status code after this action, if it changed.
        assignedTo:
          $ref: '#/components/schemas/UserRef'
          nullable: true
          description: New assigned employee (for `assign` action).
        author:
          $ref: '#/components/schemas/UserRef'
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/AttachmentRef'
        createdAt:
          type: string
          format: date-time

    CommentAddedResponse:
      type: object
      properties:
        id:
          type: integer
        ticketId:
          type: integer
        action:
          type: string
        resultingStatus:
          type: string
          nullable: true
        createdAt:
          type: string
          format: date-time

    CreateProjectRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          maxLength: 300
          example: "ERP Integration 2025"
        code:
          type: string
          maxLength: 20
          nullable: true
          description: Short project code, unique within tenant.
          example: "ERP-2025"
        description:
          type: string
          nullable: true
        projectTypeId:
          type: integer
          nullable: true
        partnerId:
          type: integer
          nullable: true
          description: Client/partner this project belongs to.
        managerUserId:
          type: integer
          nullable: true
          description: Project manager employee ID.
        startDate:
          type: string
          format: date
          nullable: true
        endDate:
          type: string
          format: date
          nullable: true
        budget:
          type: number
          format: float
          nullable: true
        addCreatorAsTeamMember:
          type: boolean
          default: true

    UpdateProjectRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          maxLength: 300
        code:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        projectTypeId:
          type: integer
          nullable: true
        partnerId:
          type: integer
          nullable: true
        managerUserId:
          type: integer
          nullable: true
        statusId:
          type: integer
          nullable: true
          description: Direct status change (projects have no thread workflow).
        startDate:
          type: string
          format: date
          nullable: true
        endDate:
          type: string
          format: date
          nullable: true
        budget:
          type: number
          format: float
          nullable: true

    AddProjectMemberRequest:
      type: object
      required: [userId]
      properties:
        userId:
          type: integer
          description: Employee user ID to add to the project team.
        role:
          type: string
          nullable: true
          description: Project role label, e.g. "Developer", "Project Manager".
          example: "Developer"

    ProjectSummary:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        code:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        status:
          $ref: '#/components/schemas/StatusRef'
        projectType:
          $ref: '#/components/schemas/NamedRef'
          nullable: true
        partner:
          $ref: '#/components/schemas/NamedRef'
          nullable: true
        startDate:
          type: string
          format: date
          nullable: true
        endDate:
          type: string
          format: date
          nullable: true
        budget:
          type: number
          nullable: true
        ticketCount:
          type: integer
        openTicketCount:
          type: integer
        memberCount:
          type: integer
        totalHours:
          type: number
        createdBy:
          $ref: '#/components/schemas/UserRef'
          nullable: true
        createdAt:
          type: string
          format: date-time
        modifiedAt:
          type: string
          format: date-time
          nullable: true

    ProjectDetail:
      allOf:
        - $ref: '#/components/schemas/ProjectSummary'
        - type: object
          properties:
            manager:
              $ref: '#/components/schemas/UserRef'
              nullable: true
            allUsersCanView:
              type: boolean

    ProjectCreatedResponse:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        statusId:
          type: integer
        createdAt:
          type: string
          format: date-time

    ProjectUpdatedResponse:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        statusId:
          type: integer
          nullable: true
        modifiedAt:
          type: string
          format: date-time

    ProjectDeletedResponse:
      type: object
      properties:
        id:
          type: integer
        deleted:
          type: boolean
        deletedAt:
          type: string
          format: date-time

    ProjectMember:
      type: object
      properties:
        id:
          type: integer
          description: Project member record ID.
        userId:
          type: integer
        name:
          type: string
        email:
          type: string
          nullable: true
        role:
          type: string
          nullable: true
        systemRole:
          type: string
          nullable: true
          description: Tenant role code (admin, employee, etc.).
        active:
          type: boolean
        createdAt:
          type: string
          format: date-time

    ProjectMemberAddedResponse:
      type: object
      properties:
        id:
          type: integer
        projectId:
          type: integer
        userId:
          type: integer

    ProjectMemberRemovedResponse:
      type: object
      properties:
        id:
          type: integer
        projectId:
          type: integer
        userId:
          type: integer
        active:
          type: boolean

    CreateWorkLogRequest:
      type: object
      required: [hours]
      properties:
        ticketId:
          type: integer
          nullable: true
          description: Ticket to log time against.
        projectId:
          type: integer
          nullable: true
          description: Project to log time against.
        workDate:
          type: string
          format: date
          description: Date of work. Defaults to today.
          example: "2025-06-10"
        startTime:
          type: string
          description: Start time (HH:mm:ss). Optional if hours is provided.
          example: "09:00:00"
        endTime:
          type: string
          description: End time (HH:mm:ss). Optional if hours is provided.
          example: "11:30:00"
        hours:
          type: number
          format: float
          description: Hours worked. Calculated from start/end if omitted.
          example: 2.5
        description:
          type: string
          nullable: true
          maxLength: 500
        billable:
          type: boolean
          default: true
        workLogTypeId:
          type: integer
          nullable: true
          description: Internal work log type ID (optional).

    UpdateWorkLogRequest:
      type: object
      properties:
        ticketId:
          type: integer
          nullable: true
        projectId:
          type: integer
          nullable: true
        workDate:
          type: string
          format: date
          nullable: true
        startTime:
          type: string
          nullable: true
        endTime:
          type: string
          nullable: true
        hours:
          type: number
          format: float
          nullable: true
        description:
          type: string
          nullable: true
        billable:
          type: boolean
          nullable: true
        workLogTypeId:
          type: integer
          nullable: true

    WorkLogEntry:
      type: object
      properties:
        id:
          type: integer
        userId:
          type: integer
        ticket:
          $ref: '#/components/schemas/NamedRef'
          nullable: true
        ticketNumber:
          type: string
          nullable: true
        partner:
          type: string
          nullable: true
        project:
          $ref: '#/components/schemas/NamedRef'
          nullable: true
        workLogType:
          type: string
          nullable: true
        workDate:
          type: string
          format: date
        startTime:
          type: string
          format: date-time
          nullable: true
        endTime:
          type: string
          format: date-time
          nullable: true
        hours:
          type: number
        description:
          type: string
          nullable: true
        billable:
          type: boolean
        locked:
          type: boolean

    TicketWorkLogEntry:
      type: object
      properties:
        id:
          type: integer
        userId:
          type: integer
        userName:
          type: string
        workDate:
          type: string
          format: date
        hours:
          type: number
        description:
          type: string
          nullable: true
        billable:
          type: boolean
        workLogType:
          type: string
          nullable: true

    WorkLogDaySummary:
      type: object
      properties:
        date:
          type: string
          format: date
        totalHours:
          type: number

    WorkLogDeletedResponse:
      type: object
      properties:
        id:
          type: integer

    CreatePlanRequest:
      type: object
      required: [assignedToUserId, startTime, endTime]
      properties:
        assignedToUserId:
          type: integer
          description: Employee the work is planned for.
        title:
          type: string
          maxLength: 300
          default: "Plan"
          example: "Sprint planning — Client X"
        description:
          type: string
          nullable: true
        startTime:
          type: string
          format: date-time
          description: Start datetime (ISO 8601).
          example: "2025-06-10T09:00:00"
        endTime:
          type: string
          format: date-time
          description: End datetime (ISO 8601).
          example: "2025-06-10T11:00:00"
        ticketId:
          type: integer
          nullable: true
        projectId:
          type: integer
          nullable: true

    UpdatePlanRequest:
      type: object
      properties:
        title:
          type: string
          maxLength: 300
        description:
          type: string
          nullable: true
        startTime:
          type: string
          format: date-time
          nullable: true
        endTime:
          type: string
          format: date-time
          nullable: true

    PlanEntry:
      type: object
      properties:
        id:
          type: integer
        userId:
          type: integer
          description: Employee the plan is assigned to.
        userName:
          type: string
        title:
          type: string
        description:
          type: string
          nullable: true
        startTime:
          type: string
          description: Start datetime.
        endTime:
          type: string
          description: End datetime.
        ticket:
          $ref: '#/components/schemas/NamedRef'
          nullable: true
        partner:
          type: string
          nullable: true
        projectId:
          type: integer
          nullable: true
        plannedHours:
          type: number
        completed:
          type: boolean
        canEdit:
          type: boolean

    PlanCreatedResponse:
      type: object
      properties:
        id:
          type: integer

    PlanUpdatedResponse:
      type: object
      properties:
        id:
          type: integer

    PlanDeletedResponse:
      type: object
      properties:
        id:
          type: integer
        deleted:
          type: boolean

    # Shared reference types

    NamedRef:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string

    UserRef:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
          nullable: true

    StatusRef:
      type: object
      properties:
        code:
          type: string
          enum: [new, open, resolved, confirmed, closed, cancelled]
        name:
          type: string
        color:
          type: string
          nullable: true

    PriorityRef:
      type: object
      properties:
        code:
          type: string
          enum: [low, normal, high, critical]
        name:
          type: string
        color:
          type: string
          nullable: true

    AttachmentRef:
      type: object
      properties:
        id:
          type: integer
        filename:
          type: string
        filesize:
          type: integer
        contentType:
          type: string

    ErrorResponse:
      type: object
      properties:
        error:
          type: string

  # ── Reusable responses ────────────────────────────────────────────────────

  responses:
    BadRequest:
      description: Validation error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unauthorized:
      description: Missing or invalid credentials.
    NotFound:
      description: Resource not found or not accessible.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
