# OpenAPI 3.1 spec for the Projekt API.
#
# Bootstrap set: the ~15 routes that drive the mobile flows in Fase 1
# (auth, /me, projects, issues, comments, time entries, push). Every
# other route in the backend still works — they just aren't typed yet
# and the SDKs won't expose them. As Fase 1 needs new endpoints they
# get added here.
#
# Conventions:
#   - All authenticated routes require Bearer + X-Org-Id.
#   - Pagination uses ?limit + ?offset (or cursor when supported).
#   - Timestamps are ISO 8601 strings.
#   - IDs are UUIDs (CHAR(36)).
openapi: 3.1.0

info:
  title: 3XA Projekt API
  version: 0.1.0
  description: |
    Project management platform — projects, issues, sprints, docs,
    time tracking, invoicing, CRM, push notifications.

    This file is the contract every typed client (web, iOS, Android,
    MCP, third-party) reads. A change here is the only acceptable way
    to introduce or rename a route — server changes that aren't
    reflected here are bugs.
  contact:
    name: 3XA
    email: info@3xa.es

servers:
  - url: https://projekt.3xa.es/api
    description: Production
  - url: http://localhost:8080/api
    description: Local dev (dev-router.php)

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque

  parameters:
    OrgIdHeader:
      name: X-Org-Id
      in: header
      schema: { type: string, format: uuid }
      description: Organisation context for the request. Resolved from /me when omitted on most reads, required on writes.
      required: false

  schemas:
    Error:
      type: object
      properties:
        error:   { type: string }
        message: { type: string }
        details: { type: object, additionalProperties: true }

    User:
      type: object
      required: [id, email]
      properties:
        id:         { type: string, format: uuid }
        email:      { type: string, format: email }
        name:       { type: string, nullable: true }
        username:   { type: string, nullable: true }
        avatar_url: { type: string, nullable: true }

    Organization:
      type: object
      required: [id, name]
      properties:
        id:       { type: string, format: uuid }
        name:     { type: string }
        slug:     { type: string }
        plan:     { type: string }
        logo_url: { type: string, nullable: true }
        role:     { type: string, description: "Caller's role in this org", nullable: true }

    Me:
      type: object
      required: [id, email]
      properties:
        id:                   { type: string, format: uuid }
        email:                { type: string, format: email }
        name:                 { type: string, nullable: true }
        avatar_url:           { type: string, nullable: true }
        current_organization: { $ref: '#/components/schemas/Organization' }
        organizations:
          type: array
          items: { $ref: '#/components/schemas/Organization' }

    Project:
      type: object
      required: [id, name, organization_id]
      properties:
        id:              { type: string, format: uuid }
        organization_id: { type: string, format: uuid }
        name:            { type: string }
        key:             { type: string, nullable: true }
        description:     { type: string, nullable: true }
        logo_url:        { type: string, nullable: true }
        columns:
          type: array
          items: { type: string }
          description: |
            Board columns / statuses available on this project. New projects
            default to `["Backlog", "To Do", "In Progress", "In Review",
            "Done"]`; project templates (e.g. bug-tracking) and the project
            settings UI may override this list. `Issue.status` must match
            one of these values.
          example: ["Backlog", "To Do", "In Progress", "In Review", "Done"]
        created_at:      { type: string, format: date-time }
        updated_at:      { type: string, format: date-time, nullable: true }

    Issue:
      type: object
      required: [id, project_id, title, status]
      properties:
        id:              { type: string, format: uuid }
        project_id:      { type: string, format: uuid }
        key:             { type: string, description: "Human-readable like PRJ-123" }
        title:           { type: string }
        description:     { type: string, nullable: true }
        status:
          type: string
          description: |
            Current board column. Free-form string because each project can
            define custom columns in `projects.columns`. The default columns
            for a new project are `Backlog`, `To Do`, `In Progress`,
            `In Review`, `Done`; localized inputs (e.g. `en revisión`) are
            normalized to these canonical English forms on write.
          example: In Review
        priority:
          type: string
          enum: [low, medium, high, critical]
        type:
          type: string
          enum: [task, bug, feature, story, epic]
          nullable: true
        assignee_id:     { type: string, format: uuid, nullable: true }
        sprint_id:       { type: string, format: uuid, nullable: true }
        due_date:        { type: string, format: date, nullable: true }
        estimated_hours: { type: number, nullable: true }
        labels:
          type: array
          items: { type: string }
        attachments:
          type: array
          items: { $ref: '#/components/schemas/Attachment' }
        created_at:      { type: string, format: date-time }
        updated_at:      { type: string, format: date-time, nullable: true }

    Attachment:
      type: object
      required: [id, file_url, original_name]
      properties:
        id:            { type: string, format: uuid }
        original_name: { type: string }
        mime_type:     { type: string }
        file_size:     { type: integer }
        file_url:      { type: string, description: "Backend-relative URL (resolveBackendUrl on native)" }
        thumbnail_url: { type: string, nullable: true }
        uploaded_by:   { type: string, format: uuid }
        created_at:    { type: string, format: date-time }

    Comment:
      type: object
      required: [id, issue_id, body, author_id]
      properties:
        id:         { type: string, format: uuid }
        issue_id:   { type: string, format: uuid }
        body:       { type: string }
        author_id:  { type: string, format: uuid }
        created_at: { type: string, format: date-time }

    TimeEntry:
      type: object
      required: [id, issue_id, user_id, hours, date]
      properties:
        id:          { type: string, format: uuid }
        issue_id:    { type: string, format: uuid }
        project_id:  { type: string, format: uuid }
        user_id:     { type: string, format: uuid }
        hours:       { type: number, format: float }
        description: { type: string, nullable: true }
        date:        { type: string, format: date }
        created_at:  { type: string, format: date-time }

    Notification:
      type: object
      required: [id, user_id, type, created_at]
      properties:
        id:         { type: string, format: uuid }
        user_id:    { type: string, format: uuid }
        type:       { type: string, description: "issue_assigned, comment_added, mention, ..." }
        title:      { type: string, nullable: true }
        body:       { type: string, nullable: true }
        url:        { type: string, nullable: true, description: "Deep link / SPA route to navigate to" }
        read_at:    { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    DeviceRegistration:
      type: object
      required: [platform, token]
      properties:
        platform:
          type: string
          enum: [fcm, apns, web]
        token:        { type: string }
        device_name:  { type: string, nullable: true }
        app_version:  { type: string, nullable: true }

    Member:
      type: object
      required: [id, email]
      description: |
        A user that can access a project. Currently the project member set
        is derived from the project's organisation membership (see
        AdminController::getProjectMembers); per-project ACLs are tracked
        via cross-org shares and external collaborators.
      properties:
        id:         { type: string, format: uuid }
        email:      { type: string, format: email }
        name:       { type: string, nullable: true }
        avatar_url: { type: string, nullable: true }
        role:
          type: string
          description: "Role in the org context (owner, admin, manager, member, viewer, ...)"
          nullable: true

    RoadmapItem:
      type: object
      required: [id, project_id, name, start_date, end_date]
      description: |
        Item del roadmap ejecutivo (mig 187). Tres tipos: `epic`,
        `milestone`, `release`. La pareja (start_date, end_date) define
        la barra horizontal en la timeline; `progress` (0-100) controla
        el porcentaje verde de la barra.
      properties:
        id:           { type: string, format: uuid }
        project_id:   { type: string, format: uuid }
        name:         { type: string }
        description:  { type: string, nullable: true }
        start_date:   { type: string, format: date }
        end_date:     { type: string, format: date }
        progress:     { type: integer, minimum: 0, maximum: 100 }
        item_type:
          type: string
          enum: [epic, milestone, release]
        color:        { type: string, nullable: true }
        # `start` / `end` aliases (without _date suffix) are emitted by
        # the create endpoint for FE convenience.
        start:        { type: string, format: date, nullable: true }
        end:          { type: string, format: date, nullable: true }
        type:
          type: string
          description: "Alias of item_type returned by `POST` for legacy clients"
          enum: [epic, milestone, release]
          nullable: true

    RoadmapDependency:
      type: object
      required: [project_id, item_id, depends_on_id]
      description: |
        Dependencia "to depends on from" entre dos items (o issues) del
        roadmap. Almacenada en `roadmap_dependencies`. La FE la dibuja
        como flecha del item dependiente al que lo bloquea.
      properties:
        id:              { type: string, format: uuid }
        project_id:      { type: string, format: uuid }
        item_id:         { type: string, format: uuid, description: "Item dependiente (the `to_id` of the API call)" }
        item_type:       { type: string, enum: [epic, milestone, release, issue] }
        depends_on_id:   { type: string, format: uuid, description: "Item que bloquea (the `from_id` of the API call)" }
        depends_on_type: { type: string, enum: [epic, milestone, release, issue] }
        created_at:      { type: string, format: date-time }

    ProjectDoc:
      type: object
      required: [id, project_id, title]
      description: |
        Notion-like project documentation page (EditorJS block-based).
        See server/Controllers/ProjectDocsController.php — backed by the
        `project_docs` table.
      properties:
        id:              { type: string, format: uuid }
        organization_id: { type: string, format: uuid }
        project_id:      { type: string, format: uuid }
        parent_doc_id:   { type: string, format: uuid, nullable: true }
        title:           { type: string }
        icon:            { type: string, nullable: true }
        cover_url:       { type: string, nullable: true }
        blocks:
          description: "EditorJS document tree. Returned only on the detail endpoint; list endpoint omits it."
          oneOf:
            - { type: object, additionalProperties: true }
            - { type: string }
          nullable: true
        is_archived:     { type: boolean }
        created_by:      { type: string, format: uuid, nullable: true }
        created_at:      { type: string, format: date-time }
        updated_at:      { type: string, format: date-time, nullable: true }

    CustomField:
      type: object
      required: [id, name, field_type]
      description: |
        Custom field definition. Stored in `custom_field_definitions`
        scoped by organisation; entity_type is usually 'issue'.
        Project scoping is currently org-wide — the {projectId} in the
        path is informational only (see CustomFieldController).
      properties:
        id:              { type: string, format: uuid }
        organization_id: { type: string, format: uuid }
        name:            { type: string }
        field_type:
          type: string
          enum: [text, number, select, multi_select, date, checkbox]
        entity_type:     { type: string, description: "Target entity for the field (default: issue)" }
        required:        { type: boolean }
        options:
          type: array
          nullable: true
          items: { type: string }
          description: "Used for select / multi_select"
        created_at:      { type: string, format: date-time }
        updated_at:      { type: string, format: date-time, nullable: true }

    AdminUser:
      type: object
      required: [id, email]
      description: |
        User row as returned by the admin user list/detail endpoints.
        See AdminUserController::users / userDetail. The list endpoint
        returns the flat fields plus aggregate counts; the detail endpoint
        adds organizations / sessions / activity arrays.
      properties:
        id:                { type: string, format: uuid }
        email:             { type: string, format: email }
        name:              { type: string, nullable: true }
        avatar_url:        { type: string, nullable: true }
        is_platform_admin: { type: boolean }
        disabled_at:       { type: string, format: date-time, nullable: true, description: "Null when account is active" }
        created_at:        { type: string, format: date-time }
        last_login:        { type: string, format: date-time, nullable: true }
        org_count:         { type: integer, nullable: true }
        org_names:         { type: string, nullable: true, description: "Comma-separated list of org names (list endpoint only)" }
        admin_role:        { type: string, nullable: true, description: "Platform admin role (detail endpoint only)" }
        organizations:
          type: array
          nullable: true
          description: "Detail endpoint only — orgs the user belongs to with their role"
          items:
            type: object
            properties:
              id:    { type: string, format: uuid }
              name:  { type: string }
              slug:  { type: string }
              plan:  { type: string }
              role:  { type: string }
        sessions:
          type: array
          nullable: true
          description: "Detail endpoint only — active sessions"
          items:
            type: object
            additionalProperties: true
        activity:
          type: array
          nullable: true
          description: "Detail endpoint only — recent activity log entries"
          items:
            type: object
            additionalProperties: true

    AdminOrg:
      type: object
      required: [id, name]
      description: |
        Organisation row as returned by the admin org list/detail
        endpoints (see AdminController::organizations / orgDetail).
      properties:
        id:            { type: string, format: uuid }
        name:          { type: string }
        slug:          { type: string }
        plan:          { type: string, nullable: true }
        logo_url:      { type: string, nullable: true }
        member_count:  { type: integer, nullable: true }
        project_count: { type: integer, nullable: true }
        suspended_at:  { type: string, format: date-time, nullable: true, description: "Null when org is active" }
        created_at:    { type: string, format: date-time }
        updated_at:    { type: string, format: date-time, nullable: true }
        members:
          type: array
          nullable: true
          description: "Detail endpoint only"
          items:
            type: object
            additionalProperties: true
        projects:
          type: array
          nullable: true
          description: "Detail endpoint only"
          items:
            type: object
            additionalProperties: true

    AdminStats:
      type: object
      description: |
        Platform-level dashboard counts. See AdminStatsController::stats.
        All count fields are non-negative integers; arrays are returned
        as-is from the underlying queries.
      properties:
        total_users:        { type: integer }
        total_orgs:         { type: integer }
        total_projects:     { type: integer }
        total_issues:       { type: integer }
        active_sessions:    { type: integer }
        new_users_7d:       { type: integer }
        new_orgs_7d:        { type: integer }
        new_users_30d:      { type: integer }
        disabled_users:     { type: integer }
        recent_errors_24h:  { type: integer }
        active_users_today: { type: integer }
        active_users_week:  { type: integer }
        plan_distribution:
          type: array
          items:
            type: object
            properties:
              plan:  { type: string }
              count: { type: integer }
        recent_users:
          type: array
          items: { $ref: '#/components/schemas/User' }
        issues_by_status:
          type: array
          items:
            type: object
            properties:
              status: { type: string }
              count:  { type: integer }
        top_projects:
          type: array
          items:
            type: object
            additionalProperties: true

    Invoice:
      type: object
      required: [id, organization_id, invoice_number, status]
      description: |
        Invoice row as returned by InvoiceController. The list endpoint
        (`GET /invoices`) wraps these in `{ data, total, page, per_page,
        pages, summary }` while the detail endpoint inlines `items` and
        `payments`. Client metadata is snapshotted on the row at create
        time (`client_name`, `client_email`, `client_address`,
        `client_tax_id`); `display_client_name` is a server-derived
        canonical label that prefers the linked org legal_name.
      properties:
        id:                  { type: string, format: uuid }
        organization_id:     { type: string, format: uuid }
        client_id:           { type: string, format: uuid, nullable: true }
        invoice_number:      { type: string, description: "Generated like INV-00001" }
        client_name:         { type: string }
        client_email:        { type: string, format: email, nullable: true }
        client_address:      { type: string, nullable: true }
        client_tax_id:       { type: string, nullable: true }
        display_client_name: { type: string, nullable: true, description: "Canonical label (linked org legal_name → org name → client.company → snapshot)" }
        status:
          type: string
          enum: [draft, issued, sent, paid, overdue, void]
        issue_date:          { type: string, format: date }
        due_date:            { type: string, format: date }
        issued_at:           { type: string, format: date-time, nullable: true }
        paid_at:             { type: string, format: date-time, nullable: true }
        currency:            { type: string, nullable: true, description: "ISO 4217, defaults to EUR via invoice_settings" }
        subtotal:            { type: number }
        tax_rate:            { type: number }
        tax_amount:          { type: number }
        total:               { type: number }
        amount_paid:         { type: number }
        balance:             { type: number }
        notes:               { type: string, nullable: true }
        void_reason:         { type: string, nullable: true }
        project_id:          { type: string, format: uuid, nullable: true, description: "Optional link to a project (own org or shared via project_org_shares)" }
        issued_to_org_id:    { type: string, format: uuid, nullable: true, description: "Cross-org B2B target (requires accepted crossorg_link_request)" }
        created_by:          { type: string, format: uuid, nullable: true }
        created_at:          { type: string, format: date-time }
        updated_at:          { type: string, format: date-time, nullable: true }
        items:
          type: array
          nullable: true
          description: "Inlined on detail / create / update responses"
          items: { $ref: '#/components/schemas/InvoiceItem' }
        payments:
          type: array
          nullable: true
          description: "Inlined on detail responses"
          items: { $ref: '#/components/schemas/InvoicePayment' }

    InvoiceItem:
      type: object
      required: [id, invoice_id, description, quantity, unit_price, amount]
      description: |
        Line item on an invoice. Backend stores the computed line total
        as `amount` (qty × unit_price); `tax_rate` is per-line and
        nullable. `project_id` / `issue_id` are not currently persisted
        on this row — kept on the spec as forward-compat placeholders.
      properties:
        id:          { type: string, format: uuid }
        invoice_id:  { type: string, format: uuid }
        description: { type: string }
        quantity:    { type: number }
        unit_price:  { type: number }
        amount:      { type: number, description: "Line total (qty × unit_price)" }
        tax_rate:    { type: number, nullable: true, description: "Per-line override; null inherits invoice tax_rate" }
        sort_order:  { type: integer, nullable: true }
        project_id:  { type: string, format: uuid, nullable: true, description: "Reserved — not persisted today" }
        issue_id:    { type: string, format: uuid, nullable: true, description: "Reserved — not persisted today" }

    InvoicePayment:
      type: object
      required: [id, invoice_id, amount, payment_date]
      properties:
        id:           { type: string, format: uuid }
        invoice_id:   { type: string, format: uuid }
        amount:       { type: number }
        payment_date: { type: string, format: date }
        method:       { type: string, nullable: true, description: "e.g. bank_transfer, card, cash" }
        reference:    { type: string, nullable: true }
        notes:        { type: string, nullable: true }
        created_by:   { type: string, format: uuid, nullable: true }
        created_at:   { type: string, format: date-time }

    InvoiceEmailLogEntry:
      type: object
      required: [id, invoice_id, recipients, status, created_at]
      description: |
        Row from invoice_email_log. `status` is `sent` when delivered
        directly via Gmail integration, `queued` when handed off to the
        SMTP job runner.
      properties:
        id:               { type: string, format: uuid }
        invoice_id:       { type: string, format: uuid }
        organization_id:  { type: string, format: uuid }
        recipients:       { type: string }
        subject:          { type: string }
        status:
          type: string
          enum: [queued, sent, failed]
        sent_at:          { type: string, format: date-time, nullable: true }
        gmail_thread_id:  { type: string, nullable: true }
        created_at:       { type: string, format: date-time }

    TaxJurisdiction:
      type: object
      required: [id, org_id, country_code, name]
      description: |
        A fiscal jurisdiction configured for the org. The first jurisdiction
        created is automatically set as primary. Creating a jurisdiction
        auto-seeds obligations and VAT rates from country templates.
      properties:
        id:                    { type: integer }
        org_id:                { type: string, format: uuid }
        country_code:          { type: string, description: "ISO 3166-1 alpha-2" }
        name:                  { type: string }
        tax_regime:            { type: string, description: "e.g. general, autonomo, reduced" }
        fiscal_year_start:     { type: integer, description: "Month number (1 = January)" }
        vat_number:            { type: string, nullable: true }
        tax_id:                { type: string, nullable: true }
        is_primary:            { type: integer, description: "0/1 — one primary per org" }
        is_active:             { type: integer, description: "0/1" }
        obligation_count:      { type: integer, description: "Joined on list endpoint" }
        active_obligation_count: { type: integer, description: "Joined on list endpoint" }
        created_at:            { type: string, format: date-time, nullable: true }
        updated_at:            { type: string, format: date-time, nullable: true }

    TaxRate:
      type: object
      required: [id, org_id, jurisdiction_id, name, rate, type]
      description: |
        VAT / sales-tax rate for a jurisdiction. Rates are period-bound via
        `effective_from` / `effective_to`. `is_current` is a computed
        column returned by the list endpoint (1 when still in effect today).
      properties:
        id:              { type: integer }
        org_id:          { type: string, format: uuid }
        jurisdiction_id: { type: integer }
        name:            { type: string }
        rate:            { type: number }
        type:            { type: string, description: "e.g. vat, reduced_vat, zero_vat, exempt" }
        is_default:      { type: integer, description: "0/1" }
        is_current:      { type: integer, description: "Computed: 1 when effective_to IS NULL or in future" }
        effective_from:  { type: string, format: date, nullable: true }
        effective_to:    { type: string, format: date, nullable: true }
        created_at:      { type: string, format: date-time, nullable: true }

    TaxObligation:
      type: object
      required: [id, org_id, jurisdiction_id, model_code, name, frequency]
      description: |
        A recurring tax filing obligation (e.g. Modelo 303 quarterly IVA).
        Auto-seeded when a jurisdiction is created. The `filing_count` and
        `filed_count` aggregates are joined on the list endpoint.
      properties:
        id:                  { type: integer }
        org_id:              { type: string, format: uuid }
        jurisdiction_id:     { type: integer }
        country_code:        { type: string, description: "Joined from tax_jurisdictions" }
        jurisdiction_name:   { type: string, description: "Joined from tax_jurisdictions" }
        model_code:          { type: string, description: "e.g. 303, 130, 390" }
        name:                { type: string }
        frequency:           { type: string, description: "monthly | quarterly | annual" }
        due_day:             { type: integer, description: "Day-of-month on which the return is due" }
        description:         { type: string, nullable: true }
        is_active:           { type: integer, description: "0/1" }
        filing_count:        { type: integer, description: "Filings for current year (joined)" }
        filed_count:         { type: integer, description: "Filed/paid filings for current year (joined)" }
        created_at:          { type: string, format: date-time, nullable: true }

    TaxFiling:
      type: object
      required: [id, org_id, obligation_id, period_year, status, due_date]
      description: |
        A single period's filing instance for an obligation. `status` flows:
        not_started → in_progress → calculated → filed → paid.
        `calculation_data` holds the breakdown JSON written by calculateFiling.
      properties:
        id:                  { type: integer }
        org_id:              { type: string, format: uuid }
        obligation_id:       { type: integer }
        model_code:          { type: string, description: "Joined from tax_obligations" }
        obligation_name:     { type: string, description: "Joined from tax_obligations" }
        frequency:           { type: string, description: "Joined from tax_obligations" }
        jurisdiction_id:     { type: integer, description: "Joined from tax_obligations" }
        country_code:        { type: string, description: "Joined from tax_jurisdictions" }
        jurisdiction_name:   { type: string, description: "Joined from tax_jurisdictions" }
        period_year:         { type: integer }
        period_quarter:      { type: integer, nullable: true }
        period_month:        { type: integer, nullable: true }
        status:
          type: string
          description: "not_started | in_progress | calculated | filed | paid | overdue"
        due_date:            { type: string, format: date }
        calculated_amount:   { type: number, nullable: true }
        filed_amount:        { type: number, nullable: true }
        filed_date:          { type: string, format: date, nullable: true }
        paid_date:           { type: string, format: date, nullable: true }
        notes:               { type: string, nullable: true }
        document_url:        { type: string, nullable: true }
        calculation_data:    { type: object, additionalProperties: true, nullable: true, description: "Breakdown JSON from calculateFiling" }
        created_by:          { type: string, format: uuid, nullable: true }
        created_at:          { type: string, format: date-time, nullable: true }
        updated_at:          { type: string, format: date-time, nullable: true }

    TaxTransaction:
      type: object
      required: [id, org_id, jurisdiction_id, type, date, base_amount, tax_amount]
      description: |
        Normalised ledger row synced from invoices and expenses.
        `type` classification: sale | intra_eu_sale | export | purchase |
        intra_eu_purchase | import | reverse_charge | withholding.
      properties:
        id:                    { type: integer }
        org_id:                { type: string, format: uuid }
        jurisdiction_id:       { type: integer }
        country_code:          { type: string, description: "Joined from tax_jurisdictions" }
        jurisdiction_name:     { type: string, description: "Joined from tax_jurisdictions" }
        type:                  { type: string }
        date:                  { type: string, format: date }
        invoice_id:            { type: integer, nullable: true }
        expense_id:            { type: integer, nullable: true }
        counterparty_name:     { type: string, nullable: true }
        counterparty_tax_id:   { type: string, nullable: true }
        counterparty_country:  { type: string, nullable: true, description: "ISO alpha-2" }
        base_amount:           { type: number }
        tax_amount:            { type: number }
        tax_rate:              { type: number }
        is_capital_asset:      { type: integer, description: "0/1 — used for Modelo 303 boxes 30-31" }
        description:           { type: string, nullable: true }
        created_at:            { type: string, format: date-time, nullable: true }

    TaxCalendarEntry:
      type: object
      description: |
        One obligation's calendar entry as returned by GET /tax-multi/calendar.
        `periods` is a map keyed by period label (Q1..Q4, M1..M12, annual).
      properties:
        obligation:    { $ref: '#/components/schemas/TaxObligation' }
        total_payable: { type: number }
        periods:
          type: object
          additionalProperties:
            type: object
            properties:
              period_key: { type: string }
              due_date:   { type: string, format: date }
              status:     { type: string, description: "not_started | overdue | calculated | filed | paid" }
              filing:
                nullable: true
                allOf:
                  - $ref: '#/components/schemas/TaxFiling'
              is_overdue: { type: boolean }

    InvoiceSettings:
      type: object
      description: |
        Per-org invoicing defaults. Only owners/admins can read/write
        (see InvoiceController::settings / updateSettings). The reminder
        fields use different keys on the way in vs out — see the spec
        on `PUT /invoices/settings`.
      properties:
        id:                   { type: string, format: uuid }
        organization_id:      { type: string, format: uuid }
        company_name:         { type: string, nullable: true }
        company_address:      { type: string, nullable: true }
        tax_id:               { type: string, nullable: true }
        currency:             { type: string, nullable: true, description: "Defaults to EUR" }
        invoice_prefix:       { type: string, nullable: true, description: "Defaults to INV" }
        yearly_reset:         { type: boolean, nullable: true }
        payment_terms_days:   { type: integer, nullable: true }
        reminder_before_days: { type: integer, nullable: true, description: "Reminder window before due (input key: reminder_before_due)" }
        reminder_after_days:  { type: integer, nullable: true, description: "Reminder window after overdue (input key: reminder_after_overdue)" }
        bank_details:         { type: string, nullable: true }
        next_invoice_number:  { type: integer, nullable: true }
        created_at:           { type: string, format: date-time, nullable: true }
        updated_at:           { type: string, format: date-time, nullable: true }

    RecurringInvoice:
      type: object
      required: [id, organization_id, client_name, frequency]
      description: |
        Row from recurring_invoice_configs. `items` is stored as JSON in
        the DB and decoded on the way out. There is no `last_invoice_id`
        — only `last_run_at` (date) and `next_run_at` (date) are tracked.
      properties:
        id:              { type: string, format: uuid }
        organization_id: { type: string, format: uuid }
        client_id:       { type: string, format: uuid, nullable: true }
        client_name:     { type: string }
        client_email:    { type: string, format: email, nullable: true }
        client_address:  { type: string, nullable: true }
        client_tax_id:   { type: string, nullable: true }
        frequency:
          type: string
          enum: [weekly, biweekly, monthly, quarterly, yearly]
        day_of_month:    { type: integer, minimum: 1, maximum: 28, nullable: true }
        tax_rate:        { type: number, nullable: true }
        notes:           { type: string, nullable: true }
        is_active:       { type: integer, description: "0/1 — backend stores TINYINT, not boolean" }
        auto_send:       { type: integer, nullable: true, description: "0/1 — auto-email generated invoices" }
        last_run_at:     { type: string, format: date, nullable: true }
        next_run_at:     { type: string, format: date, nullable: true }
        created_by:      { type: string, format: uuid, nullable: true }
        created_by_name: { type: string, nullable: true, description: "Joined from users table on list endpoint" }
        created_at:      { type: string, format: date-time }
        updated_at:      { type: string, format: date-time, nullable: true }
        items:
          type: array
          description: "Decoded from JSON storage; same line-item shape used at create time"
          items: { $ref: '#/components/schemas/InvoiceItem' }

    TeamMember:
      type: object
      required: [id, email]
      description: |
        A row returned by `GET /team` (TeamController::index). Joins
        `organization_users` against `users` for the caller's current
        org. Includes presence (`last_active_at`, `manual_status`) which
        is missing from the lighter `Member` and `OrgMember` shapes.
      properties:
        id:             { type: string, format: uuid }
        email:          { type: string, format: email }
        name:           { type: string, nullable: true }
        avatar_url:     { type: string, nullable: true }
        last_active_at: { type: string, format: date-time, nullable: true }
        manual_status:  { type: string, nullable: true, description: "User-set presence label (e.g. busy, away)" }
        role:
          type: string
          description: "Org role (owner, admin, manager, member, viewer)"
        joined_at:      { type: string, format: date-time }

    OrgMember:
      type: object
      required: [id, email]
      description: |
        A row returned by `GET /orgs/{id}/members`
        (OrganizationController::members). Same join as TeamMember but
        without presence — adds `username`.
      properties:
        id:         { type: string, format: uuid }
        email:      { type: string, format: email }
        name:       { type: string, nullable: true }
        username:   { type: string, nullable: true }
        avatar_url: { type: string, nullable: true }
        role:       { type: string }
        joined_at:  { type: string, format: date-time }

    UserSearchResult:
      type: object
      required: [id, email]
      description: |
        Lean user row returned by `GET /team/search` — used by the
        invite-by-username/email autocomplete. Excludes current org
        members and disabled users; respects `allow_global_search`.
      properties:
        id:         { type: string, format: uuid }
        email:      { type: string, format: email }
        username:   { type: string, nullable: true }
        name:       { type: string, nullable: true }
        avatar_url: { type: string, nullable: true }

    Invitation:
      type: object
      required: [id, email, role, status]
      description: |
        A pending team invitation (row from `pending_invitations`).
        Returned by `GET /team/invitations`, `POST /team/invite` and
        `POST /team/invitations/{id}/resend`. The `token` is hashed at
        rest — only the create flow ever sees the raw value (it goes out
        in the invite email link).
      properties:
        id:           { type: string, format: uuid }
        email:        { type: string, format: email }
        name:         { type: string, nullable: true }
        role:
          type: string
          enum: [admin, manager, member, viewer]
        status:
          type: string
          enum: [pending, accepted, declined, expired]
        expires_at:   { type: string, format: date-time }
        created_at:   { type: string, format: date-time, nullable: true }
        inviter_name: { type: string, nullable: true, description: "Joined from users — list endpoint only" }

    InviteLink:
      type: object
      required: [invite_url, token, role, expires_at]
      description: |
        Response from `POST /team/invite-link`. The raw `token` is
        returned exactly once; the backend stores `hash('sha256', token)`
        in `invite_links.token`.
      properties:
        invite_url: { type: string, description: "Full URL with ?invite=<token> appended" }
        token:      { type: string, description: "Raw token — surface to the user once and discard" }
        role:
          type: string
          enum: [admin, manager, member, viewer]
        expires_at: { type: string, format: date-time }

    InvitationResponse:
      type: object
      required: [status]
      description: |
        Response from `POST /invitations/{token}/respond`. On accept the
        backend includes the org context so the SPA can switch to it.
      properties:
        status:
          type: string
          enum: [accepted, declined]
        organization_id: { type: string, format: uuid, nullable: true }
        organization:    { type: string, nullable: true, description: "Org display name" }
        role:            { type: string, nullable: true }

    AuthSession:
      type: object
      required: [id]
      description: |
        Active session row returned by `GET /auth/sessions`
        (AuthController::sessions). `device_name` is server-derived from
        the User-Agent when no client-supplied label is stored.
      properties:
        id:             { type: string, format: uuid }
        ip_address:     { type: string, nullable: true }
        device_name:    { type: string, nullable: true }
        location:       { type: string, nullable: true }
        last_active_at: { type: string, format: date-time, nullable: true }
        created_at:     { type: string, format: date-time }
        is_current:     { type: boolean }

    PresenceViewers:
      type: object
      description: |
        Response shape for `POST /presence/heartbeat` and
        `GET /presence/issue/{issueId}`. The backend always returns the
        wrapper even on errors (it falls back to `{ viewers: [] }`).
      properties:
        viewers:
          type: array
          items:
            type: object
            properties:
              user_id:       { type: string, format: uuid }
              name:          { type: string, nullable: true }
              avatar_url:    { type: string, nullable: true }
              last_seen_at:  { type: string, format: date-time, nullable: true, description: "Only present on the GET /presence/issue/{issueId} variant" }

    RoleGates:
      type: object
      description: |
        Map of capability key → list of roles allowed. Returned by
        `GET /orgs/{id}/role-gates` (merged defaults + overrides) and
        `PUT /orgs/{id}/role-gates` (after upserting the overrides).
        Owner is always implicitly allowed; the PUT endpoint rejects
        payloads that omit `owner` from any roles_allowed list.
      additionalProperties:
        type: array
        items:
          type: string
          enum: [owner, admin, manager, member, viewer]

    Contract:
      type: object
      required: [id, organization_id, title, status]
      description: |
        Row from the `contracts` table as returned by ContractController.
        Contracts are tied to either an `employee_id` (HR-style internal
        agreements) or a `client_id` (B2B service agreements / NDAs /
        SOWs / MSAs); both can be set or null. Numbering is auto-generated
        on create from `contract_settings` (e.g. `CTR-00001`). The detail
        endpoint inlines `clauses` and a 10-row `audit_log` slice; the
        list endpoint joins denormalised employee / client labels on top.

        Drift note: the contract domain is HR/B2B-oriented, not the
        client-acceptance flow described in some product copy. Fields
        like `signature_data`, `signed_by_email`, `total_amount` do not
        exist on this table — `salary` + `currency` is the value pair,
        and signing is recorded by toggling `status` to `signed` plus
        an entry in `contract_audit_log`. There is no public/unauth
        signature endpoint.
      properties:
        id:                  { type: string, format: uuid }
        organization_id:     { type: string, format: uuid }
        employee_id:         { type: string, format: uuid, nullable: true }
        client_id:           { type: string, format: uuid, nullable: true }
        template_version_id: { type: string, format: uuid, nullable: true, description: "Source contract_template_versions row when created from a template" }
        contract_number:     { type: string, description: "Generated like CTR-00001 from contract_settings" }
        title:               { type: string }
        status:
          type: string
          enum: [draft, active, issued, signed, archived, expired, terminated]
        contract_type:
          type: string
          enum: [internship, full_time, part_time, freelance, service_agreement, nda, sow, msa, custom]
        start_date:          { type: string, format: date, nullable: true }
        end_date:            { type: string, format: date, nullable: true }
        salary:              { type: number, description: "Numeric value pair with currency — used for HR contracts and B2B value alike" }
        currency:            { type: string, description: "ISO 4217, defaults to EUR" }
        notes:               { type: string, nullable: true }
        variables_data:      { type: string, nullable: true, description: "JSON-encoded blob of custom template variables (server stringifies on write)" }
        content_snapshot:    { type: string, nullable: true, description: "JSON-encoded resolved-clauses snapshot, written by generatePdf" }
        pdf_path:            { type: string, nullable: true, description: "Backend-relative path to the most recent generated PDF" }
        pdf_generated_at:    { type: string, format: date-time, nullable: true }
        created_by:          { type: string, format: uuid, nullable: true }
        created_at:          { type: string, format: date-time }
        updated_at:          { type: string, format: date-time, nullable: true }
        # Denormalised joins surfaced on list/detail (employees_view + clients)
        employee_first_name: { type: string, nullable: true }
        employee_last_name:  { type: string, nullable: true }
        employee_email:      { type: string, format: email, nullable: true }
        employee_position:   { type: string, nullable: true }
        employee_department: { type: string, nullable: true }
        client_name:         { type: string, nullable: true }
        client_company:      { type: string, nullable: true }
        client_email:        { type: string, format: email, nullable: true }
        client_phone:        { type: string, nullable: true }
        client_tax_id:       { type: string, nullable: true }
        client_address:      { type: string, nullable: true }
        client_city:         { type: string, nullable: true }
        client_country:      { type: string, nullable: true }
        clauses:
          type: array
          nullable: true
          description: "Inlined on detail / create / update responses"
          items: { $ref: '#/components/schemas/ContractClause' }
        audit_log:
          type: array
          nullable: true
          description: "Last 10 contract_audit_log rows on the detail endpoint"
          items: { $ref: '#/components/schemas/ContractAuditEntry' }

    ContractClause:
      type: object
      required: [id, contract_id, sort_order]
      description: |
        Row from `contract_clauses`. Replaced wholesale on update when
        `clauses` is included in the PUT body.
      properties:
        id:          { type: string, format: uuid }
        contract_id: { type: string, format: uuid }
        title:       { type: string }
        content:     { type: string }
        sort_order:  { type: integer }
        is_required: { type: integer, description: "0/1 TINYINT" }

    ContractAuditEntry:
      type: object
      required: [id, contract_id]
      description: |
        Row from `contract_audit_log` (see ContractAuditService). Action
        strings are free-form (e.g. `created`, `status_changed`,
        `pdf_generated`, `pdf_downloaded`, `payment_milestone_created`,
        `payment_milestone_invoiced`, `updated`). `old_value` /
        `new_value` are stored as strings — `new_value` is often a
        JSON-encoded payload.
      properties:
        id:          { type: string, format: uuid }
        contract_id: { type: string, format: uuid }
        user_id:     { type: string, format: uuid, nullable: true }
        user_name:   { type: string, nullable: true, description: "Joined from users on the audit endpoint" }
        action:      { type: string }
        old_value:   { type: string, nullable: true }
        new_value:   { type: string, nullable: true }
        created_at:  { type: string, format: date-time }

    ContractSettings:
      type: object
      description: |
        Per-org contract numbering + corporate header defaults
        (`contract_settings`). Owners/admins only. The fallback object
        when no row exists is `{contract_prefix: 'CTR', company_name,
        company_address, tax_id }` (all but the prefix null).
      properties:
        id:                   { type: string, format: uuid, nullable: true }
        organization_id:      { type: string, format: uuid, nullable: true }
        company_name:         { type: string, nullable: true }
        company_address:      { type: string, nullable: true }
        tax_id:               { type: string, nullable: true }
        contract_prefix:      { type: string, description: "Defaults to CTR" }
        next_contract_number: { type: integer, nullable: true }
        default_clauses:      { type: string, nullable: true, description: "JSON-encoded array of default clause objects" }
        created_at:           { type: string, format: date-time, nullable: true }
        updated_at:           { type: string, format: date-time, nullable: true }

    ContractTemplate:
      type: object
      required: [id, organization_id, name]
      description: |
        Row from `contract_templates`. Each template owns one or more
        `contract_template_versions`; the list endpoint joins the
        current version with `version_number`, `current_version_id`,
        `version_count`, `clause_count`. The detail endpoint adds
        `current_version`, full `clauses`, and a `versions` history
        array. Soft-deleted via `is_active = 0`.
      properties:
        id:                    { type: string, format: uuid }
        organization_id:       { type: string, format: uuid }
        name:                  { type: string }
        description:           { type: string, nullable: true }
        jurisdiction:          { type: string, nullable: true }
        default_payment_terms: { type: string, nullable: true }
        duration_type:
          type: string
          enum: [fixed, ongoing, milestone]
        contract_type:
          type: string
          enum: [service_agreement, nda, sow, msa, custom]
        is_active:             { type: integer, description: "0/1 TINYINT" }
        created_by:            { type: string, format: uuid, nullable: true }
        created_at:            { type: string, format: date-time }
        updated_at:            { type: string, format: date-time, nullable: true }
        # List-endpoint join fields
        version_number:        { type: integer, nullable: true }
        current_version_id:    { type: string, format: uuid, nullable: true }
        version_count:         { type: integer, nullable: true }
        clause_count:          { type: integer, nullable: true }
        # Detail-endpoint inlines
        current_version:
          nullable: true
          allOf: [{ $ref: '#/components/schemas/ContractTemplateVersion' }]
        clauses:
          type: array
          nullable: true
          items: { $ref: '#/components/schemas/ContractTemplateClause' }
        versions:
          type: array
          nullable: true
          items: { $ref: '#/components/schemas/ContractTemplateVersion' }

    ContractTemplateVersion:
      type: object
      required: [id, template_id, version_number]
      description: |
        Row from `contract_template_versions`. `is_current = 1` for the
        active version of a template; updates that include a `clauses`
        array spawn a new version row and flip the previous one to 0.
      properties:
        id:             { type: string, format: uuid }
        template_id:    { type: string, format: uuid }
        version_number: { type: integer }
        changelog:      { type: string, nullable: true }
        is_current:     { type: integer, description: "0/1 TINYINT" }
        created_by:     { type: string, format: uuid, nullable: true }
        created_at:     { type: string, format: date-time }
        clauses:
          type: array
          nullable: true
          description: "Inlined on showVersion responses"
          items: { $ref: '#/components/schemas/ContractTemplateClause' }

    ContractTemplateClause:
      type: object
      required: [id, version_id, sort_order]
      description: |
        Row from `contract_template_clauses`. Cloned into
        `contract_clauses` when a contract is created with
        `template_version_id` and no explicit clauses.
      properties:
        id:          { type: string, format: uuid }
        version_id:  { type: string, format: uuid }
        title:       { type: string }
        content:     { type: string }
        sort_order:  { type: integer }
        is_required: { type: integer, description: "0/1 TINYINT" }
        is_editable: { type: integer, description: "0/1 TINYINT — defaults to 1" }
        is_locked:   { type: integer, description: "0/1 TINYINT" }
        clause_key:  { type: string, nullable: true, description: "Optional stable key used by template variable resolution" }

    LegalSettings:
      type: object
      description: |
        Per-org legal / signatory defaults (`organization_legal_settings`)
        merged into the contract corporate template at PDF time. Owners /
        admins only. The fallback object when no row exists has all
        fields null except `default_currency: 'EUR'`.
      properties:
        id:                    { type: string, format: uuid, nullable: true }
        organization_id:       { type: string, format: uuid, nullable: true }
        legal_name:            { type: string, nullable: true }
        legal_address:         { type: string, nullable: true }
        registration_number:   { type: string, nullable: true }
        tax_id:                { type: string, nullable: true }
        jurisdiction:          { type: string, nullable: true }
        default_currency:      { type: string, nullable: true, description: "Defaults to EUR" }
        default_payment_terms: { type: string, nullable: true }
        signatory_name:        { type: string, nullable: true }
        signatory_title:       { type: string, nullable: true }
        footer_text:           { type: string, nullable: true }
        created_at:            { type: string, format: date-time, nullable: true }
        updated_at:            { type: string, format: date-time, nullable: true }

    PaymentMilestone:
      type: object
      required: [id, contract_id, title, amount, status]
      description: |
        Row from `payment_milestones`. Optionally linked to a project
        `milestone_id`; when that milestone flips to `completed`, the
        backing PaymentMilestoneController::onMilestoneCompleted hook
        auto-marks the payment milestone as `completed` and notifies the
        contract creator. Generating an invoice (POST .../generate-invoice)
        flips the status to `invoiced` and stamps `invoice_id`. Edits and
        deletes are rejected once a row is in `invoiced` state.
      properties:
        id:              { type: string, format: uuid }
        contract_id:     { type: string, format: uuid }
        milestone_id:    { type: string, format: uuid, nullable: true, description: "Optional link to a project milestone" }
        title:           { type: string }
        amount:          { type: number }
        currency:        { type: string, description: "ISO 4217, defaults to contract currency or EUR" }
        status:
          type: string
          enum: [pending, completed, invoiced]
        due_date:        { type: string, format: date, nullable: true }
        completed_at:    { type: string, format: date-time, nullable: true }
        invoice_id:      { type: string, format: uuid, nullable: true, description: "Set when generate-invoice flips status to invoiced" }
        created_at:      { type: string, format: date-time }
        updated_at:      { type: string, format: date-time, nullable: true }
        # List-endpoint joins
        milestone_title:  { type: string, nullable: true, description: "From projects.milestones — list endpoint join" }
        milestone_status: { type: string, nullable: true, description: "From projects.milestones — list endpoint join" }
        invoice_number:   { type: string, nullable: true, description: "From invoices — list endpoint join" }

    CrmPipeline:
      type: object
      required: [id, org_id, name]
      description: |
        Sales pipeline owned by an org. The first read of `GET /crm/pipelines`
        seeds a default `Sales Pipeline` with six stages
        (Prospecting → Qualification → Proposal → Negotiation →
        Closed Won → Closed Lost) when the org has none. `stages` is
        always present on list responses (joined via GROUP_CONCAT) and
        on the create response.
      properties:
        id:         { type: string, format: uuid }
        org_id:     { type: string, format: uuid }
        name:       { type: string }
        is_default: { type: integer, description: "0 or 1 — backend stores int, never bool" }
        created_at: { type: string, format: date-time }
        stages:
          type: array
          items: { $ref: '#/components/schemas/CrmPipelineStage' }

    CrmPipelineStage:
      type: object
      required: [id, pipeline_id, name, position]
      properties:
        id:          { type: string, format: uuid }
        pipeline_id: { type: string, format: uuid }
        name:        { type: string }
        position:    { type: integer, description: "Ordering within the pipeline (0-based)" }
        probability: { type: integer, description: "Default win probability for deals in this stage (0-100)" }
        color:       { type: string, description: "Hex colour, e.g. #6366f1" }
        created_at:  { type: string, format: date-time }

    Lead:
      type: object
      required: [id, org_id, name]
      description: |
        Row from `crm_leads`. List endpoint joins `users` to inline
        `assigned_name` / `assigned_avatar`; detail endpoint additionally
        inlines `activities`. `convertLead` flips `status` to `converted`,
        stamps `converted_to_client_id` + `converted_at`, and creates a
        backing client + deal.
      properties:
        id:                     { type: string, format: uuid }
        org_id:                 { type: string, format: uuid }
        name:                   { type: string }
        email:                  { type: string, format: email, nullable: true }
        phone:                  { type: string, nullable: true }
        company:                { type: string, nullable: true }
        source:                 { type: string, nullable: true, description: "Free-form (referral, web, ad…); defaults to `other` on create" }
        status:                 { type: string, description: "Free-form; commonly new|contacted|qualified|converted|lost. Defaults to `new`." }
        assigned_to:            { type: string, format: uuid, nullable: true }
        notes:                  { type: string, nullable: true }
        converted_to_client_id: { type: string, format: uuid, nullable: true }
        converted_at:           { type: string, format: date-time, nullable: true }
        created_by:             { type: string, format: uuid, nullable: true }
        created_at:             { type: string, format: date-time }
        updated_at:             { type: string, format: date-time, nullable: true }
        # List/detail endpoint joins
        assigned_name:   { type: string, nullable: true, description: "From users — list/detail join" }
        assigned_avatar: { type: string, nullable: true, description: "From users — list/detail join" }
        activities:
          type: array
          nullable: true
          description: "Inlined on detail responses"
          items: { $ref: '#/components/schemas/CrmActivity' }

    Deal:
      type: object
      required: [id, org_id, title]
      description: |
        Row from `crm_deals`. `value` is cast to float and `probability`
        to int on every response. List endpoint joins `clients`,
        `crm_leads` and `users` for inline display fields; detail endpoint
        additionally inlines `activities`. `stage` is one of the canonical
        six values; `moveDealStage` auto-resets `probability` to a
        per-stage default (10/25/50/75/100/0) unless an override is
        supplied. `closeDeal` is a shortcut that flips to `closed_won` /
        `closed_lost` and stamps `actual_close_date` + optional
        `lost_reason`.
      properties:
        id:                  { type: string, format: uuid }
        org_id:              { type: string, format: uuid }
        lead_id:             { type: string, format: uuid, nullable: true }
        client_id:           { type: string, format: uuid, nullable: true }
        pipeline_id:         { type: string, format: uuid, nullable: true }
        title:               { type: string }
        value:               { type: number }
        currency:            { type: string, description: "ISO 4217, defaults to EUR" }
        stage:
          type: string
          enum: [prospecting, qualification, proposal, negotiation, closed_won, closed_lost]
        probability:         { type: integer, description: "0-100" }
        expected_close_date: { type: string, format: date, nullable: true }
        actual_close_date:   { type: string, format: date, nullable: true }
        assigned_to:         { type: string, format: uuid, nullable: true }
        lost_reason:         { type: string, nullable: true }
        created_by:          { type: string, format: uuid, nullable: true }
        created_at:          { type: string, format: date-time }
        updated_at:          { type: string, format: date-time, nullable: true }
        # List/detail endpoint joins
        client_name:     { type: string, nullable: true, description: "From clients — list/detail join" }
        client_company:  { type: string, nullable: true, description: "From clients — list/detail join" }
        lead_name:       { type: string, nullable: true, description: "From crm_leads — list/detail join" }
        lead_email:      { type: string, nullable: true, description: "From crm_leads — detail join only" }
        assigned_name:   { type: string, nullable: true, description: "From users — list/detail join" }
        assigned_avatar: { type: string, nullable: true, description: "From users — list/detail join" }
        activities:
          type: array
          nullable: true
          description: "Inlined on detail responses"
          items: { $ref: '#/components/schemas/CrmActivity' }

    CrmActivity:
      type: object
      required: [id, org_id, type, subject, date]
      description: |
        Row from `crm_activities`. Polymorphic — at most one of `deal_id`,
        `lead_id`, `client_id` is set (the controller does not enforce
        exclusivity). `type` is free-form on the wire, commonly one of
        call|email|meeting|note|task. `outcome` is a free-text result
        (CrmStatsController treats null/empty `outcome` as "pending"
        when computing the `pending_activities` KPI).
      properties:
        id:               { type: string, format: uuid }
        org_id:           { type: string, format: uuid }
        type:             { type: string, description: "call | email | meeting | note | task | …" }
        deal_id:          { type: string, format: uuid, nullable: true }
        lead_id:          { type: string, format: uuid, nullable: true }
        client_id:        { type: string, format: uuid, nullable: true }
        subject:          { type: string }
        description:      { type: string, nullable: true }
        date:             { type: string, format: date-time, description: "Scheduled or logged datetime" }
        duration_minutes: { type: integer, nullable: true }
        outcome:          { type: string, nullable: true, description: "Free text; null/empty == pending" }
        created_by:       { type: string, format: uuid, nullable: true }
        created_at:       { type: string, format: date-time }
        # List endpoint joins
        creator_name:   { type: string, nullable: true, description: "From users — list join" }
        creator_avatar: { type: string, nullable: true, description: "From users — list join" }
        deal_title:     { type: string, nullable: true, description: "From crm_deals — list join" }
        lead_name:      { type: string, nullable: true, description: "From crm_leads — list join" }
        client_name:    { type: string, nullable: true, description: "From clients — list join" }

    Client:
      type: object
      required: [id, organization_id, name, client_type]
      description: |
        Row from `clients`. The `convertLead` flow inserts here. Note the
        column is `organization_id` (not `org_id`) — different from the
        rest of the CRM tables.

        Naming model:
          • `name` — legal entity name. For `client_type=legal` this is
            the *razón social* (e.g. "ACME Soluciones, S.L."). For
            `client_type=individual` it's the person's full legal name
            (e.g. "Juan García López"). Always present, used as the
            authoritative label in invoices/contracts via the SQL
            COALESCE chain in InvoiceController.
          • `company` — trade / brand name. Optional, only meaningful
            when `client_type=legal`. Forced to `null` server-side when
            type is `individual`.
      additionalProperties: true # TODO — clients table has more columns (vat_id, address, linked_org_id…) but no first-class CRUD path is exposed via crmApi.js
      properties:
        id:              { type: string, format: uuid }
        organization_id: { type: string, format: uuid }
        name:            { type: string, description: "Legal name (razón social) or full name for individuals" }
        client_type:
          type: string
          enum: [legal, individual]
          default: legal
          description: "`legal` = company / S.L. / S.A., `individual` = autónomo / persona física"
        email:           { type: string, format: email, nullable: true }
        phone:           { type: string, nullable: true }
        company:         { type: string, nullable: true, description: "Trade name (nombre comercial). Always null for individuals." }
        notes:           { type: string, nullable: true }
        is_active:       { type: integer, description: "0 or 1" }
        created_by:      { type: string, format: uuid, nullable: true }
        created_at:      { type: string, format: date-time }
        updated_at:      { type: string, format: date-time, nullable: true }

    WorkSchedule:
      type: object
      required: [id, organization_id, name]
      description: |
        Row from `work_schedules` (Phase 4). A schedule is a named weekly
        work pattern (mon-sun time ranges) that can be assigned to many
        organization_users. `delete()` is a soft-delete (`is_active = 0`)
        and clears any `organization_users.schedule_id` still pointing
        at it. `pattern` is stored as JSON and decoded by
        ScheduleService::hydrate before being returned.

        Drift note: the spec-suggested fields (`employee_id` / `member_id`,
        `week_pattern`, `effective_from`, `effective_to`) do not exist on
        the table. Schedules are not per-employee; they are per-org and
        attached to members via the `organization_users.schedule_id`
        column or via `schedule_role_assignments`. The pattern column is
        named `pattern` (not `week_pattern`) and there are no effective
        date columns.
      properties:
        id:                  { type: string, format: uuid }
        organization_id:     { type: string, format: uuid }
        name:                { type: string }
        weekly_hours:        { type: number, nullable: true, description: "Optional 1-80; informational only" }
        pattern:
          type: object
          description: "Keys mon|tue|wed|thu|fri|sat|sun, each an array of {start: HH:MM, end: HH:MM} slots"
          additionalProperties: true
        default_start:       { type: string, nullable: true, description: "HH:MM fallback when a day has no slots" }
        default_end:         { type: string, nullable: true, description: "HH:MM fallback when a day has no slots" }
        break_minutes:       { type: integer, nullable: true, description: "0-600" }
        is_active:           { type: integer, description: "0/1 — set to 0 by soft delete" }
        created_at:          { type: string, format: date-time }
        updated_at:          { type: string, format: date-time, nullable: true }
        # Computed / joined fields surfaced by ScheduleService::list/get
        assigned_count:      { type: integer, nullable: true, description: "Count of organization_users.schedule_id matches (list/get)" }
        direct_users_count:  { type: integer, nullable: true, description: "Mirror of assigned_count (list endpoint only)" }
        assigned_roles:
          type: array
          nullable: true
          description: "List endpoint only — roles currently mapped to this schedule via schedule_role_assignments"
          items: { type: string }
        assignees:
          type: array
          nullable: true
          description: "Inlined on the detail (`GET /schedules/{id}`) endpoint"
          items: { $ref: '#/components/schemas/ScheduleAssignee' }

    ScheduleAssignee:
      type: object
      required: [org_user_id]
      description: |
        Row returned by `GET /schedules/{id}/assignees` and inlined on
        the schedule detail endpoint. Joins organization_users → users.
      properties:
        org_user_id:     { type: string, format: uuid, description: "organization_users.id — the value passed back as person_ids on assign/unassign" }
        user_id:         { type: string, format: uuid, nullable: true }
        position:        { type: string, nullable: true }
        department:      { type: string, nullable: true }
        employee_number: { type: string, nullable: true }
        name:            { type: string, nullable: true }
        email:           { type: string, format: email, nullable: true }
        avatar_url:      { type: string, nullable: true }

    ScheduleRoleAssignment:
      type: object
      required: [role, schedule_id]
      description: |
        Row from `schedule_role_assignments`. Returned by
        `GET /schedules/role-assignments` joined with `work_schedules.name`.
      properties:
        role:
          type: string
          enum: [owner, admin, manager, member, viewer]
        schedule_id:   { type: string, format: uuid }
        schedule_name: { type: string, nullable: true, description: "Joined from work_schedules" }

    EmployeeSchedule:
      type: object
      required: [user_id, organization_id]
      description: |
        Row from `employee_schedules` (legacy per-user schedule, served
        by EmployeeScheduleController under `/employee-schedules*`). New
        code should use the `WorkSchedule` family. The `getSchedule`
        endpoint synthesises a default row when none exists; `id` is
        only present once the row has been persisted via PUT.
      properties:
        id:              { type: string, format: uuid, nullable: true }
        user_id:         { type: string, format: uuid }
        organization_id: { type: string, format: uuid }
        timezone:        { type: string, nullable: true, description: "IANA tz; defaults to 'UTC'" }
        work_days:       { type: string, description: "JSON-encoded array of ISO weekday numbers (1=Mon..7=Sun); defaults to [1,2,3,4,5]" }
        work_start:      { type: string, nullable: true, description: "HH:MM; defaults to 09:00" }
        work_end:        { type: string, nullable: true, description: "HH:MM; defaults to 17:00" }
        created_at:      { type: string, format: date-time, nullable: true }
        updated_at:      { type: string, format: date-time, nullable: true }
        # List-endpoint joins from EmployeeScheduleController::listSchedules
        user_name:       { type: string, nullable: true }
        user_email:      { type: string, format: email, nullable: true }

    Holiday:
      type: object
      required: [id, organization_id, name, date]
      description: |
        Row from `org_holidays` as returned via the legacy
        `/holidays` endpoints (EmployeeScheduleController). The schema
        here mirrors the raw column shape — note the storage column is
        `date` and the recurring flag is `is_recurring` (NOT `recurring`,
        though the create/update bodies accept the unprefixed alias).

        Drift note: the spec-suggested `country_code` field does not
        exist on the legacy table. The newer `/org/holidays` route family
        (HolidayController, not exposed via scheduleApi.js) adds
        `holiday_type` / `region` and uses a different response wrapper
        — out of scope for this spec entry.
      properties:
        id:              { type: string, format: uuid }
        organization_id: { type: string, format: uuid }
        name:            { type: string }
        date:            { type: string, format: date }
        is_recurring:    { type: integer, description: "0/1 — TINYINT" }
        created_at:      { type: string, format: date-time, nullable: true }

    TimeOffRequest:
      type: object
      required: [id, organization_id, user_id, type, start_date, end_date, status]
      description: |
        Row from `employee_time_off` (legacy time-off table served by
        EmployeeScheduleController under `/time-off`). Distinct from the
        richer `LeaveRequest` family under `/leave/*`. The reason field
        on the wire is named `note` in the DB; create/update accept
        either `reason` or `note` (controller maps both to `note`).

        Drift note: spec-suggested `member_id`, `approved_at` and
        `approved_by` (UUID joined to user name) deviate slightly from
        the actual columns — the table tracks `user_id` (not member_id)
        and `approved_by` only; there is no `approved_at` column.
      properties:
        id:              { type: string, format: uuid }
        organization_id: { type: string, format: uuid }
        user_id:         { type: string, format: uuid }
        type:            { type: string, description: "vacation | sick | personal | … (free-form)" }
        start_date:      { type: string, format: date }
        end_date:        { type: string, format: date }
        note:            { type: string, nullable: true, description: "Free-form note / reason; create/update accept `reason` as alias" }
        status:
          type: string
          enum: [pending, approved, rejected]
        approved_by:     { type: string, format: uuid, nullable: true }
        created_at:      { type: string, format: date-time }
        # List-endpoint joins from EmployeeScheduleController::listTimeOff
        user_name:       { type: string, nullable: true }
        user_email:      { type: string, format: email, nullable: true }
        approver_name:   { type: string, nullable: true }

    LeavePolicy:
      type: object
      required: [id, org_id, name, leave_type]
      description: |
        Row from `leave_policies` as returned by LeaveController.
        `getPolicies` will lazy-seed the canonical Spanish defaults
        (Vacaciones, Asuntos propios, Enfermedad, Maternidad, Paternidad,
        Permiso sin sueldo) when the org has none.
      properties:
        id:                   { type: string, format: uuid }
        org_id:               { type: string, format: uuid }
        name:                 { type: string }
        leave_type:           { type: string, description: "vacation | personal | sick | maternity | paternity | unpaid | …" }
        days_per_year:        { type: number, description: "0 means unlimited / not capped (e.g. sick)" }
        accrual_method:       { type: string, description: "Defaults to 'annual'" }
        max_carryover:        { type: number, description: "Days that can roll over via /leave/balances/carry-over" }
        requires_approval:    { type: integer, description: "0/1 TINYINT" }
        requires_document:    { type: integer, description: "0/1 TINYINT" }
        min_notice_days:      { type: integer }
        max_consecutive_days: { type: integer, nullable: true }
        color:                { type: string, description: "Hex colour, e.g. #3b82f6" }
        is_paid:              { type: integer, description: "0/1 TINYINT" }
        is_active:            { type: integer, description: "0/1 TINYINT" }
        country:              { type: string, description: "Defaults to 'ES'" }

    LeaveBalance:
      type: object
      required: [id, employee_id, org_id, policy_id, year]
      description: |
        Row from `leave_balances`. Keyed by (employee_id, policy_id,
        year). `available` is computed by the DB / app layer as
        `entitled + carried_over + adjustments - used - pending`.
        `LeaveController::getBalances` returns a wrapped object — see
        the path entry for that response shape.

        Drift note: the spec-suggested `member_id` /
        `vacation_days_total` / `vacation_days_used` /
        `vacation_days_remaining` shape does not match — balances are
        per-policy, not vacation-only, and use the
        `entitled / used / pending / carried_over / adjustments / available`
        column set instead.
      properties:
        id:           { type: string, format: uuid }
        employee_id:  { type: string, format: uuid }
        org_id:       { type: string, format: uuid }
        policy_id:    { type: string, format: uuid }
        year:         { type: integer }
        entitled:     { type: number }
        used:         { type: number }
        pending:      { type: number }
        carried_over: { type: number }
        adjustments:  { type: number }
        available:    { type: number, description: "Computed: entitled + carried_over + adjustments - used - pending" }

    LeaveRequest:
      type: object
      required: [id, org_id, employee_id, policy_id, start_date, end_date, status]
      description: |
        Row from `leave_requests`. List/detail endpoints join
        `leave_policies` and `employees_view` for inline labels. Status
        flows: pending → approved (or rejected); approved/pending →
        cancelled. Approving a vacation request also mirrors the days
        into `timesheet_entries` (Wave 21A).
      properties:
        id:                { type: string, format: uuid }
        org_id:             { type: string, format: uuid }
        employee_id:        { type: string, format: uuid }
        policy_id:          { type: string, format: uuid }
        start_date:         { type: string, format: date }
        end_date:           { type: string, format: date }
        days:               { type: number, description: "Business days, computed (excludes weekends + org_holidays; respects half_day_*)" }
        half_day_start:     { type: integer, description: "0/1 TINYINT" }
        half_day_end:       { type: integer, description: "0/1 TINYINT" }
        reason:             { type: string, nullable: true }
        status:
          type: string
          enum: [pending, approved, rejected, cancelled]
        approved_by:        { type: string, format: uuid, nullable: true }
        approved_at:        { type: string, format: date-time, nullable: true }
        rejection_reason:   { type: string, nullable: true }
        document_url:       { type: string, nullable: true }
        created_at:         { type: string, format: date-time, nullable: true }
        updated_at:         { type: string, format: date-time, nullable: true }
        # Joined / denormalised fields
        policy_name:        { type: string, nullable: true, description: "From leave_policies — list/detail join" }
        leave_type:         { type: string, nullable: true, description: "From leave_policies — list/detail join" }
        color:              { type: string, nullable: true, description: "From leave_policies — list/detail join" }
        first_name:         { type: string, nullable: true, description: "From employees_view (split from `name`)" }
        last_name:          { type: string, nullable: true, description: "From employees_view (split from `name`)" }
        department:         { type: string, nullable: true }
        position:           { type: string, nullable: true }
        approver_name:      { type: string, nullable: true, description: "From users — list/detail join" }

    PayrollCompensation:
      type: object
      required: [id, org_id, employee_id, gross_annual, pay_frequency]
      description: |
        Row from `employee_compensation` as returned by
        PayrollV2Controller. Joined with `employees_view` to inline
        first_name / last_name / department / position; the list
        endpoint additionally returns `email` and `is_active`.
      properties:
        id:                     { type: string, format: uuid }
        org_id:                 { type: string, format: uuid }
        employee_id:            { type: string, format: uuid }
        gross_annual:           { type: number }
        pay_frequency:
          type: string
          enum: [monthly, biweekly, weekly]
        extra_payments:         { type: integer, description: "12 = prorrateo, 14 = with separate June + December extras (Spain default)" }
        currency:               { type: string, description: "ISO 4217, defaults to EUR" }
        effective_date:         { type: string, format: date }
        social_security_number: { type: string, nullable: true }
        irpf_pct:               { type: number, nullable: true, description: "Manual override; null lets the engine compute via progressive brackets" }
        contract_type:          { type: string, description: "indefinido | …" }
        work_group:             { type: string, nullable: true }
        seniority_date:         { type: string, format: date, nullable: true }
        created_at:             { type: string, format: date-time, nullable: true }
        updated_at:             { type: string, format: date-time, nullable: true }
        # Joined fields from employees_view
        first_name:             { type: string, nullable: true }
        last_name:              { type: string, nullable: true }
        email:                  { type: string, format: email, nullable: true, description: "List endpoint only" }
        department:             { type: string, nullable: true }
        position:               { type: string, nullable: true }
        is_active:              { type: integer, nullable: true, description: "List endpoint only — 0/1" }

    PayrollRun:
      type: object
      required: [id, org_id, period_year, period_month, type, status]
      description: |
        Row from `payroll_runs_v2`. Periods are encoded as
        `period_year` + `period_month` (1-12), not as a `period_start`
        /`period_end` date pair.

        Drift note: spec-suggested `period_start`, `period_end`,
        `items_count` are not columns on the table. The list endpoint
        adds `employee_count` (count of attached payslips). The detail
        endpoint inlines `payslips`. Allowed `type` values include
        liquidación and bonus runs, not only the spec's gross/net
        wording.
      properties:
        id:                  { type: string, format: uuid }
        org_id:              { type: string, format: uuid }
        period_year:         { type: integer }
        period_month:        { type: integer, description: "1-12" }
        type:
          type: string
          enum: [ordinary, extra_june, extra_december, liquidacion, bonus]
        status:
          type: string
          enum: [draft, calculating, calculated, approved, paid, cancelled]
        run_date:            { type: string, format: date, nullable: true }
        paid_date:           { type: string, format: date, nullable: true }
        total_gross:         { type: number, nullable: true }
        total_deductions:    { type: number, nullable: true }
        total_net:           { type: number, nullable: true }
        total_employer_cost: { type: number, nullable: true }
        notes:               { type: string, nullable: true }
        created_by:          { type: string, format: uuid, nullable: true }
        approved_by:         { type: string, format: uuid, nullable: true }
        created_at:          { type: string, format: date-time, nullable: true }
        updated_at:          { type: string, format: date-time, nullable: true }
        # Joined / inlined fields
        employee_count:      { type: integer, nullable: true, description: "Count of payroll_payslips for this run (list endpoint only)" }
        payslips:
          type: array
          nullable: true
          description: "Inlined on the detail / calculate response"
          items: { $ref: '#/components/schemas/PayrollPayslip' }

    PayrollPayslip:
      type: object
      required: [id, run_id, employee_id]
      description: |
        Row from `payroll_payslips`. The detail endpoint
        (`GET /payroll-v2/payslips/{id}`) joins the parent run's
        `period_year` / `period_month` / `type` / `run_date` and
        compensation metadata (`social_security_number`, `contract_type`,
        `seniority_date`).

        Drift note: the spec-suggested `gross / deductions / net /
        breakdown` shape is replaced by the explicit Spanish payroll
        breakdown columns (gross_salary, prorrateada_extra, total_gross,
        ss_*, irpf_*, total_deductions, net_pay, employer_cost, YTD
        accumulators).
      properties:
        id:                       { type: string, format: uuid }
        run_id:                   { type: string, format: uuid }
        employee_id:              { type: string, format: uuid }
        gross_salary:             { type: number }
        prorrateada_extra:        { type: number, description: "Prorrateo of extra pagas when extra_payments=12" }
        total_gross:              { type: number }
        ss_employee:              { type: number, description: "Contingencias comunes (employee share)" }
        ss_unemployment:          { type: number }
        ss_training:              { type: number }
        ss_total_employee:        { type: number }
        irpf_base:                { type: number }
        irpf_pct:                 { type: number, description: "0-47%" }
        irpf_amount:              { type: number }
        other_deductions:         { type: number }
        other_deductions_detail:  { type: string, nullable: true }
        total_deductions:         { type: number }
        net_pay:                  { type: number }
        ss_employer:              { type: number }
        ss_employer_unemployment: { type: number }
        ss_employer_training:     { type: number }
        ss_employer_fogasa:       { type: number }
        ss_total_employer:        { type: number }
        total_employer_cost:      { type: number }
        ytd_gross:                { type: number }
        ytd_irpf:                 { type: number }
        ytd_ss:                   { type: number }
        ytd_net:                  { type: number }
        created_at:               { type: string, format: date-time, nullable: true }
        # Joined fields
        first_name:               { type: string, nullable: true }
        last_name:                { type: string, nullable: true }
        email:                    { type: string, format: email, nullable: true, description: "Detail endpoint only" }
        department:               { type: string, nullable: true }
        position:                 { type: string, nullable: true }
        social_security_number:   { type: string, nullable: true, description: "Detail endpoint only" }
        contract_type:            { type: string, nullable: true, description: "Detail endpoint only" }
        seniority_date:           { type: string, format: date, nullable: true, description: "Detail endpoint only" }
        period_year:              { type: integer, nullable: true, description: "Detail endpoint only — joined from payroll_runs_v2" }
        period_month:             { type: integer, nullable: true, description: "Detail endpoint only" }
        run_type:                 { type: string, nullable: true, description: "Detail endpoint only" }
        run_date:                 { type: string, format: date, nullable: true, description: "Detail endpoint only" }

    IrpfBracket:
      type: object
      description: |
        Row from `irpf_tables`. Returned as a flat list by
        `GET /payroll-v2/irpf-table?year=YYYY` (sorted by `min_base`).
      additionalProperties: true # TODO — irpf_tables column set is environment-specific
      properties:
        year:     { type: integer }
        min_base: { type: number }
        max_base: { type: number, description: "Upper bound; the engine treats the highest bracket as effectively unbounded" }
        rate_pct: { type: number, description: "Percentage applied to the slice within this bracket" }

    SsContributionRate:
      type: object
      description: |
        Row from `ss_contribution_rates`. Returned as a flat list by
        `GET /payroll-v2/ss-rates?year=YYYY` (sorted by `concept`).
      additionalProperties: true # TODO — table also stores caps and other auxiliary columns
      properties:
        year:         { type: integer }
        concept:      { type: string, description: "contingencias_comunes | desempleo | formacion_profesional | fogasa | …" }
        employee_pct: { type: number, description: "Percentage" }
        employer_pct: { type: number, description: "Percentage" }
        min_base:     { type: number, nullable: true }
        max_base:     { type: number, nullable: true, description: "Used as the SS base cap during calculation" }

    # ── GitHub Integration ───────────────────────────────────────────
    GithubStatus:
      type: object
      description: Returned by `GET /github/status`. Reports the OAuth identity state for the calling user.
      properties:
        connected:      { type: boolean }
        has_repo_scope: { type: boolean }
        scopes:         { type: string, nullable: true, description: "Space-separated scopes from the stored token" }
        connected_at:   { type: string, format: date-time, nullable: true }

    GithubRepo:
      type: object
      description: |
        Item returned by `GET /github/repos`. Subset of the GitHub
        `/user/repos` response remapped server-side.
      properties:
        id:          { type: integer, description: "GitHub numeric repo id" }
        full_name:   { type: string }
        name:        { type: string }
        owner:       { type: string }
        private:     { type: boolean }
        html_url:    { type: string }
        description: { type: string, nullable: true }
        updated_at:  { type: string, format: date-time, nullable: true }

    GithubRepoLink:
      type: object
      description: |
        Row from `github_repo_links`. The list endpoint joins
        `projects` to inline `project_name` and `project_key`. The
        detail endpoint also adds a `stats` block with counts of
        linked issues / PRs / commits.

        `status_mapping` and `label_mapping` are JSON columns —
        clients receive raw strings (the wire format keeps PHP's
        `json_encode` output) unless a future transform is added.
      additionalProperties: true # TODO — wire format for JSON columns may stabilise
      properties:
        id:                    { type: string, format: uuid }
        org_id:                { type: string, format: uuid }
        project_id:            { type: string, format: uuid }
        github_repo_id:        { type: integer }
        github_repo_full_name: { type: string }
        github_repo_url:       { type: string, nullable: true }
        github_webhook_id:     { type: integer, nullable: true }
        github_webhook_secret: { type: string, nullable: true }
        sync_direction:        { type: string, description: "pull | push | both" }
        sync_issues:           { type: integer, description: "0/1" }
        sync_comments:         { type: integer, description: "0/1" }
        sync_prs:              { type: integer, description: "0/1" }
        sync_commits:          { type: integer, description: "0/1" }
        status_mapping:        { description: "JSON string or object" }
        label_mapping:         { description: "JSON string or object" }
        conflict_resolution:   { type: string, description: "last_write_wins | …" }
        status:                { type: string, description: "active | paused | error | setup" }
        linked_by:             { type: string, format: uuid, nullable: true }
        created_at:            { type: string, format: date-time, nullable: true }
        updated_at:            { type: string, format: date-time, nullable: true }
        # Joined fields (list / detail endpoints)
        project_name:          { type: string, nullable: true }
        project_key:           { type: string, nullable: true }
        # Detail-only block
        stats:
          type: object
          nullable: true
          properties:
            issues_linked:  { type: integer }
            prs_linked:     { type: integer }
            commits_linked: { type: integer }

    GithubSyncLogEntry:
      type: object
      description: Row from `github_sync_log` returned by `GET /github/links/{id}/sync-log`.
      additionalProperties: true # TODO — column set may evolve
      properties:
        id:           { type: string, format: uuid, nullable: true }
        repo_link_id: { type: string, format: uuid, nullable: true }
        type:         { type: string, nullable: true, description: "full | incremental" }
        status:       { type: string, nullable: true, description: "running | success | error" }
        started_at:   { type: string, format: date-time, nullable: true }
        finished_at:  { type: string, format: date-time, nullable: true }
        message:      { type: string, nullable: true }
        items_synced: { type: integer, nullable: true }

    GithubDisconnectInfo:
      type: object
      description: Pre-flight stats for `POST /github/disconnect`.
      properties:
        repos:          { type: array, items: { type: string }, description: "Repo full_names that would be unlinked" }
        repo_count:     { type: integer }
        issues_linked:  { type: integer }
        prs_linked:     { type: integer }
        commits_linked: { type: integer }

    GithubBackfillStats:
      type: object
      description: |
        Response from `POST /github/sync/backfill`. Aggregates
        commits + PRs scanned per linked repo and reports issues
        created / skipped along with any errors encountered.
      additionalProperties: true # TODO — `errors` is mixed (string list + per-repo objects)
      properties:
        repos:           { type: integer }
        commits_scanned: { type: integer }
        prs_scanned:     { type: integer }
        issues_created:  { type: integer }
        skipped:         { type: integer }
        errors:          { type: array, items: {} }
        per_repo:
          type: array
          items:
            type: object
            additionalProperties: true
            properties:
              repo:            { type: string }
              commits_scanned: { type: integer }
              prs_scanned:     { type: integer }
              issues_created:  { type: integer }
              skipped:         { type: integer }
              errors:          { type: array, items: { type: string } }

    GithubManualSyncResult:
      type: object
      description: Response from `POST /github/sync/manual`.
      properties:
        success:         { type: boolean }
        message:         { type: string, nullable: true }
        repos:           { type: integer }
        jobs_dispatched: { type: integer }
        errors:
          type: array
          items:
            type: object
            properties:
              repo:  { type: string }
              error: { type: string }

    IssueGithubData:
      type: object
      description: Response from `GET /issues/{issueId}/github`.
      properties:
        issue_link:
          type: object
          nullable: true
          additionalProperties: true # TODO — github_issue_links columns
        prs:
          type: array
          items:
            type: object
            additionalProperties: true # TODO — github_pr_links columns
        commits:
          type: array
          items:
            type: object
            additionalProperties: true # TODO — github_commit_links columns

    # ── Intake Forms ─────────────────────────────────────────────────
    IntakeFormField:
      type: object
      description: |
        One field definition inside `IntakeForm.fields`. Stored as a
        JSON column server-side; the controller does not enforce a
        rigid shape, so this schema is permissive.
      additionalProperties: true
      properties:
        key:         { type: string, nullable: true }
        name:        { type: string, nullable: true }
        label:       { type: string, nullable: true }
        type:        { type: string, nullable: true, description: "text | textarea | select | radio | checkbox | …" }
        required:    { type: boolean, nullable: true }
        placeholder: { type: string, nullable: true }
        options:     { type: array, items: { type: string }, nullable: true }

    IntakeForm:
      type: object
      description: |
        Row from `intake_forms` as returned by `GET /projects/{id}/intake-forms`
        and the create/update endpoints. `fields` is decoded from JSON
        before being sent.
      additionalProperties: true
      properties:
        id:               { type: string, format: uuid }
        project_id:       { type: string, format: uuid }
        slug:             { type: string }
        title:            { type: string }
        description:      { type: string, nullable: true }
        fields:
          type: array
          items: { $ref: '#/components/schemas/IntakeFormField' }
        auto_assign_to:   { type: string, format: uuid, nullable: true }
        auto_priority:    { type: string, description: "low | medium | high | critical" }
        auto_status:      { type: string, description: "Project column name; defaults to Backlog" }
        is_active:        { type: integer, description: "0/1" }
        submission_count: { type: integer }
        created_by:       { type: string, format: uuid, nullable: true }
        created_at:       { type: string, format: date-time, nullable: true }
        creator_name:     { type: string, nullable: true, description: "Joined from users (list endpoint)" }

    IntakeFormPublic:
      type: object
      description: Response from public `GET /forms/{slug}`. Strips internal columns.
      properties:
        id:           { type: string, format: uuid }
        title:        { type: string }
        description:  { type: string, nullable: true }
        fields:
          type: array
          items: { $ref: '#/components/schemas/IntakeFormField' }
        slug:         { type: string }
        is_active:    { type: integer, description: "0/1" }
        project_name: { type: string, nullable: true }

    IntakeFormSubmissionResult:
      type: object
      description: Response from public `POST /forms/{slug}/submit`.
      properties:
        success:   { type: boolean }
        message:   { type: string, nullable: true }
        issue_key: { type: string, nullable: true, description: "Created issue key (e.g. PRJ-42); absent if honeypot tripped" }

    # ── Financial Statements ─────────────────────────────────────────
    BalanceSheetLine:
      type: object
      properties:
        name:    { type: string }
        code:    { type: string, nullable: true }
        balance: { type: number }

    BalanceSheet:
      type: object
      description: |
        Inner `current` / `prior` block returned by
        `GET /statements/balance-sheet`. `source` indicates whether the
        numbers came from the `gl_*` tables or the
        invoices/expenses/payroll fallback.
      properties:
        assets:
          type: object
          properties:
            current:           { type: array, items: { $ref: '#/components/schemas/BalanceSheetLine' } }
            non_current:       { type: array, items: { $ref: '#/components/schemas/BalanceSheetLine' } }
            total_current:     { type: number }
            total_non_current: { type: number }
            total:             { type: number }
        liabilities:
          type: object
          properties:
            current:           { type: array, items: { $ref: '#/components/schemas/BalanceSheetLine' } }
            non_current:       { type: array, items: { $ref: '#/components/schemas/BalanceSheetLine' } }
            total_current:     { type: number }
            total_non_current: { type: number }
            total:             { type: number }
        equity:
          type: object
          properties:
            items: { type: array, items: { $ref: '#/components/schemas/BalanceSheetLine' } }
            total: { type: number }
        total_liabilities_equity: { type: number }
        balanced:                 { type: boolean }
        source:                   { type: string, description: "gl | fallback" }

    BalanceSheetResponse:
      type: object
      properties:
        as_of_date:  { type: string, format: date }
        currency:    { type: string }
        current:     { $ref: '#/components/schemas/BalanceSheet' }
        prior:       { $ref: '#/components/schemas/BalanceSheet', nullable: true }
        comparative: { type: boolean }

    CashFlowSection:
      type: object
      additionalProperties: true # TODO — keys vary per section (operating / investing / financing)
      properties:
        total: { type: number }
        line_items:
          type: array
          items:
            type: object
            properties:
              label:  { type: string }
              amount: { type: number }

    CashFlowStatement:
      type: object
      properties:
        operating:      { $ref: '#/components/schemas/CashFlowSection' }
        investing:      { $ref: '#/components/schemas/CashFlowSection' }
        financing:      { $ref: '#/components/schemas/CashFlowSection' }
        net_change:     { type: number }
        beginning_cash: { type: number }
        ending_cash:    { type: number }
        source:         { type: string, description: "gl | fallback" }

    CashFlowResponse:
      type: object
      properties:
        from_date:   { type: string, format: date }
        to_date:     { type: string, format: date }
        currency:    { type: string }
        current:     { $ref: '#/components/schemas/CashFlowStatement' }
        prior:       { $ref: '#/components/schemas/CashFlowStatement', nullable: true }
        comparative: { type: boolean }

    FinancialRatio:
      type: object
      description: One ratio entry inside the `ratios` map.
      properties:
        value:      { type: number }
        category:   { type: string, description: "liquidity | solvency | profitability | efficiency" }
        thresholds: { type: array, items: { type: number }, description: "[good, warn] cut-offs" }
        invert:     { type: boolean, nullable: true, description: "When true, lower values are better" }

    FinancialRatiosResponse:
      type: object
      description: Response from `GET /statements/ratios`.
      properties:
        as_of_date: { type: string, format: date }
        currency:   { type: string }
        ratios:
          type: object
          additionalProperties: { $ref: '#/components/schemas/FinancialRatio' }
        summary:
          type: object
          properties:
            total_assets:      { type: number }
            total_liabilities: { type: number }
            total_equity:      { type: number }
            cash_position:     { type: number }
            net_income:        { type: number }
            revenue:           { type: number }

    PnlSummary:
      type: object
      properties:
        revenue:      { type: number }
        expenses:     { type: number }
        payroll:      { type: number }
        gross_profit: { type: number }
        net_income:   { type: number }
        gross_margin: { type: number, description: "Percentage" }
        net_margin:   { type: number, description: "Percentage" }

    ComparativePeriod:
      type: object
      properties:
        start:         { type: string, format: date }
        end:           { type: string, format: date }
        pnl:           { $ref: '#/components/schemas/PnlSummary' }
        balance_sheet: { $ref: '#/components/schemas/BalanceSheet' }

    ComparativeStatement:
      type: object
      description: Response from `GET /statements/comparative`.
      properties:
        currency: { type: string }
        period_a: { $ref: '#/components/schemas/ComparativePeriod' }
        period_b: { $ref: '#/components/schemas/ComparativePeriod' }

    # ── General Ledger ───────────────────────────────────────────────
    LedgerAccount:
      type: object
      description: |
        Row from `gl_accounts`. The list endpoint
        (`GET /gl/accounts`) joins journal lines to compute
        `total_debit`, `total_credit` and `balance`. The detail endpoint
        (`GET /gl/accounts/{id}`) inlines the last 50 transactions in
        `transactions`.
      additionalProperties: true # TODO — additional GL columns may exist
      properties:
        id:           { type: string, format: uuid }
        org_id:       { type: string, format: uuid, nullable: true }
        code:         { type: string }
        name:         { type: string }
        type:         { type: string, description: "asset | liability | equity | revenue | expense" }
        parent_id:    { type: string, format: uuid, nullable: true }
        is_header:    { type: boolean }
        balance_type: { type: string, description: "debit | credit" }
        currency:     { type: string, description: "ISO 4217" }
        is_active:    { type: boolean }
        description:  { type: string, nullable: true }
        total_debit:  { type: number, nullable: true, description: "List endpoint only" }
        total_credit: { type: number, nullable: true, description: "List endpoint only" }
        balance:      { type: number, nullable: true, description: "Computed via balance_type" }
        created_at:   { type: string, format: date-time, nullable: true }
        updated_at:   { type: string, format: date-time, nullable: true }
        transactions:
          type: array
          nullable: true
          description: "Detail endpoint only — last 50 journal lines."
          items:
            type: object
            additionalProperties: true

    JournalLine:
      type: object
      description: Row from `gl_journal_lines`. The read paths join `gl_accounts` to inline `account_code` / `account_name` / `account_type`.
      additionalProperties: true
      properties:
        id:               { type: string, format: uuid, nullable: true }
        journal_entry_id: { type: string, format: uuid, nullable: true }
        account_id:       { type: string, format: uuid }
        debit:            { type: number }
        credit:           { type: number }
        description:      { type: string, nullable: true }
        cost_center_id:   { type: string, format: uuid, nullable: true }
        created_at:       { type: string, format: date-time, nullable: true }
        # Joined
        account_code:     { type: string, nullable: true }
        account_name:     { type: string, nullable: true }
        account_type:     { type: string, nullable: true }

    JournalEntry:
      type: object
      description: |
        Row from `gl_journal_entries`. The list endpoint adds
        `total_debit` / `total_credit` aggregates; the detail and
        create endpoints inline `lines`.
      additionalProperties: true
      properties:
        id:           { type: string, format: uuid }
        org_id:       { type: string, format: uuid }
        entry_number: { type: string, description: "Auto-incrementing JE-NNNNNN" }
        date:         { type: string, format: date }
        description:  { type: string }
        reference:    { type: string, nullable: true }
        source_type:  { type: string, description: "manual | invoice | expense | payroll | asset | adjustment" }
        source_id:    { type: string, format: uuid, nullable: true }
        status:       { type: string, description: "draft | posted | reversed" }
        posted_by:    { type: string, format: uuid, nullable: true }
        posted_at:    { type: string, format: date-time, nullable: true }
        reversed_by:  { type: string, format: uuid, nullable: true }
        reversed_at:  { type: string, format: date-time, nullable: true }
        created_by:   { type: string, format: uuid, nullable: true }
        created_at:   { type: string, format: date-time, nullable: true }
        updated_at:   { type: string, format: date-time, nullable: true }
        total_debit:  { type: number, nullable: true, description: "List endpoint only" }
        total_credit: { type: number, nullable: true, description: "List endpoint only" }
        lines:
          type: array
          nullable: true
          items: { $ref: '#/components/schemas/JournalLine' }

    JournalEntriesPage:
      type: object
      properties:
        data:       { type: array, items: { $ref: '#/components/schemas/JournalEntry' } }
        total:      { type: integer }
        page:       { type: integer }
        limit:      { type: integer }
        totalPages: { type: integer }

    TrialBalance:
      type: object
      description: Response from `GET /gl/trial-balance`.
      properties:
        accounts:
          type: array
          items:
            allOf:
              - $ref: '#/components/schemas/LedgerAccount'
              - type: object
                properties:
                  parent_id: { type: string, format: uuid, nullable: true }
        grand_debit:  { type: number }
        grand_credit: { type: number }
        balanced:     { type: boolean }
        year:         { type: integer }
        month:        { type: integer, description: "0 means full-year" }

    LedgerPeriod:
      type: object
      description: Row from `gl_periods`. `getPeriods` synthesises 12 entries per year, even for months with no DB row.
      properties:
        id:        { type: string, format: uuid, nullable: true }
        org_id:    { type: string, format: uuid, nullable: true }
        year:      { type: integer }
        month:     { type: integer, description: "1-12" }
        status:    { type: string, description: "open | closed | locked" }
        closed_by: { type: string, format: uuid, nullable: true }
        closed_at: { type: string, format: date-time, nullable: true }

    AccountLedgerLine:
      type: object
      additionalProperties: true
      properties:
        entry_number:    { type: string }
        date:            { type: string, format: date }
        entry_desc:      { type: string, nullable: true }
        source_type:     { type: string, nullable: true }
        reference:       { type: string, nullable: true }
        debit:           { type: number }
        credit:          { type: number }
        line_desc:       { type: string, nullable: true }
        running_balance: { type: number }

    AccountLedger:
      type: object
      description: Response from `GET /gl/accounts/{accountId}/ledger`.
      properties:
        account:         { $ref: '#/components/schemas/LedgerAccount' }
        lines:           { type: array, items: { $ref: '#/components/schemas/AccountLedgerLine' } }
        closing_balance: { type: number }
        from:            { type: string, nullable: true }
        to:              { type: string, nullable: true }

    # ── Gamification ─────────────────────────────────────────────────
    LeaderboardEntry:
      type: object
      description: Row from `GET /gamification/leaderboard`.
      properties:
        user_id:        { type: string, format: uuid }
        name:           { type: string, nullable: true }
        username:       { type: string, nullable: true }
        avatar_url:     { type: string, nullable: true }
        points_week:    { type: integer }
        points_total:   { type: integer }
        streak_current: { type: integer }
        badges_count:   { type: integer }
        rank:           { type: integer }

    Achievement:
      type: object
      description: Row from `GET /gamification/achievements` (definitions are hardcoded server-side).
      properties:
        key:         { type: string }
        name:        { type: string }
        description: { type: string }
        icon:        { type: string, description: "Lucide-style icon name" }
        unlocked:    { type: boolean }
        unlocked_at: { type: string, format: date-time, nullable: true }

    GamificationStats:
      type: object
      description: Response from `GET /gamification/my-stats`.
      properties:
        points_week:        { type: integer }
        points_month:       { type: integer }
        points_total:       { type: integer }
        streak_current:     { type: integer }
        streak_best:        { type: integer }
        last_activity_date: { type: string, format: date, nullable: true }
        rank:               { type: integer }
        achievements:
          type: array
          description: Compact list — only `achievement_key` + `unlocked_at`.
          items:
            type: object
            properties:
              achievement_key: { type: string }
              unlocked_at:     { type: string, format: date-time, nullable: true }

    # ── Global Search ────────────────────────────────────────────────
    SearchResult:
      type: object
      description: |
        Polymorphic hit returned by `GET /search`. The shape varies
        by `type` — the union of all observed keys is enumerated
        below; only `id`, `type`, `match_context` and `link` are
        guaranteed present across every entity type.
      additionalProperties: true
      properties:
        id:            { type: string, format: uuid }
        type:          { type: string, description: "project | issue | document | contact | invoice | expense" }
        name:          { type: string, nullable: true, description: "Display title (project.name, issue.title, doc.title, client.name, invoice.invoice_number, expense.description)" }
        match_context: { type: string }
        link:          { type: string, description: "Web app URL for the hit" }
        # Project / issue
        key:        { type: string, nullable: true }
        active:     { type: boolean, nullable: true }
        status:     { type: string, nullable: true }
        priority:   { type: string, nullable: true }
        project_id: { type: string, format: uuid, nullable: true }
        # Contact
        company: { type: string, nullable: true }
        email:   { type: string, nullable: true }
        # Invoice
        client_name: { type: string, nullable: true }
        total:       { type: number, nullable: true }
        # Expense
        vendor:   { type: string, nullable: true }
        amount:   { type: number, nullable: true }
        category: { type: string, nullable: true }

    SearchResults:
      type: object
      description: Response from `GET /search`.
      properties:
        results:
          type: object
          properties:
            projects: { type: array, items: { $ref: '#/components/schemas/SearchResult' } }
            issues:   { type: array, items: { $ref: '#/components/schemas/SearchResult' } }
            docs:     { type: array, items: { $ref: '#/components/schemas/SearchResult' } }
            contacts: { type: array, items: { $ref: '#/components/schemas/SearchResult' } }
            invoices: { type: array, items: { $ref: '#/components/schemas/SearchResult' } }
            expenses: { type: array, items: { $ref: '#/components/schemas/SearchResult' } }
        total: { type: integer }

    # ── Profiles & Social ────────────────────────────────────────────
    ProfilePrivacy:
      type: object
      description: Returned inside the own-profile response and by `PATCH /settings/privacy`.
      properties:
        visibility:           { type: string, description: "public | private" }
        allow_dm_anyone:      { type: boolean }
        allow_dm_mutuals:     { type: boolean }
        allow_global_search:  { type: boolean }
        show_orgs:            { type: boolean }
        show_projects:        { type: boolean }
        show_contributions:   { type: boolean }
        show_experience:      { type: boolean }
        show_education:       { type: boolean }
        show_certifications:  { type: boolean }

    ProfileExperience:
      type: object
      description: Row from `user_experiences`.
      additionalProperties: true
      properties:
        id:          { type: string, nullable: true }
        user_id:     { type: string, format: uuid, nullable: true }
        title:       { type: string }
        company:     { type: string }
        location:    { type: string, nullable: true }
        start_date:  { type: string, format: date, nullable: true }
        end_date:    { type: string, format: date, nullable: true }
        is_current:  { type: integer, description: "0/1" }
        description: { type: string, nullable: true }
        sort_order:  { type: integer }
        created_at:  { type: string, format: date-time, nullable: true }
        updated_at:  { type: string, format: date-time, nullable: true }

    ProfileEducation:
      type: object
      description: Row from `user_education`.
      additionalProperties: true
      properties:
        id:             { type: string, nullable: true }
        user_id:        { type: string, format: uuid, nullable: true }
        institution:    { type: string }
        degree:         { type: string, nullable: true }
        field_of_study: { type: string, nullable: true }
        start_year:     { type: integer, nullable: true }
        end_year:       { type: integer, nullable: true }
        description:    { type: string, nullable: true }
        sort_order:     { type: integer }
        created_at:     { type: string, format: date-time, nullable: true }
        updated_at:     { type: string, format: date-time, nullable: true }

    ProfileCertification:
      type: object
      description: Row from `user_certifications`.
      additionalProperties: true
      properties:
        id:             { type: string, nullable: true }
        user_id:        { type: string, format: uuid, nullable: true }
        title:          { type: string }
        issuing_org:    { type: string }
        issue_date:     { type: string, format: date, nullable: true }
        expiry_date:    { type: string, format: date, nullable: true }
        credential_id:  { type: string, nullable: true }
        credential_url: { type: string, nullable: true }
        sort_order:     { type: integer }
        created_at:     { type: string, format: date-time, nullable: true }
        updated_at:     { type: string, format: date-time, nullable: true }

    ProfileProject:
      type: object
      description: Inlined entry under `profile.projects` / `profile.pinned_projects`.
      additionalProperties: true
      properties:
        id:               { type: string, format: uuid }
        name:             { type: string }
        key:              { type: string, nullable: true }
        logo_url:         { type: string, nullable: true }
        org_name:         { type: string, nullable: true }
        role:             { type: string, nullable: true }
        open_tasks:       { type: integer, nullable: true }
        completed_tasks:  { type: integer, nullable: true }
        pinned:           { type: boolean, nullable: true }

    ProfileOrgMembership:
      type: object
      description: Inlined entry under `profile.organizations`.
      properties:
        id:       { type: string, format: uuid }
        name:     { type: string }
        logo_url: { type: string, nullable: true }
        role:     { type: string, nullable: true }

    PublicProfile:
      type: object
      description: |
        Response from `GET /profile/{username}`. When the profile is
        private and the viewer is not the owner, only a minimal card is
        returned (id, name, username, avatar_url, visibility, can_dm).
        `is_own_profile` toggles whether `privacy` is included.
      additionalProperties: true
      properties:
        id:               { type: string, format: uuid }
        name:             { type: string, nullable: true }
        display_name:     { type: string, nullable: true }
        username:         { type: string, nullable: true }
        avatar_url:       { type: string, nullable: true }
        created_at:       { type: string, format: date-time, nullable: true }
        last_active_at:   { type: string, format: date-time, nullable: true }
        headline:         { type: string, nullable: true }
        bio:              { type: string, nullable: true }
        location:         { type: string, nullable: true }
        timezone:         { type: string, nullable: true }
        website_url:      { type: string, nullable: true }
        linkedin_url:     { type: string, nullable: true }
        followers_count:  { type: integer, nullable: true }
        following_count:  { type: integer, nullable: true }
        projects_count:   { type: integer, nullable: true }
        orgs_count:       { type: integer, nullable: true }
        is_following:     { type: boolean, nullable: true }
        is_mutual:        { type: boolean, nullable: true }
        is_blocked:       { type: boolean, nullable: true }
        follows_you:      { type: boolean, nullable: true }
        is_own_profile:   { type: boolean, nullable: true }
        can_dm:           { type: boolean, nullable: true }
        visibility:       { type: string, nullable: true, description: "Present on the minimal-card variant" }
        show_contributions: { type: boolean, nullable: true }
        show_projects:      { type: boolean, nullable: true }
        projects:         { type: array, items: { $ref: '#/components/schemas/ProfileProject' } }
        pinned_projects:  { type: array, items: { $ref: '#/components/schemas/ProfileProject' } }
        organizations:    { type: array, items: { $ref: '#/components/schemas/ProfileOrgMembership' } }
        experiences:      { type: array, items: { $ref: '#/components/schemas/ProfileExperience' } }
        education:        { type: array, items: { $ref: '#/components/schemas/ProfileEducation' } }
        certifications:   { type: array, items: { $ref: '#/components/schemas/ProfileCertification' } }
        privacy:          { $ref: '#/components/schemas/ProfilePrivacy' }

    ProfileSettings:
      type: object
      description: Response from `PATCH /settings/profile` — flat user + profile snapshot.
      properties:
        id:           { type: string, format: uuid }
        name:         { type: string, nullable: true }
        username:     { type: string, nullable: true }
        avatar_url:   { type: string, nullable: true }
        theme:        { type: string, nullable: true, description: "light | dark | alpine | deep-dark" }
        display_name: { type: string, nullable: true }
        headline:     { type: string, nullable: true }
        bio:          { type: string, nullable: true }
        location:     { type: string, nullable: true }
        timezone:     { type: string, nullable: true }
        website_url:  { type: string, nullable: true }
        linkedin_url: { type: string, nullable: true }

    ProfileActivityItem:
      type: object
      description: Row from `activity_logs` returned by `GET /profile/{username}/activity`.
      additionalProperties: true
      properties:
        id:           { type: string, format: uuid, nullable: true }
        action:       { type: string, nullable: true }
        description:  { type: string, nullable: true }
        metadata:     { type: object, additionalProperties: true, nullable: true }
        created_at:   { type: string, format: date-time, nullable: true }
        project_id:   { type: string, format: uuid, nullable: true }
        project_name: { type: string, nullable: true }

    ProfileActivityFeed:
      type: object
      description: Paginated response from `GET /profile/{username}/activity`.
      properties:
        items:       { type: array, items: { $ref: '#/components/schemas/ProfileActivityItem' } }
        next_cursor: { type: string, format: date-time, nullable: true }

    ProfileHeatmap:
      type: object
      description: |
        Response from `GET /profile/{username}/heatmap`. `days` maps
        `YYYY-MM-DD` → activity count for the last 365 days. Empty
        results return an object literal (`{}`), not `[]`.
      additionalProperties: true
      properties:
        days:                   { type: object, additionalProperties: { type: integer }, description: "date → count map (may be empty {})" }
        total:                  { type: integer }
        streak:                 { type: integer }
        most_active_day:        { type: string, format: date, nullable: true }
        period:                 { type: string, description: "week | month | quarter" }
        period_total:           { type: integer }
        period_start:           { type: string, format: date }
        period_most_active_day: { type: string, format: date, nullable: true }

    PersonSearchResult:
      type: object
      description: Item returned by `GET /people/search`.
      properties:
        id:           { type: string, format: uuid }
        username:     { type: string, nullable: true }
        name:         { type: string, nullable: true }
        avatar_url:   { type: string, nullable: true }
        headline:     { type: string, nullable: true }
        is_following: { type: boolean, nullable: true }

    OrgSearchHit:
      type: object
      description: |
        Item under `results` from `GET /orgs/search`. Backed by
        `OrganizationController::search`, which merges org rows with
        unique `user_experiences.company` strings — `is_org=false` rows
        therefore have `org_id=null`.
      properties:
        name:     { type: string }
        logo_url: { type: string, nullable: true }
        is_org:   { type: boolean }
        org_id:   { type: string, format: uuid, nullable: true }

    OrgSearchResults:
      type: object
      properties:
        results: { type: array, items: { $ref: '#/components/schemas/OrgSearchHit' } }

    OrgProfile:
      type: object
      description: Response from `GET /org/{slug}` (public org page).
      additionalProperties: true
      properties:
        id:             { type: string, format: uuid }
        name:           { type: string }
        slug:           { type: string }
        description:    { type: string, nullable: true }
        headline:       { type: string, nullable: true }
        logo_url:       { type: string, nullable: true }
        website_url:    { type: string, nullable: true }
        location:       { type: string, nullable: true }
        industry:       { type: string, nullable: true }
        size_range:     { type: string, nullable: true }
        founded_year:   { type: integer, nullable: true }
        is_public:      { type: boolean }
        created_at:     { type: string, format: date-time, nullable: true }
        member_count:   { type: integer }
        follower_count: { type: integer }
        is_following:   { type: boolean }
        is_member:      { type: boolean }
        members:
          type: array
          items:
            type: object
            properties:
              id:         { type: string, format: uuid }
              name:       { type: string, nullable: true }
              username:   { type: string, nullable: true }
              avatar_url: { type: string, nullable: true }
              role:       { type: string, nullable: true }
        projects:
          type: array
          items:
            type: object
            properties:
              id:         { type: string, format: uuid }
              name:       { type: string }
              key:        { type: string, nullable: true }
              logo_url:   { type: string, nullable: true }
              created_at: { type: string, format: date-time, nullable: true }

    OrgFollowResult:
      type: object
      description: Response from `POST/DELETE /org/{slug}/follow`.
      properties:
        following:      { type: boolean }
        follower_count: { type: integer }

    SocialUserCard:
      type: object
      description: Common shape used inside followers/following list responses.
      properties:
        id:           { type: string, format: uuid }
        username:     { type: string, nullable: true }
        name:         { type: string, nullable: true }
        display_name: { type: string, nullable: true }
        avatar_url:   { type: string, nullable: true }
        created_at:   { type: string, format: date-time, nullable: true }
        is_following: { type: boolean, nullable: true }
        is_mutual:    { type: boolean, nullable: true }

    SocialFollowers:
      type: object
      description: Response from `GET /social/{username}/followers`.
      properties:
        followers:   { type: array, items: { $ref: '#/components/schemas/SocialUserCard' } }
        has_more:    { type: boolean }
        next_cursor: { type: string, format: date-time, nullable: true }

    SocialFollowing:
      type: object
      description: Response from `GET /social/{username}/following`.
      properties:
        following:   { type: array, items: { $ref: '#/components/schemas/SocialUserCard' } }
        has_more:    { type: boolean }
        next_cursor: { type: string, format: date-time, nullable: true }

    SocialBlockedList:
      type: object
      description: Response from `GET /social/blocked`.
      properties:
        blocked:
          type: array
          items:
            allOf:
              - $ref: '#/components/schemas/SocialUserCard'
              - type: object
                properties:
                  blocked_at: { type: string, format: date-time, nullable: true }

    SocialFollowResult:
      type: object
      description: Response from `POST/DELETE /social/follow/{userId}`.
      properties:
        success:   { type: boolean }
        following: { type: boolean }
        is_mutual: { type: boolean, nullable: true }

    SocialBlockResult:
      type: object
      description: Response from `POST/DELETE /social/block/{userId}`.
      properties:
        success: { type: boolean }
        blocked: { type: boolean }

    PinnedProjectsResult:
      type: object
      description: Response from `PATCH /settings/profile/pins`.
      properties:
        pinned_projects:
          type: array
          items: { type: string, format: uuid }


    # ── Sprint 10: teamApi.js + issueApi.js residue ──────────────────

    Employee:
      type: object
      required: [id, organization_id, first_name, last_name]
      description: |
        HR employee row from the `employees` table. `is_active` is a 0/1
        flag; `deactivate` toggles it (it is NOT a one-way action). The
        controller dual-writes mirror fields into `organization_users`
        during the Wave 1 → Wave 3 transition. `show` adds a
        `payroll_history` array of the last 10 payroll items joined onto
        their runs.
      additionalProperties: true # TODO — controllers SELECT * so columns may include phone, national_id, contract_type, etc.
      properties:
        id:              { type: string, format: uuid }
        organization_id: { type: string, format: uuid }
        user_id:         { type: string, format: uuid, nullable: true, description: "Optional link to an auth user; HR-only rows leave this null" }
        first_name:      { type: string }
        last_name:       { type: string }
        email:           { type: string, format: email, nullable: true }
        position:        { type: string, nullable: true }
        department:      { type: string, nullable: true }
        base_salary:     { type: number, nullable: true }
        currency:        { type: string, nullable: true, description: "ISO 4217 — defaults to EUR on create" }
        hire_date:       { type: string, format: date, nullable: true }
        is_active:       { type: integer, enum: [0, 1] }
        created_by:      { type: string, format: uuid, nullable: true }
        created_at:      { type: string, format: date-time }
        updated_at:      { type: string, format: date-time, nullable: true }

    EmployeeFields:
      type: object
      description: |
        Distinct department/position values for the org — NOT a dynamic
        field-definition list. Returned by `GET /employees/fields`.
      properties:
        departments: { type: array, items: { type: string } }
        positions:   { type: array, items: { type: string } }

    EmployeeBulkResult:
      type: object
      description: Response from `POST /employees/bulk`.
      properties:
        processed: { type: integer }
        skipped:   { type: integer }
        errors:    { type: array, items: { type: string } }

    EmployeeDepartmentStats:
      type: object
      description: Response from `GET /employees/department-stats`.
      properties:
        departments:
          type: array
          items:
            type: object
            properties:
              department:   { type: string }
              count:        { type: integer }
              active_count: { type: integer }
        total: { type: integer }

    PayrollRunLegacy:
      type: object
      required: [id, organization_id, name, period_start, period_end, status]
      description: |
        Row from the legacy `payroll_runs` table — distinct from
        `PayrollRun` (which is the v2 engine). `status` enum is
        draft|approved|paid. Approval requires an `approver` row in
        `payroll_signatures` and a draft status. Detail/show response
        includes `items` and `signatures` arrays.
      additionalProperties: true # TODO — backend SELECT * may include extra columns (created_by, approved_by, etc.)
      properties:
        id:               { type: string, format: uuid }
        organization_id:  { type: string, format: uuid }
        name:             { type: string }
        period_start:     { type: string, format: date }
        period_end:       { type: string, format: date }
        status:           { type: string, enum: [draft, approved, paid] }
        total_gross:      { type: number }
        total_deductions: { type: number }
        total_net:        { type: number }
        employee_count:   { type: integer, description: "Only on list endpoint" }
        created_by:       { type: string, format: uuid, nullable: true }
        approved_by:      { type: string, format: uuid, nullable: true }
        created_at:       { type: string, format: date-time }
        updated_at:       { type: string, format: date-time, nullable: true }
        items:
          type: array
          description: "Only on detail / create / update / approve responses"
          items: { $ref: '#/components/schemas/PayrollRunLegacyItem' }
        signatures:
          type: array
          description: "Only on detail / export responses"
          items: { $ref: '#/components/schemas/PayrollSignature' }

    PayrollRunLegacyItem:
      type: object
      required: [id, payroll_run_id]
      properties:
        id:             { type: string, format: uuid }
        payroll_run_id: { type: string, format: uuid }
        employee_id:    { type: string, format: uuid, nullable: true }
        user_id:        { type: string, format: uuid, nullable: true }
        employee_name:  { type: string }
        role:           { type: string, nullable: true }
        gross_salary:   { type: number }
        deductions:     { type: number }
        net_salary:     { type: number }
        notes:          { type: string, nullable: true }
        sort_order:     { type: integer }

    PayrollSignature:
      type: object
      properties:
        id:             { type: string, format: uuid }
        signer_role:    { type: string, enum: [creator, approver] }
        user_id:        { type: string, format: uuid, nullable: true }
        signer_name:    { type: string }
        signer_title:   { type: string, nullable: true }
        signature_mode: { type: string, enum: [draw, typed] }
        signature_data: { type: string, description: "Base64 PNG (draw) or empty (typed)" }
        typed_text:     { type: string, nullable: true }
        signed_at:      { type: string, format: date-time }

    PayrollExportData:
      type: object
      description: Response from `GET /payroll/{id}/export` — bundle for client-side PDF rendering.
      properties:
        run:          { $ref: '#/components/schemas/PayrollRunLegacy' }
        items:
          type: array
          items: { $ref: '#/components/schemas/PayrollRunLegacyItem' }
        signatures:
          type: array
          items: { $ref: '#/components/schemas/PayrollSignature' }
        org_name:     { type: string }
        org_logo_url: { type: string, nullable: true, description: "data:image/...;base64 URI (logo embedded for offline PDF render)" }

    CapacityRow:
      type: object
      description: Single member row in `GET /capacity`.
      properties:
        user_id:                { type: string, format: uuid }
        name:                   { type: string, nullable: true }
        avatar_url:             { type: string, nullable: true }
        role:                   { type: string, nullable: true }
        assigned_issues_count:  { type: integer }
        total_estimated_hours:  { type: number }
        completed_hours:        { type: number, description: "Sum of estimated_hours of issues completed in the last 7 days" }
        projects:
          type: array
          items:
            type: object
            properties:
              project_id:      { type: string, format: uuid }
              project_name:    { type: string }
              issue_count:     { type: integer }
              estimated_hours: { type: number }

    CapacityResult:
      type: object
      description: Response from `GET /capacity`.
      properties:
        members:            { type: array, items: { $ref: '#/components/schemas/CapacityRow' } }
        overload_threshold: { type: number, description: "Hours per week above which a member is considered overloaded" }

    CapacityThreshold:
      type: object
      description: Response from `GET/POST /capacity/threshold`. Same handler serves both verbs.
      properties:
        overload_threshold: { type: number }

    NotificationPreferenceRow:
      type: object
      properties:
        type:   { type: string, description: "Notification type key (issue_assigned, mention, …) — see DigestService::NOTIFICATION_TYPES" }
        in_app: { type: integer, enum: [0, 1] }
        email:  { type: integer, enum: [0, 1] }
        push:   { type: integer, enum: [0, 1], description: "Defaults to 1 when the `push` column is not yet migrated" }

    NotificationPreferences:
      type: object
      description: Response from `GET /notifications/preferences`. Defaults are all-on for unsaved types.
      properties:
        preferences:
          type: array
          items: { $ref: '#/components/schemas/NotificationPreferenceRow' }

    NotificationDigest:
      type: object
      description: "Response from `GET /notifications/digest`. Defaults: daily/disabled."
      properties:
        digest:
          type: object
          properties:
            frequency:    { type: string, enum: [daily, weekly] }
            enabled:      { type: integer, enum: [0, 1] }
            last_sent_at: { type: string, format: date-time, nullable: true }

    PushSubscriptionResult:
      type: object
      description: Response from `POST /push/subscribe`.
      properties:
        id:         { type: string, format: uuid }
        subscribed: { type: boolean }

    Favorite:
      type: object
      required: [id, entity_type, entity_id]
      description: |
        Row from `favorites`. Scoped per (user, org). `entity_type` is
        currently restricted to project|issue. The `entity` field is
        enriched server-side and shape varies by type.
      properties:
        id:          { type: string, format: uuid }
        entity_type: { type: string, enum: [project, issue] }
        entity_id:   { type: string, format: uuid }
        sort_order:  { type: integer }
        created_at:  { type: string, format: date-time }
        entity:
          description: "Resolved project or issue snapshot — null if the underlying row was deleted"
          oneOf:
            - { type: object, additionalProperties: true }
            - { type: 'null' }

    FavoritesList:
      type: object
      description: Response from `GET /favorites`.
      properties:
        favorites:
          type: array
          items: { $ref: '#/components/schemas/Favorite' }

    DashboardSummary:
      type: object
      description: |
        Response from `GET /dashboard`. Note the camelCase keys —
        DashboardController returns this shape verbatim and the SPA
        depends on it.
      properties:
        openIssues:    { type: integer }
        closedIssues:  { type: integer }
        totalIssues:   { type: integer }
        totalProjects: { type: integer }
        workload:      { type: object, additionalProperties: { type: integer }, description: "Map of assignee_id to open issue count" }
        byPriority:
          type: object
          properties:
            critical: { type: integer }
            high:     { type: integer }
            medium:   { type: integer }
            low:      { type: integer }
        byStatus:
          type: object
          additionalProperties: { type: integer }
          description: "Map of normalised status to count"
        activity:
          type: array
          items: { type: object, additionalProperties: true } # TODO — activity_logs row shape, see ActivityController
        dailyActivity:
          type: array
          items:
            type: object
            properties:
              day:   { type: string, format: date }
              count: { type: integer }

    Addon:
      type: object
      description: |
        Catalog entry from `AddonService::getCatalog`. `installed` is
        injected by the controller. Distinct from
        `/admin/organizations/{orgId}/addons` (platform-admin shape).
      additionalProperties: true # TODO — service-defined extras (icon, category, plan_required, …)
      properties:
        slug:        { type: string }
        name:        { type: string }
        installed:   { type: boolean }
        description: { type: string, nullable: true }

    AddonCatalogResult:
      type: object
      description: Response from `GET /addons`.
      properties:
        addons:
          type: array
          items: { $ref: '#/components/schemas/Addon' }
        installed:
          type: array
          items: { type: string }
          description: "Slugs currently installed for the org"
        plan: { type: string, description: "Org plan, e.g. free|pro|business" }

    AddonReview:
      type: object
      additionalProperties: true # TODO — service-defined (author, rating, text, created_at, …)

    AddonReviewsResult:
      type: object
      description: Response from `GET /addons/{slug}/reviews`.
      properties:
        reviews:
          type: array
          items: { $ref: '#/components/schemas/AddonReview' }
        user_rating: { type: integer, nullable: true, minimum: 1, maximum: 5 }
        user_review: { type: string, nullable: true }

    Integration:
      type: object
      required: [type, name, status]
      description: |
        Row returned by `GET /integrations`. Merges the static
        availableIntegrations catalog with any `integrations` table
        rows for the current org. GitHub status is derived from
        `oauth_identities` / `github_repo_links` rather than the
        integrations table.
      properties:
        id:           { type: string, format: uuid, nullable: true }
        type:         { type: string, enum: [github, slack, discord, google_calendar, gitlab, jira, trello, webhook] }
        name:         { type: string }
        description:  { type: string }
        status:       { type: string, enum: [connected, disconnected] }
        connected_at: { type: string, format: date-time, nullable: true }
        config:
          oneOf:
            - { type: object, additionalProperties: true }
            - { type: 'null' }

    IntegrationToggleResult:
      type: object
      description: Response from `POST /integrations/{id}/connect|disconnect`.
      properties:
        success: { type: boolean }
        status:  { type: string, enum: [connected, disconnected] }

    Whitelabel:
      type: object
      description: |
        Whitelabel config (Business / Enterprise). The PUT endpoint
        sanitises and replaces the whole document; the GET endpoint
        falls back to a null-filled default when nothing is saved.
      properties:
        custom_domain:     { type: string, nullable: true }
        logo_url:          { type: string, nullable: true, description: "Absolute http(s) URL or /uploads/... relative path" }
        favicon_url:       { type: string, nullable: true }
        brand_name:        { type: string, nullable: true }
        primary_color:     { type: string, nullable: true, description: "#RRGGBB hex" }
        accent_color:      { type: string, nullable: true, description: "#RRGGBB hex" }
        login_bg_url:      { type: string, nullable: true }
        email_from_name:   { type: string, nullable: true }
        email_footer_text: { type: string, nullable: true }
        hide_powered_by:   { type: boolean }

    OrgJoinByCode:
      type: object
      required: [invite_code]
      properties:
        invite_code: { type: string, maxLength: 20 }

    Sprint:
      type: object
      required: [id, project_id, name, status]
      description: |
        Row from `sprints`. Status enum is `planning|active|completed`
        (NOT "planned"). Best-effort Google Calendar sync runs inline
        on create/update.
      properties:
        id:         { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        name:       { type: string }
        goal:       { type: string, nullable: true }
        status:     { type: string, enum: [planning, active, completed] }
        start_date: { type: string, format: date, nullable: true }
        end_date:   { type: string, format: date, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time, nullable: true }

    IssueDependency:
      type: object
      required: [id, source_id, target_id]
      description: |
        Row from `issue_dependencies`. Uses `source_id`/`target_id`
        (NOT `issue_id`/`depends_on_issue_id`). `index` returns deps
        where the issue is on either side of the relation.
      properties:
        id:         { type: string, format: uuid }
        source_id:  { type: string, format: uuid }
        target_id:  { type: string, format: uuid }
        type:       { type: string, enum: [blocks, blocked_by, relates_to] }
        created_at: { type: string, format: date-time }

    IssueFilter:
      type: object
      required: [id, name, filters]
      description: |
        Row from `saved_filters`. The body field is `filters` (a JSON
        object), not `query`. There is no `shared` flag on this table —
        scoped per (user, org).
      properties:
        id:              { type: string, format: uuid }
        user_id:         { type: string, format: uuid }
        organization_id: { type: string, format: uuid }
        name:            { type: string }
        filters:         { type: object, additionalProperties: true }
        created_at:      { type: string, format: date-time }

    ActivityEntry:
      type: object
      additionalProperties: true # TODO — activity_logs SELECT * + user_name / user_avatar_url join
      properties:
        id:              { type: string, format: uuid }
        organization_id: { type: string, format: uuid }
        project_id:      { type: string, format: uuid, nullable: true }
        user_id:         { type: string, format: uuid, nullable: true }
        action:          { type: string }
        description:     { type: string, nullable: true }
        entity_type:     { type: string, nullable: true }
        entity_id:       { type: string, nullable: true }
        metadata:
          oneOf:
            - { type: object, additionalProperties: true }
            - { type: 'null' }
        user_name:       { type: string, nullable: true }
        user_avatar_url: { type: string, nullable: true }
        created_at:      { type: string, format: date-time }

    ActivityList:
      type: object
      description: Response from `GET /activity`.
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ActivityEntry' }
        total:  { type: integer }
        limit:  { type: integer }
        offset: { type: integer }

    BulkIssueActionResult:
      type: object
      description: |
        Response from `POST /issues/bulk`. The MyWork variant adds
        `updated` and `failed` keys — see `MyWorkBulkResult`.
      properties:
        affected: { type: integer }

    MyWorkBulkResult:
      type: object
      description: |
        Response from `POST /mywork/bulk`. Returns 422 with the same
        shape (plus `error`) when no submitted ids were valid.
      properties:
        updated:  { type: integer }
        affected: { type: integer }
        failed:
          type: array
          items: { type: string, format: uuid }
        error:    { type: string, description: "Only present on the 422 path" }

    SavedView:
      type: object
      additionalProperties: true # TODO — user_saved_views row
      properties:
        id:        { type: string, format: uuid }
        user_id:   { type: string, format: uuid }
        org_id:    { type: string, format: uuid }
        name:      { type: string }
        filters:   { type: object, additionalProperties: true }
        sort_by:   { type: string, nullable: true }
        group_by:  { type: string, nullable: true }
        is_shared: { type: integer, enum: [0, 1] }
        created_at: { type: string, format: date-time }

    CsvExport:
      type: object
      description: |
        Generic JSON-wrapped CSV payload. The `/exports/*` and
        `/mywork/export` endpoints return CSV inline as a string —
        they do NOT stream a Blob (despite the JS naming).
      properties:
        csv:      { type: string }
        filename: { type: string, description: "Suggested download filename — only present on /exports/*" }

    ImportParseResult:
      type: object
      description: Response from `POST /imports/parse`.
      additionalProperties: true # TODO — ImportService::parseFile output (rows, mapping suggestions, …)
      properties:
        import_id:   { type: string, format: uuid }
        source_type: { type: string }
        total:       { type: integer }
        rows:
          type: array
          items: { type: object, additionalProperties: true }

    ImportExecuteResult:
      type: object
      additionalProperties: true # TODO — ImportService::executeImport output
      properties:
        imported: { type: integer }
        errors:
          type: array
          items: { type: object, additionalProperties: true }

    GlobalSearchResult:
      type: object
      description: Response from `GET /search`.
      properties:
        results:
          type: object
          properties:
            projects: { type: array, items: { type: object, additionalProperties: true } }
            issues:   { type: array, items: { type: object, additionalProperties: true } }
            docs:     { type: array, items: { type: object, additionalProperties: true } }
            contacts: { type: array, items: { type: object, additionalProperties: true } }
            invoices: { type: array, items: { type: object, additionalProperties: true } }
            expenses: { type: array, items: { type: object, additionalProperties: true } }
        total: { type: integer }

    EstimationAccuracy:
      type: object
      description: Response from `GET /projects/{id}/estimation-accuracy`.
      additionalProperties: true # TODO — controller returns members + history; full shape varies
      properties:
        members:
          type: array
          items: { type: object, additionalProperties: true }
        history:
          type: array
          items: { type: object, additionalProperties: true }

    SprintBurndown:
      type: object
      description: Response from `GET /projects/{id}/sprints/{sprintId}/burndown`.
      additionalProperties: true # TODO — `data_points`, `prediction`, `velocity` shapes vary
      properties:
        sprint:      { $ref: '#/components/schemas/Sprint' }
        data_points:
          type: array
          items: { type: object, additionalProperties: true }
        prediction:
          oneOf:
            - { type: object, additionalProperties: true }
            - { type: 'null' }
        velocity:    { type: number }
        message:     { type: string, description: "Set when sprint has no start/end dates" }

    OrgSearchResult:
      type: object
      description: Response from `GET /orgs/search`.
      properties:
        results:
          type: array
          items:
            type: object
            properties:
              name:     { type: string }
              logo_url: { type: string, nullable: true }
              is_org:   { type: boolean }
              org_id:   { type: string, format: uuid, nullable: true }

security:
  - bearerAuth: []

paths:
  # ── Auth ──────────────────────────────────────────────────────────
  /auth/magic/request:
    post:
      summary: Request a magic-link login email
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        '200':
          description: Email sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:    { type: string }
                  _dev_link:  { type: string, nullable: true, description: "Only in development mode" }

  /auth/magic/verify:
    post:
      summary: Exchange a magic-link token for a session
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        '200':
          description: Authenticated
          content:
            application/json:
              schema:
                type: object
                required: [token, user]
                properties:
                  token:        { type: string }
                  expires_at:   { type: string, format: date-time }
                  user:         { $ref: '#/components/schemas/User' }
                  organization: { $ref: '#/components/schemas/Organization' }
                  role:         { type: string }
                  isNewUser:    { type: boolean }
        '401':
          description: Invalid or expired token
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /auth/refresh:
    post:
      summary: Refresh the current session token
      responses:
        '200':
          description: Refreshed
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:      { type: string }
                  expires_at: { type: string, format: date-time }

  # ── Identity ─────────────────────────────────────────────────────
  /me:
    get:
      summary: Get the authenticated user + current org context
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Me' }

  # NOTE: `/me/organizations` was previously listed here but is not
  # registered in `server/Core/App.php`. The list of organisations is
  # already returned inline by `GET /me` (see Me schema → `organizations`).

  # ── Projects ─────────────────────────────────────────────────────
  /projects:
    get:
      summary: List projects in the current org
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: include_shared, in: query, schema: { type: boolean }, description: "Include cross-org shared projects" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Project' }
    post:
      summary: Create a project
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:        { type: string }
                key:         { type: string }
                description: { type: string }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }

  /projects/{projectId}:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get a project
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
    put:
      summary: Update a project
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:        { type: string }
                key:         { type: string }
                description: { type: string }
                status:
                  type: string
                  enum: [active, archived, paused]
                logo_url:    { type: string, nullable: true }
                active:      { type: boolean }
                columns:
                  type: array
                  items: { type: string }
                  description: "Replaces the board columns / statuses (max 20)"
                kanban_config:
                  type: object
                  nullable: true
                  description: "Kanban tuning (e.g. wip_limits keyed by column name)"
                  additionalProperties: true
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '403':
          description: Insufficient permissions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Project not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Delete a project
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '403':
          description: Insufficient permissions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Project not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /projects/{projectId}/active:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    patch:
      summary: Toggle a project's active flag
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                active:
                  type: boolean
                  description: "Omit to flip the current value"
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '404':
          description: Project not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /projects/{projectId}/logo:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Upload a project logo
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '400':
          description: No file uploaded
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Project not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /projects/{projectId}/members:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
  # NOTE: GET /projects/{projectId}/members no está expuesto como ruta
  # de aplicación. La derivación actual ("members of the project = members
  # of the project's org") se ofrece como diagnóstico admin via
  # /admin/projects/{id}/members. POST/DELETE /projects/{projectId}/members
  # tampoco existen — la gestión de pertenencia se hace a nivel org via
  # /orgs/{orgId}/members.

  # NOTE: PATCH /projects/{projectId}/columns nunca se llegó a registrar.
  # Para cambiar las columnas del tablero usa
  #   PUT /projects/{projectId}
  # con `{"columns":["Backlog","Doing","Review","Done"]}`. El mismo PUT
  # acepta opcionalmente `kanban_config` para wip-limits por columna.

  /projects/{projectId}/docs:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List docs in a project
      parameters:
        - { name: include_archived, in: query, schema: { type: boolean }, description: "Include archived docs" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ProjectDoc' }
        '404':
          description: Project not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    post:
      summary: Create a project doc
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:         { type: string }
                parent_doc_id: { type: string, format: uuid, nullable: true }
                icon:          { type: string, nullable: true }
                cover_url:     { type: string, nullable: true }
                blocks:
                  description: "EditorJS document tree"
                  oneOf:
                    - { type: object, additionalProperties: true }
                    - { type: string }
                  nullable: true
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectDoc' }
        '403':
          description: Insufficient permissions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /projects/{projectId}/docs/{docId}:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: docId,     in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get a project doc (with blocks)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectDoc' }
        '404':
          description: Document not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    patch:
      summary: Update a project doc
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:         { type: string }
                parent_doc_id: { type: string, format: uuid, nullable: true }
                icon:          { type: string, nullable: true }
                cover_url:     { type: string, nullable: true }
                is_archived:   { type: boolean }
                blocks:
                  description: "EditorJS document tree"
                  oneOf:
                    - { type: object, additionalProperties: true }
                    - { type: string }
                  nullable: true
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectDoc' }
        '403':
          description: Insufficient permissions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Document not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Archive a project doc (soft delete)
      responses:
        '200':
          description: Archived
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '404':
          description: Document not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /projects/{projectId}/custom-fields:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List custom fields for a project
      description: |
        Custom fields are currently stored at the organisation level
        (see CustomFieldController). The {projectId} in this path is
        informational and lets clients pre-bind a form context.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/CustomField' }
    post:
      summary: Create a custom field for a project
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, field_type]
              properties:
                name:
                  type: string
                  maxLength: 255
                field_type:
                  type: string
                  enum: [text, number, select, multi_select, date, checkbox]
                entity_type: { type: string, description: "Defaults to 'issue'" }
                required:    { type: boolean }
                options:
                  type: array
                  items: { type: string }
                  description: "Used for select / multi_select"
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CustomField' }

  /projects/{projectId}/custom-fields/{fieldId}:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: fieldId,   in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    delete:
      summary: Delete a project's custom field
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '404':
          description: Custom field not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Issues ───────────────────────────────────────────────────────
  /issues:
    get:
      summary: List issues with optional filters
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: project_id,  in: query, schema: { type: string, format: uuid } }
        - name: status
          in: query
          description: |
            Filter by board column. Accepts a single value or comma-separated
            list (e.g. `Backlog,To Do`). Defaults are `Backlog`, `To Do`,
            `In Progress`, `In Review`, `Done`; projects with custom columns
            accept those values too.
          schema: { type: string }
        - { name: assignee_id, in: query, schema: { type: string, format: uuid } }
        - { name: sprint_id,   in: query, schema: { type: string, format: uuid } }
        - { name: q,           in: query, schema: { type: string } }
        - { name: limit,       in: query, schema: { type: integer, minimum: 1, maximum: 200 } }
        - { name: offset,      in: query, schema: { type: integer, minimum: 0 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Issue' }
    post:
      summary: Create an issue
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id, title]
              properties:
                project_id:      { type: string, format: uuid }
                title:           { type: string }
                description:     { type: string }
                status:
                  type: string
                  description: |
                    Board column. Defaults to `Backlog` if omitted. Must be one
                    of the project's `columns` — canonical defaults are
                    `Backlog`, `To Do`, `In Progress`, `In Review`, `Done`.
                  example: Backlog
                priority:        { type: string, enum: [low, medium, high, critical] }
                type:            { type: string, enum: [task, bug, feature, story, epic] }
                assignee_id:     { type: string, format: uuid }
                sprint_id:       { type: string, format: uuid }
                due_date:        { type: string, format: date }
                estimated_hours: { type: number }
                labels:          { type: array, items: { type: string } }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Issue' }

  /issues/{issueId}:
    parameters:
      - { name: issueId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get an issue
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Issue' }
    put:
      summary: Update an issue
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:           { type: string }
                description:     { type: string }
                status:
                  type: string
                  description: |
                    Board column. Must be one of the project's `columns` —
                    canonical defaults are `Backlog`, `To Do`, `In Progress`,
                    `In Review`, `Done`.
                priority:        { type: string }
                assignee_id:     { type: string, format: uuid, nullable: true }
                sprint_id:       { type: string, format: uuid, nullable: true }
                due_date:        { type: string, format: date, nullable: true }
                estimated_hours: { type: number }
                type:            { type: string }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Issue' }
    delete:
      summary: Delete an issue
      responses:
        '204':
          description: Deleted

  /issues/{issueId}/archive:
    parameters:
      - { name: issueId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Archive an issue (soft-delete; reversible)
      responses:
        '200':
          description: Archived
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Issue' }

  /issues/{issueId}/unarchive:
    parameters:
      - { name: issueId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Unarchive a previously-archived issue
      responses:
        '200':
          description: Unarchived
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Issue' }

  /issues/{issueId}/duplicate:
    parameters:
      - { name: issueId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Duplicate an issue (creates a copy in the same project)
      responses:
        '201':
          description: Duplicated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Issue' }

  /issues/{issueId}/comments:
    parameters:
      - { name: issueId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Add a comment to an issue
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text:      { type: string, description: "Comment body (markdown supported)" }
                parent_id: { type: string, format: uuid, nullable: true, description: "Parent comment id when replying in a thread" }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Comment' }

  /issues/{issueId}/attachments:
    parameters:
      - { name: issueId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Upload an attachment to an issue
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Attachment' }

  # ── Roadmap ──────────────────────────────────────────────────────
  /projects/{projectId}/roadmap:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List roadmap items for a project
      description: |
        Returns roadmap items (epics, milestones, releases) for the
        project with their dates, progress and dependencies. Used by
        the executive 3-column panel.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/RoadmapItem' }
                  dependencies:
                    type: array
                    items: { $ref: '#/components/schemas/RoadmapDependency' }
    post:
      summary: Create a roadmap item
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, start_date, end_date]
              properties:
                name:        { type: string, maxLength: 255 }
                description: { type: string, nullable: true }
                start_date:  { type: string, format: date }
                end_date:    { type: string, format: date }
                progress:    { type: integer, minimum: 0, maximum: 100 }
                item_type:
                  type: string
                  enum: [epic, milestone, release]
                  default: epic
                color:       { type: string, nullable: true, description: "Hex color e.g. #3b82f6" }
      responses:
        '200':
          description: Created — response is wrapped in `item`
          content:
            application/json:
              schema:
                type: object
                properties:
                  item: { $ref: '#/components/schemas/RoadmapItem' }

  /projects/{projectId}/roadmap/{itemId}:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: itemId,    in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    patch:
      summary: Update a roadmap item (partial)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:        { type: string }
                description: { type: string, nullable: true }
                start_date:  { type: string, format: date }
                end_date:    { type: string, format: date }
                progress:    { type: integer, minimum: 0, maximum: 100 }
                item_type:
                  type: string
                  enum: [epic, milestone, release]
                color:       { type: string, nullable: true }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  item: { $ref: '#/components/schemas/RoadmapItem' }
    delete:
      summary: Delete a roadmap item
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  /projects/{projectId}/roadmap/dependencies:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Create a roadmap dependency (from → to)
      description: |
        Modela "to depends on from": el item `to_id` no puede empezar
        hasta que el `from_id` esté completo. El backend autodetecta
        `from_type`/`to_type` (epic / milestone / issue) si no se envían.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from_id, to_id]
              properties:
                from_id:   { type: string, format: uuid }
                to_id:     { type: string, format: uuid }
                from_type: { type: string, enum: [epic, milestone, release, issue], nullable: true }
                to_type:   { type: string, enum: [epic, milestone, release, issue], nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RoadmapDependency' }
        '400':
          description: Self-dependency or invalid type
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /projects/{projectId}/roadmap/dependencies/{depId}:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: depId,     in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    delete:
      summary: Delete a roadmap dependency
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  # ── Time tracking ────────────────────────────────────────────────
  /projects/{projectId}/issues/{issueId}/time-entries:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: issueId,   in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List time entries on an issue
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/TimeEntry' }
    post:
      summary: Log time on an issue
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hours]
              properties:
                hours:       { type: number, format: float, minimum: 0 }
                description: { type: string }
                date:        { type: string, format: date }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TimeEntry' }

  # ── My Work ──────────────────────────────────────────────────────
  /mywork:
    get:
      summary: Issues + tasks assigned to the authenticated user
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  issues:   { type: array, items: { $ref: '#/components/schemas/Issue' } }
                  projects: { type: array, items: { $ref: '#/components/schemas/Project' } }

  # ── Notifications ────────────────────────────────────────────────
  /notifications:
    get:
      summary: List the user's notifications
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Notification' }

  /notifications/{id}/read:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      summary: Mark a notification as read
      responses:
        '200': { description: OK }

  /notifications/read-all:
    post:
      summary: Mark every notification as read
      responses:
        '200': { description: OK }

  # ── Push subscriptions (mobile) ──────────────────────────────────
  /push/register-device:
    post:
      summary: Register an FCM/APNs device token for push delivery
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DeviceRegistration' }
      responses:
        '200':
          description: Registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  device_id: { type: string, format: uuid }

  /push/vapid-key:
    get:
      summary: VAPID public key for the Web Push subscription handshake
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  public_key: { type: string }

  # ── Team ─────────────────────────────────────────────────────────
  /team:
    get:
      summary: List members of the caller's current org
      description: |
        Returns the org's user list joined with presence info. Backed by
        `TeamController::index` — no role gate (any member can read).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/TeamMember' }

  /team/invite:
    post:
      summary: Invite a user to the current org by email
      description: |
        Creates a `pending_invitations` row, emails the invite link and,
        when the recipient already has a Projekt account, fires an
        in-app `team_invite` notification. Caller must have
        `manage_members` (admin/manager). Cannot invite at a role above
        your own. If `user_id` is supplied (selected from the
        `/team/search` autocomplete) the email/name are resolved
        server-side from that record.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:   { type: string, format: email, description: "Ignored when user_id resolves to a known user" }
                name:    { type: string, nullable: true }
                role:
                  type: string
                  enum: [admin, manager, member, viewer]
                  description: "Defaults to 'member' when omitted"
                user_id: { type: string, format: uuid, nullable: true, description: "Optional — resolves email/name from an existing Projekt user" }
      responses:
        '201':
          description: Invitation created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invitation' }
        '403':
          description: Insufficient permissions or role escalation
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Validation error or already a member / pending invite
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /team/invite-link:
    post:
      summary: Generate a shareable invite link for the current org
      description: |
        Creates an `invite_links` row scoped to a role and returns the
        raw token (only shown once). Hashed at rest. Caller must have
        `manage_members`; cannot create a link for a role above their
        own. Default expiry is +7 days.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                role:
                  type: string
                  enum: [admin, manager, member, viewer]
                  description: "Defaults to 'member'"
      responses:
        '200':
          description: Invite link created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InviteLink' }
        '403':
          description: Insufficient permissions or role escalation
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /team/search:
    get:
      summary: Autocomplete users for invite-by-username/email
      description: |
        Returns up to 10 users matching the query against username,
        name or email. Excludes current org members and disabled
        accounts; only surfaces users with `allow_global_search`. A
        leading `@` in `q` is stripped. Returns `[]` for queries
        shorter than 2 chars.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: q, in: query, required: true, schema: { type: string }, description: "Search query (min 2 chars)" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/UserSearchResult' }

  /team/invitations:
    get:
      summary: List pending/accepted/declined invitations for the current org
      description: |
        Backed by `TeamController::pendingInvitations`. Despite the
        method name, every status is returned (filter client-side).
        Caller must have `manage_members`.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Invitation' }
        '403':
          description: Insufficient permissions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /team/invitations/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    delete:
      summary: Cancel (delete) a pending invitation
      description: |
        Only deletes invitations that are expired or declined — pending
        non-expired invitations are rejected with 400. Accepted
        invitations cannot be deleted. Caller must have
        `manage_members`.
      responses:
        '200':
          description: Invitation deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
        '400':
          description: Invitation is still pending or already accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Invitation not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /team/invitations/{id}/resend:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Re-issue and re-email a pending or expired invitation
      description: |
        Rotates the token, resets `expires_at` to +7 days, flips status
        back to `pending` and resends the invite email. Only allowed on
        invitations that are pending or expired. Caller must have
        `manage_members`.
      responses:
        '200':
          description: Invitation re-issued
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: '#/components/schemas/Invitation' }
                  - type: object
                    properties:
                      success: { type: boolean }
        '400':
          description: Invitation is in a non-resendable state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Invitation not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invitations/{token}/respond:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string }, description: "Raw invite token from the email link or shareable invite URL" }
    post:
      summary: Accept or decline an invitation by token
      description: |
        Identity-level endpoint — does NOT take `X-Org-Id` (the org is
        resolved from the token). Hashes the supplied token and tries
        `pending_invitations` first, then falls back to `invite_links`.
        On accept the user is added to `organization_users` (subject to
        the plan's user limit). The invite_link path treats `decline` as
        a no-op success.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action:
                  type: string
                  enum: [accept, decline]
      responses:
        '200':
          description: Responded
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InvitationResponse' }
        '403':
          description: Wrong recipient or plan limit reached
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Invite not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Invalid action value
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /presence/heartbeat:
    post:
      summary: Touch the caller's presence row for an issue
      description: |
        Upserts into `issue_presence` and returns the other viewers
        currently active on the same issue (last 30 seconds). When
        `issue_id` is omitted the backend short-circuits to an empty
        viewer list — no row is written.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                issue_id: { type: string, format: uuid, nullable: true }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PresenceViewers' }

  /orgs/{orgId}/members:
    parameters:
      - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List members of an organisation
      description: |
        IDOR-guarded — `orgId` must equal the caller's current
        `X-Org-Id`. Any member of the org can read.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/OrgMember' }
        '403':
          description: Cross-org access denied
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /orgs/{orgId}/members/{userId}:
    parameters:
      - { name: orgId,  in: path, required: true, schema: { type: string, format: uuid } }
      - { name: userId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    patch:
      summary: Update a member's org role
      description: |
        Owner/admin only. Body must include the new `role`. The path is
        org-scoped and IDOR-guarded against the caller's current org.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role:
                  type: string
                  enum: [admin, manager, member, viewer]
      responses:
        '200':
          description: Role updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '403':
          description: Insufficient permissions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Remove a member from the organisation
      description: |
        Owner/admin only. Owners cannot be removed and admins cannot
        remove other admins. Self-removal is rejected — use
        `POST /orgs/{orgId}/leave` instead.
      responses:
        '200':
          description: Member removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400':
          description: Cannot remove self / owner / peer admin
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: Insufficient permissions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Member not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /orgs/{orgId}/leave:
    parameters:
      - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Leave the organisation
      description: |
        Removes the caller's `organization_users` row. Owners can only
        leave when at least one other owner/admin remains.
      responses:
        '200':
          description: Left
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400':
          description: Sole owner — transfer ownership or delete the org first
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Caller is not a member of this org
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /orgs/{orgId}/role-gates:
    parameters:
      - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get the org's merged role gates (defaults + overrides)
      description: |
        Falls back to the platform defaults when the
        `org_role_overrides` table is unavailable.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RoleGates' }
        '403':
          description: Cross-org access denied
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      summary: Replace role-gate overrides
      description: |
        Body is a JSON object of capability_key → array of role names.
        Owner/admin only. The backend rejects any list that omits
        `owner` (so admins can't lock themselves out via the UI).
        Returns the merged result after upserting.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RoleGates' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RoleGates' }
        '400':
          description: Invalid capability key, role, or owner missing
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: Insufficient permissions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /auth/sessions:
    get:
      summary: List the caller's active sessions
      description: |
        Backed by `AuthController::sessions`. Each row is flagged with
        `is_current` so the UI can prevent revoking the active session.
        Identity-scoped — no `X-Org-Id` required.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/AuthSession' }

  /auth/sessions/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      summary: Revoke a session by id
      description: |
        Cannot revoke the current session — use `POST /auth/logout`
        instead. Returns 404 when the session does not belong to the
        caller or is already revoked.
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
        '400':
          description: Refused — this is the current session
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Session not found or already revoked
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Admin: Platform Stats ────────────────────────────────────────
  /admin/projects/{id}/members:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      summary: List members with access to a project (admin)
      description: |
        Devuelve los miembros de la organización dueña del proyecto
        (heurística usada hasta que aterricen ACLs por proyecto).
        Reservado a la consola admin platform-level.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Member' }
        '404':
          description: Project not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/recent-errors:
    get:
      summary: Tail de los logs de aplicación (admin diagnostic)
      description: |
        Devuelve las últimas N entradas (default 200, max 1000) de
        `server/logs/app-{today,yesterday}.log` para diagnosticar 5xx
        sin acceso SSH. Solo platform admins.
      parameters:
        - { name: lines, in: query, schema: { type: integer, minimum: 1, maximum: 1000 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:   { type: integer }
                  entries:
                    type: array
                    items:
                      type: object
                      additionalProperties: true

  /admin/stats:
    get:
      summary: Platform-level admin dashboard stats
      description: |
        Aggregate counts (orgs, users, projects, issues), 7d / 30d
        deltas, plan distribution, recent signups and top projects.
        Platform-admin only — see AdminStatsController::stats.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminStats' }

  # ── Admin: Users ─────────────────────────────────────────────────
  /admin/users:
    get:
      summary: Paginated user list (admin)
      parameters:
        - { name: page,   in: query, schema: { type: integer, minimum: 1 } }
        - { name: search, in: query, schema: { type: string }, description: "Matches name / email" }
        - { name: status, in: query, schema: { type: string, enum: [active, disabled] } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  users: { type: array, items: { $ref: '#/components/schemas/AdminUser' } }
                  total: { type: integer }
                  page:  { type: integer }
                  pages: { type: integer }

  /admin/users/{userId}:
    parameters:
      - { name: userId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      summary: User detail (admin)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminUser' }
        '404':
          description: User not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/users/{userId}/toggle:
    parameters:
      - { name: userId, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      summary: Toggle a user's disabled flag (admin)
      description: |
        Enables a disabled user or disables an active one. Disabling also
        revokes every active session. Method is PATCH per the live route
        in App.php (`PATCH /admin/users/:id/toggle`).
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:  { type: boolean }
                  disabled: { type: boolean }
                  message:  { type: string }
        '400':
          description: Cannot disable your own account
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: User not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/users/{userId}/force-logout:
    parameters:
      - { name: userId, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      summary: Revoke every active session for a user (admin)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:          { type: boolean }
                  sessions_revoked: { type: integer }
        '404':
          description: User not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/users/{userId}/reset-settings:
    parameters:
      - { name: userId, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      summary: Reset a user's personal settings to defaults (admin)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
        '404':
          description: User not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/users/{userId}/org-role:
    parameters:
      - { name: userId, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      summary: Change a user's role inside an organisation (admin)
      description: |
        The org id is sent in the body, not the path — this matches the
        live route in App.php (`PATCH /admin/users/:id/org-role`) and
        the JS client (adminApi.adminChangeOrgRole).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [org_id, role]
              properties:
                org_id: { type: string, format: uuid }
                role:
                  type: string
                  enum: [owner, admin, manager, member, viewer]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:  { type: boolean }
                  old_role: { type: string }
                  new_role: { type: string }
        '400':
          description: Invalid role / missing org_id
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: User or membership not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Admin: Organisations ─────────────────────────────────────────
  /admin/organizations:
    get:
      summary: Paginated organisation list (admin)
      parameters:
        - { name: page,   in: query, schema: { type: integer, minimum: 1 } }
        - { name: search, in: query, schema: { type: string }, description: "Matches name / slug" }
        - { name: plan,   in: query, schema: { type: string, enum: [free, pro, business] } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  organizations: { type: array, items: { $ref: '#/components/schemas/AdminOrg' } }
                  total:         { type: integer }
                  page:          { type: integer }
                  pages:         { type: integer }

  /admin/organizations/{orgId}:
    parameters:
      - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      summary: Organisation detail (admin)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminOrg' }
        '404':
          description: Organization not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Permanently delete an organisation (admin, superadmin only)
      description: |
        The caller must pass the org name as `confirm_name` (query string
        or JSON body). Cascades to projects, issues and memberships.
      parameters:
        - { name: confirm_name, in: query, schema: { type: string }, description: "Must match the organisation name exactly" }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                confirm_name: { type: string, description: "Must match the organisation name exactly" }
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
        '400':
          description: Confirmation mismatch
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Organization not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/organizations/{orgId}/plan:
    parameters:
      - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      summary: Change an organisation's billing plan (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan:
                  type: string
                  enum: [free, pro, business]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:  { type: boolean }
                  org_id:   { type: string, format: uuid }
                  org_name: { type: string }
                  old_plan: { type: string }
                  new_plan: { type: string }
        '400':
          description: Invalid plan
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Organization not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/organizations/{orgId}/transfer:
    parameters:
      - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      summary: Transfer organisation ownership (admin, superadmin only)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [new_owner_id]
              properties:
                new_owner_id: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
        '400':
          description: New owner is not a member of the organisation
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Organization not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/organizations/{orgId}/suspend:
    parameters:
      - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      summary: Suspend or unsuspend an organisation (admin)
      description: |
        The action verb is `suspend` or `unsuspend`. The JS client passes
        an `action` string verbatim — keep the enum aligned with the
        backend (`suspendOrg` in AdminController).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action:
                  type: string
                  enum: [suspend, unsuspend]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
        '400':
          description: Invalid action
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Organization not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Admin: Org Addons ────────────────────────────────────────────
  /admin/organizations/{orgId}/addons:
    parameters:
      - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      summary: List addons available + installed for an org (admin)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  addons:
                    type: array
                    description: "Catalog filtered by the org's plan"
                    items:
                      type: object
                      additionalProperties: true
                  installed:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
        '404':
          description: Organization not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/organizations/{orgId}/addons/install:
    parameters:
      - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      summary: Install an addon for an organisation (admin)
      description: |
        The slug is sent in the body, not the path — this matches the
        live route (`POST /admin/organizations/:id/addons/install`) and
        the JS client (adminApi.adminInstallAddon).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug]
              properties:
                slug: { type: string }
      responses:
        '200':
          description: Installed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  slug:    { type: string }
        '400':
          description: Plan does not allow this addon, or slug missing
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Organization not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/organizations/{orgId}/addons/uninstall:
    parameters:
      - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      summary: Uninstall an addon for an organisation (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug]
              properties:
                slug: { type: string }
      responses:
        '200':
          description: Uninstalled
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  slug:    { type: string }
        '400':
          description: Slug missing
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Organization not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Invoices ─────────────────────────────────────────────────────
  /invoices:
    get:
      summary: List invoices for the current org (filter, sort, paginate)
      description: |
        Wrapped response with pagination + summary stats. Status filter
        only honours the canonical enum; any other value is silently
        dropped. Sort uses `field:dir`, e.g. `issue_date:desc`. Plan
        gate: `billing.invoices` (Business plan).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: status,    in: query, schema: { type: string, enum: [draft, issued, sent, paid, overdue, void] } }
        - { name: search,    in: query, schema: { type: string }, description: "Matches invoice_number, client_name, notes" }
        - { name: date_from, in: query, schema: { type: string, format: date }, description: "Filter on issue_date" }
        - { name: date_to,   in: query, schema: { type: string, format: date }, description: "Filter on issue_date" }
        - { name: sort,      in: query, schema: { type: string }, description: "field:dir — fields: created_at, issue_date, due_date, total, invoice_number, client_name, status" }
        - { name: page,      in: query, schema: { type: integer, minimum: 1 } }
        - { name: per_page,  in: query, schema: { type: integer, minimum: 1, maximum: 100 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:     { type: array, items: { $ref: '#/components/schemas/Invoice' } }
                  total:    { type: integer }
                  page:     { type: integer }
                  per_page: { type: integer }
                  pages:    { type: integer }
                  summary:
                    type: object
                    properties:
                      total_invoiced:    { type: number }
                      total_paid:        { type: number }
                      total_outstanding: { type: number }
                      overdue_count:     { type: integer }
                      void_count:        { type: integer }
        '403':
          description: Plan does not allow invoices
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    post:
      summary: Create a draft invoice
      description: |
        Numbering is managed server-side (`INV-00001` style, drawn from
        invoice_settings). Totals are recomputed from `items` + `tax_rate`
        — clients can omit subtotal/total. The `client_*` snapshot
        fields are stored on the row even when `client_id` is provided.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_name, issue_date, due_date]
              properties:
                client_id:        { type: string, format: uuid, nullable: true }
                client_name:      { type: string, maxLength: 255 }
                client_email:     { type: string, format: email, nullable: true }
                client_address:   { type: string, nullable: true }
                client_tax_id:    { type: string, nullable: true }
                issue_date:       { type: string, format: date }
                due_date:         { type: string, format: date }
                tax_rate:         { type: number, description: "Percentage applied to subtotal" }
                notes:            { type: string, nullable: true }
                project_id:       { type: string, format: uuid, nullable: true, description: "Validated against own org / shared projects" }
                issued_to_org_id: { type: string, format: uuid, nullable: true, description: "Cross-org B2B target — silently nulled if no accepted link" }
                items:
                  type: array
                  items:
                    type: object
                    required: [description]
                    properties:
                      description: { type: string }
                      quantity:    { type: number }
                      unit_price:  { type: number }
                      tax_rate:    { type: number, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invoices/settings:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get the org's invoice settings (admin/owner only)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InvoiceSettings' }
        '403':
          description: Plan does not allow invoices, or caller is not owner/admin
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      summary: Update the org's invoice settings (admin/owner only)
      description: |
        Reminder fields use different keys in the request than in the
        response — input expects `reminder_before_due` /
        `reminder_after_overdue`, output is `reminder_before_days` /
        `reminder_after_days` (see InvoiceController::updateSettings).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                company_name:           { type: string, nullable: true }
                company_address:        { type: string, nullable: true }
                tax_id:                 { type: string, nullable: true }
                currency:               { type: string, description: "Defaults to EUR" }
                invoice_prefix:         { type: string, description: "Defaults to INV" }
                yearly_reset:           { type: boolean }
                payment_terms_days:     { type: integer }
                reminder_before_due:    { type: integer, description: "Stored as reminder_before_days" }
                reminder_after_overdue: { type: integer, description: "Stored as reminder_after_days" }
                bank_details:           { type: string, nullable: true }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InvoiceSettings' }
        '403':
          description: Plan does not allow invoices, or caller is not owner/admin
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invoices/aging:
    get:
      summary: Accounts-receivable aging report
      description: |
        Buckets unpaid (`sent` / `issued` / `overdue`) invoices by
        days-overdue, plus top debtors and upcoming due (next 30 days).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  buckets:
                    type: object
                    description: "Keys: current, days_30_60, days_60_90, days_90_plus"
                    additionalProperties: true
                  grand_total:  { type: number }
                  top_debtors:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
                  upcoming_due:
                    type: array
                    items:
                      type: object
                      additionalProperties: true

  /invoices/report:
    get:
      summary: Invoice reporting summary (revenue, top clients, monthly)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: date_from, in: query, schema: { type: string, format: date }, description: "Defaults to Jan 1 of current year" }
        - { name: date_to,   in: query, schema: { type: string, format: date }, description: "Defaults to Dec 31 of current year" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  summary:
                    type: object
                    properties:
                      total_revenue:     { type: number }
                      total_paid:        { type: number }
                      total_outstanding: { type: number }
                      total_overdue:     { type: number }
                      invoice_count:     { type: integer }
                  top_clients:
                    type: array
                    items:
                      type: object
                      properties:
                        client_name: { type: string }
                        total:       { type: number }
                        count:       { type: integer }
                  monthly:
                    type: array
                    items:
                      type: object
                      properties:
                        month:    { type: string, description: "YYYY-MM" }
                        invoiced: { type: number }
                        paid:     { type: number }
                        count:    { type: integer }

  /invoices/export-csv:
    get:
      summary: Export invoices as CSV (returned as a JSON envelope)
      description: |
        The backend embeds the CSV string + filename inside a JSON body
        rather than streaming the file. Optional `ids=a,b,c` filters to
        a specific selection (used by bulk export from the list view).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: date_from, in: query, schema: { type: string, format: date } }
        - { name: date_to,   in: query, schema: { type: string, format: date } }
        - { name: ids,       in: query, schema: { type: string }, description: "Comma-separated invoice ids" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  csv:      { type: string, description: "CSV content with UTF-8 BOM" }
                  filename: { type: string }
                  count:    { type: integer }

  /invoices/bulk:
    post:
      summary: Bulk action on invoices (status change today)
      description: |
        Currently only `action=status` is supported — the JS client
        mirrors that. Other actions are rejected with 400.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [invoice_ids, action, value]
              properties:
                invoice_ids:
                  type: array
                  items: { type: string, format: uuid }
                action:
                  type: string
                  enum: [status]
                value:
                  type: string
                  enum: [draft, issued, sent, paid, overdue, void]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  processed: { type: integer }
                  skipped:   { type: integer }
        '400':
          description: Invalid action / status / no ids
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invoices/bulk-delete:
    post:
      summary: Bulk-delete invoices (only `draft` and `void` rows)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [invoice_ids]
              properties:
                invoice_ids:
                  type: array
                  items: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  processed: { type: integer }
                  skipped:   { type: integer, description: "Rows that were not draft/void" }

  /invoices/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get an invoice with items + payments inlined
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      summary: Update an invoice
      description: |
        Once an invoice leaves `draft`, only a status-only payload is
        accepted; any other field combo returns 403. Status transitions
        follow `InvoiceController::ALLOWED_TRANSITIONS`. Sending `items`
        re-creates the line set and recalculates totals.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                client_id:        { type: string, format: uuid, nullable: true }
                client_name:      { type: string }
                client_email:     { type: string, format: email, nullable: true }
                client_address:   { type: string, nullable: true }
                client_tax_id:    { type: string, nullable: true }
                status:
                  type: string
                  enum: [draft, issued, sent, paid, overdue, void]
                issue_date:       { type: string, format: date }
                due_date:         { type: string, format: date }
                tax_rate:         { type: number }
                notes:            { type: string, nullable: true }
                project_id:       { type: string, format: uuid, nullable: true }
                issued_to_org_id: { type: string, format: uuid, nullable: true }
                items:
                  type: array
                  description: "Replaces the invoice's line items (draft only)"
                  items:
                    type: object
                    properties:
                      description: { type: string }
                      quantity:    { type: number }
                      unit_price:  { type: number }
                      tax_rate:    { type: number, nullable: true }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '400':
          description: Invalid status transition
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: Cannot edit issued invoice
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Delete an invoice (draft only)
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400':
          description: Only draft invoices can be deleted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invoices/{id}/export:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: HTML/print render payload for an invoice
      description: |
        Returns the structured data the SPA uses to render the printable
        invoice (and the same payload is reused for `/pdf`). The logo is
        inlined as a base64 data URI when resolvable on disk.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  invoice:      { $ref: '#/components/schemas/Invoice' }
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/InvoiceItem' }
                  payments:
                    type: array
                    items: { $ref: '#/components/schemas/InvoicePayment' }
                  settings:     { $ref: '#/components/schemas/InvoiceSettings' }
                  issuer:
                    type: object
                    additionalProperties: true
                    description: "Org-derived issuer info (legal_name, tax_id, fiscal_*, IBAN…)"
                  org_logo_url: { type: string, nullable: true, description: "data: URI or remote URL" }
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invoices/{id}/pdf:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: PDF render payload (alias of `/export` today)
      description: |
        Returns the same JSON envelope as `/invoices/{id}/export`. The
        client renders the PDF locally — the backend does not currently
        produce a binary PDF.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invoices/{id}/send:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Send invoice email (Gmail integration with SMTP fallback)
      description: |
        No request body — the JS client just POSTs. The response shows
        which transport was used (`gmail` for direct send, `smtp` when
        queued via JobService). Auto-transitions the invoice to `sent`
        when the current status allows it.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:  { type: boolean }
                  message:  { type: string }
                  sent_via:
                    type: string
                    enum: [gmail, smtp]
        '400':
          description: Invoice has no client email
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invoices/{id}/duplicate:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Duplicate an invoice as a new draft
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invoices/{id}/credit-note:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Create a credit note (negative copy) for a paid invoice
      description: |
        Generates a new invoice with `CN-NNNNN` numbering, status
        `issued`, and negated amounts. Only allowed when the source
        invoice is `paid`.
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '400':
          description: Source invoice is not paid
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invoices/{id}/issue:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Move invoice from `draft` to `issued`
      description: |
        Stamps `issued_at`. If `issued_to_org_id` is set, notifies that
        org's members (`invoice_received` notification).
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '400':
          description: Invalid status transition
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invoices/{id}/void:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Void an invoice
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string }
      responses:
        '200':
          description: Voided
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '400':
          description: Invalid status transition / missing reason
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invoices/{id}/payment:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Record a payment against an invoice
      description: |
        Only `sent` and `overdue` invoices can take payments. Auto
        transitions to `paid` once the running balance hits zero.
        Triggers an `invoice_paid` push notification to org members on
        the transition.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, payment_date]
              properties:
                amount:       { type: number, exclusiveMinimum: 0, description: "Must be positive (OAS 3.1 syntax)" }
                payment_date: { type: string, format: date }
                method:       { type: string, nullable: true }
                reference:    { type: string, nullable: true }
                notes:        { type: string, nullable: true }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  payment: { $ref: '#/components/schemas/InvoicePayment' }
                  invoice: { $ref: '#/components/schemas/Invoice' }
        '400':
          description: Invalid status / non-positive amount
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invoices/{id}/payments:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List payments for an invoice
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/InvoicePayment' }
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invoices/{id}/email-log:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Dunning / email log for an invoice
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/InvoiceEmailLogEntry' }
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invoices/{id}/revert-to-draft:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Revert an issued invoice back to draft
      description: |
        Clears `issued_at` and `sent_at` and sets `status` back to `draft`.
        Rejected if the invoice is already draft, if it has any payments
        recorded, or if it has been voided. Requires owner or admin role.
        Business plan gate: `billing.invoices`.
      responses:
        '200':
          description: Invoice reverted to draft
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '400':
          description: Already draft, has payments, or is void
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: Insufficient role or plan
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Recurring Invoices ───────────────────────────────────────────
  /recurring-invoices:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List recurring invoice configs for the org
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/RecurringInvoice' }
    post:
      summary: Create a recurring invoice config
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_name, frequency, items]
              properties:
                client_id:      { type: string, format: uuid, nullable: true }
                client_name:    { type: string, maxLength: 255 }
                client_email:   { type: string, format: email, nullable: true }
                client_address: { type: string, nullable: true }
                client_tax_id:  { type: string, nullable: true }
                frequency:
                  type: string
                  enum: [weekly, biweekly, monthly, quarterly, yearly]
                day_of_month:   { type: integer, minimum: 1, maximum: 28 }
                tax_rate:       { type: number }
                notes:          { type: string, nullable: true }
                auto_send:      { type: boolean, description: "Stored as 0/1 TINYINT" }
                items:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    properties:
                      description: { type: string }
                      quantity:    { type: number }
                      unit_price:  { type: number }
                      tax_rate:    { type: number, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecurringInvoice' }
        '400':
          description: Invalid frequency / no items
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /recurring-invoices/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    put:
      summary: Update a recurring invoice config
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                client_id:      { type: string, format: uuid, nullable: true }
                client_name:    { type: string }
                client_email:   { type: string, format: email, nullable: true }
                client_address: { type: string, nullable: true }
                client_tax_id:  { type: string, nullable: true }
                notes:          { type: string, nullable: true }
                tax_rate:       { type: number }
                auto_send:      { type: boolean }
                is_active:      { type: integer, description: "0/1 — TINYINT in DB" }
                frequency:
                  type: string
                  enum: [weekly, biweekly, monthly, quarterly, yearly]
                day_of_month:   { type: integer, minimum: 1, maximum: 28 }
                items:
                  type: array
                  items:
                    type: object
                    additionalProperties: true
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecurringInvoice' }
        '404':
          description: Recurring invoice config not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Delete a recurring invoice config
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '404':
          description: Recurring invoice config not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /recurring-invoices/{id}/toggle:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Pause / resume a recurring invoice config
      description: |
        No request body. Flips `is_active` between 0 and 1.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecurringInvoice' }
        '404':
          description: Recurring invoice config not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /recurring-invoices/{id}/trigger:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Manually generate an invoice from a recurring config
      description: |
        Creates a fresh draft invoice using the config's items / client
        snapshot and advances `next_run_at` based on `frequency` /
        `day_of_month`.
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '404':
          description: Recurring invoice config not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Contracts ─────────────────────────────────────────────────────
  /contracts:
    get:
      summary: List contracts for the current org
      description: |
        Returns `{ data, summary }` with status counts. Plan gate:
        `billing.payroll` + `billing.contracts`. The `search` term
        matches `contract_number`, `title`, employee name (collapsed
        from employees_view) and client name/company.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: status,      in: query, schema: { type: string, enum: [draft, active, issued, signed, archived, expired, terminated] } }
        - { name: employee_id, in: query, schema: { type: string, format: uuid } }
        - { name: client_id,   in: query, schema: { type: string, format: uuid } }
        - { name: search,      in: query, schema: { type: string } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Contract' } }
                  summary:
                    type: object
                    properties:
                      total:            { type: integer }
                      draft_count:      { type: integer }
                      active_count:     { type: integer }
                      issued_count:     { type: integer }
                      signed_count:     { type: integer }
                      archived_count:   { type: integer }
                      expired_count:    { type: integer }
                      terminated_count: { type: integer }
        '403':
          description: Plan / role does not allow contracts
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    post:
      summary: Create a draft contract
      description: |
        Numbering is generated server-side (e.g. `CTR-00001`) inside a
        FOR-UPDATE transaction against `contract_settings`. When
        `template_version_id` is provided and `clauses` is omitted, the
        clause set is cloned from the template version.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:               { type: string, maxLength: 255 }
                employee_id:         { type: string, format: uuid, nullable: true }
                client_id:           { type: string, format: uuid, nullable: true }
                template_version_id: { type: string, format: uuid, nullable: true }
                contract_type:
                  type: string
                  enum: [internship, full_time, part_time, freelance, service_agreement, nda, sow, msa, custom]
                start_date:          { type: string, format: date, nullable: true }
                end_date:            { type: string, format: date, nullable: true }
                salary:              { type: number }
                currency:            { type: string, description: "Defaults to EUR" }
                notes:               { type: string, nullable: true }
                variables_data:
                  type: object
                  additionalProperties: true
                  description: "Free-form custom variables — server JSON-encodes on insert"
                clauses:
                  type: array
                  items:
                    type: object
                    properties:
                      title:       { type: string }
                      content:     { type: string }
                      is_required: { type: boolean }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contract' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/settings:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get the org's contract numbering / corporate header settings
      description: |
        Owners/admins only. Returns the row from `contract_settings` or
        a default fallback (`{contract_prefix: 'CTR', ...nulls}`) when
        none exists.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContractSettings' }
        '403':
          description: Plan / role does not allow contracts
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      summary: Update the org's contract settings (owner/admin only)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                company_name:    { type: string, nullable: true }
                company_address: { type: string, nullable: true }
                tax_id:          { type: string, nullable: true }
                contract_prefix: { type: string, description: "Defaults to CTR" }
                default_clauses:
                  type: array
                  description: "JSON-encoded server-side"
                  items:
                    type: object
                    additionalProperties: true
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContractSettings' }
        '403':
          description: Plan / role does not allow contracts
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/sepe-templates:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List available SEPE PDF contract templates
      description: |
        Returns the ContractTemplateRegistry manifest with the list of
        official Spanish SEPE contract modalities and languages available
        for download. Requires `billing.payroll` plan + `billing.contracts` gate.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  modalities: { type: array, items: { type: object, additionalProperties: true } }
                  languages:  { type: array, items: { type: object, additionalProperties: true } }
                  manifest:   { type: array, items: { type: object, additionalProperties: true }, description: "Localised manifest rows: code, modality_label, language_label, filename" }
        '403':
          description: Plan / role does not allow contracts
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/sepe-templates/{code}/file:
    parameters:
      - { name: code, in: path, required: true, schema: { type: string }, description: "SEPE template code, e.g. FECTEMCAS" }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Download the original SEPE PDF template binary
      description: |
        Streams the raw SEPE PDF for the given template code. The path is
        resolved through `ContractTemplateRegistry::pathFor` which only
        returns files inside `server/Templates/` — path traversal is not
        possible. Responds with `Content-Disposition: attachment`.
      responses:
        '200':
          description: Binary PDF
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '403':
          description: Plan / role does not allow contracts
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Template code not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get a contract with clauses + recent audit log inlined
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contract' }
        '404':
          description: Contract not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      summary: Update a contract (status transitions, fields, clauses)
      description: |
        Sending `clauses` replaces the full clause set. `status` only
        accepts canonical values; invalid `contract_type` is silently
        dropped. Status changes are recorded via ContractAuditService.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:         { type: string }
                employee_id:   { type: string, format: uuid, nullable: true }
                client_id:     { type: string, format: uuid, nullable: true }
                contract_type:
                  type: string
                  enum: [internship, full_time, part_time, freelance, service_agreement, nda, sow, msa, custom]
                status:
                  type: string
                  enum: [draft, active, issued, signed, archived, expired, terminated]
                start_date:    { type: string, format: date, nullable: true }
                end_date:      { type: string, format: date, nullable: true }
                salary:        { type: number }
                currency:      { type: string }
                notes:         { type: string, nullable: true }
                variables_data:
                  type: object
                  additionalProperties: true
                clauses:
                  type: array
                  items:
                    type: object
                    properties:
                      title:       { type: string }
                      content:     { type: string }
                      is_required: { type: boolean }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contract' }
        '404':
          description: Contract not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Delete a contract
      description: |
        Hard delete. Clauses cascade via FK; payment milestones for the
        contract follow whatever the DB cascade rule dictates.
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '404':
          description: Contract not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/{id}/export:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Export a contract as a JSON envelope (contract + clauses + settings + logo)
      description: |
        Returns the raw building blocks the frontend uses to render the
        printable HTML. The `org_logo_url` is converted to a base64
        data URI when the logo lives under `/uploads/`.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  contract:     { $ref: '#/components/schemas/Contract' }
                  clauses:
                    type: array
                    items: { $ref: '#/components/schemas/ContractClause' }
                  settings:
                    nullable: true
                    allOf: [{ $ref: '#/components/schemas/ContractSettings' }]
                  org_logo_url: { type: string, nullable: true, description: "Base64 data URI when local; otherwise the raw URL" }
        '404':
          description: Contract not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/{id}/pdf:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Generate (or regenerate) the contract PDF
      description: |
        Resolves template variables, snapshots the clauses, persists
        `pdf_path` + `pdf_generated_at` on the contract, and returns
        the new path. Audit-logs `pdf_generated`.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:          { type: boolean }
                  pdf_path:         { type: string }
                  pdf_generated_at: { type: string, format: date-time }
        '404':
          description: Contract not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    get:
      summary: Download the previously generated contract PDF
      description: |
        Streams the binary file with `Content-Type: application/pdf`
        and `Content-Disposition: attachment`. JS clients build the
        URL via `getContractPdfUrl()`; SDK consumers should treat the
        response as `application/pdf`. 404 if no PDF has been
        generated yet.
      responses:
        '200':
          description: PDF binary
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '404':
          description: Contract or PDF file not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/{id}/preview:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Render preview HTML for a contract without persisting
      description: |
        Used by the wizard. The optional `clauses` / `variables_data`
        in the body override what's stored on the contract for the
        preview render only. Returns the wrapped corporate HTML and
        the list of variables that could not be resolved.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                clauses:
                  type: array
                  items:
                    type: object
                    properties:
                      title:   { type: string }
                      content: { type: string }
                variables_data:
                  type: object
                  additionalProperties: true
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  html:              { type: string }
                  missing_variables:
                    type: array
                    items: { type: string }
        '404':
          description: Contract not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/{id}/variables:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Resolve template variables for a contract (no render)
      description: |
        Returns the resolved variable map, the list of missing keys and
        the full registry exposed by VariableResolverService — the UI
        uses this to power the variable picker.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                variables_data:
                  type: object
                  additionalProperties: true
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  resolved:
                    type: object
                    additionalProperties: true
                  missing:
                    type: array
                    items: { type: string }
                  registry:
                    type: object
                    additionalProperties: true
                    description: "Full variable registry — shape mirrors VariableResolverService::getRegistry()"
        '404':
          description: Contract not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/{id}/audit:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Full audit log for a contract
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ContractAuditEntry' }
        '404':
          description: Contract not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/{id}/data-annex:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Generate the data-annex PDF for a contract
      description: |
        Produces a one-page PDF (via dompdf) summarising the employee and
        company data required to fill the official SEPE form manually: DNI/NIE,
        NUSS, fiscal address, contract terms. An optional `template_code` query
        parameter adds the SEPE modality label to the header. Returns the binary
        PDF directly (`Content-Disposition: attachment`).
      parameters:
        - { name: template_code, in: query, required: false, schema: { type: string }, description: "SEPE template code to include in the PDF header, e.g. FECTEMCAS" }
      responses:
        '200':
          description: Binary PDF (data annex)
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '404':
          description: Contract not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/{id}/apply-default-clauses:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Copy the org's default contract clauses into a contract
      description: |
        Inserts the org's default clause set (from `ContractDefaultClauses`)
        into `contract_clauses`. Returns `409` if the contract already has
        clauses unless `replace=1` is passed in the body.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                replace: { type: string, description: "Pass '1' to delete existing clauses before inserting defaults" }
      responses:
        '200':
          description: Clauses applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  clauses:
                    type: array
                    items: { type: object, additionalProperties: true }
                  count: { type: integer }
        '409':
          description: Contract already has clauses (pass replace=1 to override)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: No default clauses available for this contract type
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Contract not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/{id}/sepe-filled:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Return the SEPE form pre-filled with the contract's data
      description: |
        Stamps the contract, employee, and organisation data onto the
        official SEPE PDF using `SepePdfFiller`. Only a subset of templates
        (currently FECTEMCAS) have a full coordinate map; unsupported codes
        return `422`. Returns the binary PDF with `Content-Disposition: attachment`.
      parameters:
        - { name: template_code, in: query, required: true, schema: { type: string }, description: "SEPE template code, e.g. FECTEMCAS" }
      responses:
        '200':
          description: Pre-filled binary PDF
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '404':
          description: Contract or template not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Template not yet supported by SepePdfFiller
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/{id}/upload-signed:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Upload the externally-signed PDF for a contract
      description: |
        Accepts a multipart upload (`file` field). Validates magic bytes
        (`%PDF-`), max 25 MB. Stores the file as `/uploads/contracts/signed/{id}.pdf`.
        If an embedded PKCS#7 signature is detected the audit entry is flagged
        `has_signature=true`. Promotes the contract from `draft` to `active`
        (other statuses are left unchanged). Subsequent uploads overwrite the
        previous file.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: "Signed PDF — must start with %PDF- magic bytes, max 25 MB"
      responses:
        '200':
          description: Signed PDF stored
          content:
            application/json:
              schema:
                type: object
                properties:
                  contract:      { $ref: '#/components/schemas/Contract' }
                  has_signature: { type: boolean, description: "True if a PKCS#7 /Sig dictionary was detected" }
                  sha256:        { type: string, description: "SHA-256 hex of the stored file" }
        '400':
          description: No file, invalid PDF, or exceeds 25 MB
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Contract not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/{id}/signed-pdf:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    delete:
      summary: Detach the signed-PDF attachment and revert the contract to draft
      description: |
        Clears `signed_pdf_path` and related metadata columns. If the
        contract was in `active` status (promoted automatically by
        `upload-signed`) it is rolled back to `draft`. Contracts in
        `terminated` or `expired` status keep their status. The file is
        left on disk for forensic purposes. Audit-logged.
      responses:
        '200':
          description: Signed PDF detached
          content:
            application/json:
              schema:
                type: object
                properties:
                  contract: { $ref: '#/components/schemas/Contract' }
        '404':
          description: Contract not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: No signed PDF attached to this contract
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Contract Templates ────────────────────────────────────────────
  /contract-templates:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List active contract templates with current-version metadata
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ContractTemplate' }
        '403':
          description: Plan / role does not allow contract templates
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    post:
      summary: Create a contract template (auto-creates version 1)
      description: |
        Inserts the template, an initial `contract_template_versions`
        row (`is_current = 1`, `version_number = 1`), and the supplied
        `clauses` inside a single transaction.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:                  { type: string }
                description:           { type: string, nullable: true }
                jurisdiction:          { type: string, nullable: true }
                default_payment_terms: { type: string, nullable: true }
                duration_type:
                  type: string
                  enum: [fixed, ongoing, milestone]
                contract_type:
                  type: string
                  enum: [service_agreement, nda, sow, msa, custom]
                clauses:
                  type: array
                  items:
                    type: object
                    properties:
                      title:       { type: string }
                      content:     { type: string }
                      is_required: { type: boolean }
                      is_editable: { type: boolean }
                      is_locked:   { type: boolean }
                      clause_key:  { type: string, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContractTemplate' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contract-templates/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get a contract template with current version + clauses + history
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContractTemplate' }
        '404':
          description: Template not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      summary: Update template metadata (and spawn a new version when clauses change)
      description: |
        Metadata-only updates touch only `contract_templates`. Sending
        a `clauses` array creates a fresh `contract_template_versions`
        row (incrementing `version_number`), flips the previous one to
        `is_current = 0`, and stores the new clauses against the new
        version. Returns the same shape as the show endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:                  { type: string }
                description:           { type: string, nullable: true }
                jurisdiction:          { type: string, nullable: true }
                default_payment_terms: { type: string, nullable: true }
                duration_type:
                  type: string
                  enum: [fixed, ongoing, milestone]
                contract_type:
                  type: string
                  enum: [service_agreement, nda, sow, msa, custom]
                changelog:             { type: string, nullable: true, description: "Stored on the new version row when clauses change" }
                clauses:
                  type: array
                  items:
                    type: object
                    properties:
                      title:       { type: string }
                      content:     { type: string }
                      is_required: { type: boolean }
                      is_editable: { type: boolean }
                      is_locked:   { type: boolean }
                      clause_key:  { type: string, nullable: true }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContractTemplate' }
        '404':
          description: Template not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Soft-delete a contract template (is_active = 0)
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '404':
          description: Template not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contract-templates/{id}/versions:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List all versions for a contract template (newest first)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ContractTemplateVersion' }
        '404':
          description: Template not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contract-templates/{id}/versions/{versionId}:
    parameters:
      - { name: id,        in: path, required: true, schema: { type: string, format: uuid } }
      - { name: versionId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get a specific version with its clauses inlined
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContractTemplateVersion' }
        '404':
          description: Template or version not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Legal Settings ────────────────────────────────────────────────
  /legal-settings:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get the org's legal settings (owner/admin only)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LegalSettings' }
        '403':
          description: Plan / role does not allow legal settings
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      summary: Upsert the org's legal settings (owner/admin only)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                legal_name:            { type: string, nullable: true }
                legal_address:         { type: string, nullable: true }
                registration_number:   { type: string, nullable: true }
                tax_id:                { type: string, nullable: true }
                jurisdiction:          { type: string, nullable: true }
                default_currency:      { type: string, nullable: true, description: "Defaults to EUR" }
                default_payment_terms: { type: string, nullable: true }
                signatory_name:        { type: string, nullable: true }
                signatory_title:       { type: string, nullable: true }
                footer_text:           { type: string, nullable: true }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LegalSettings' }
        '403':
          description: Plan / role does not allow legal settings
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Payment Milestones (per-contract) ─────────────────────────────
  /contracts/{id}/payment-milestones:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List payment milestones for a contract
      description: |
        Returns `{ data, summary }` with computed totals broken down by
        status (`pending` / `completed` / `invoiced`).
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/PaymentMilestone' }
                  summary:
                    type: object
                    properties:
                      total_amount:     { type: number }
                      pending_amount:   { type: number }
                      completed_amount: { type: number }
                      invoiced_amount:  { type: number }
                      count:            { type: integer }
        '404':
          description: Contract not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    post:
      summary: Create a payment milestone
      description: |
        `amount` is accepted as a string and cast server-side. When
        `milestone_id` is supplied it must reference a project milestone
        in the same org — otherwise the call returns 404. New rows
        always start at `status: pending`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, amount]
              properties:
                title:        { type: string, maxLength: 255 }
                amount:       { type: string, description: "Validator expects a string; cast to float on insert" }
                currency:     { type: string, description: "Defaults to contract currency, then EUR" }
                due_date:     { type: string, format: date, nullable: true }
                milestone_id: { type: string, format: uuid, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentMilestone' }
        '404':
          description: Contract or referenced project milestone not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/{id}/payment-milestones/{pmId}:
    parameters:
      - { name: id,   in: path, required: true, schema: { type: string, format: uuid } }
      - { name: pmId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    put:
      summary: Update a payment milestone
      description: |
        Rejected with 403 if the milestone is already `invoiced`.
        Setting `status` to `completed` stamps `completed_at` if it was
        not previously set. Status values outside the canonical enum are
        silently dropped.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:        { type: string }
                amount:       { type: number }
                currency:     { type: string }
                due_date:     { type: string, format: date, nullable: true }
                milestone_id: { type: string, format: uuid, nullable: true }
                status:
                  type: string
                  enum: [pending, completed, invoiced]
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentMilestone' }
        '403':
          description: Milestone already invoiced
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Contract or payment milestone not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Delete a payment milestone (only when not invoiced)
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '403':
          description: Milestone already invoiced
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Contract or payment milestone not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contracts/{id}/payment-milestones/{pmId}/generate-invoice:
    parameters:
      - { name: id,   in: path, required: true, schema: { type: string, format: uuid } }
      - { name: pmId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Generate a draft invoice from a completed payment milestone
      description: |
        Requires the milestone to be in `status: completed`. Allocates
        the next `INV-NNNNN` invoice number under a FOR-UPDATE lock,
        creates the invoice + a single line item, flips the payment
        milestone to `status: invoiced` and stamps `invoice_id`. Client
        snapshot fields are pulled from the contract's client (or
        employee) record. Audit-logs `payment_milestone_invoiced`.
      responses:
        '201':
          description: Invoice created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:           { type: boolean }
                  invoice:           { $ref: '#/components/schemas/Invoice' }
                  payment_milestone: { $ref: '#/components/schemas/PaymentMilestone' }
        '400':
          description: Milestone not completed, or already invoiced
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Contract or payment milestone not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── CRM ───────────────────────────────────────────────────────────
  /crm/pipelines:
    get:
      summary: List sales pipelines for the current org
      description: |
        Returns the org's pipelines with their stages joined inline. If
        the org has no pipelines, the controller seeds a default
        `Sales Pipeline` (six canonical stages) and returns it as the
        sole entry.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/CrmPipeline' }
    post:
      summary: Create a sales pipeline
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:       { type: string, maxLength: 255 }
                is_default: { type: integer, description: "0 or 1" }
                stages:
                  type: array
                  description: "Optional initial stages; created in order, position auto-assigned"
                  items:
                    type: object
                    properties:
                      name:        { type: string }
                      probability: { type: integer }
                      color:       { type: string, description: "Hex colour (default #6366f1)" }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CrmPipeline' }
        '422':
          description: Validation failed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /crm/pipelines/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    put:
      summary: Update a pipeline (name / default flag)
      description: |
        Only `name` and `is_default` are mutable here. Stage edits are
        not exposed via crmApi.js.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:       { type: string }
                is_default: { type: integer, description: "0 or 1" }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CrmPipeline' }
        '404':
          description: Pipeline not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /crm/leads:
    get:
      summary: List leads with optional filters
      description: |
        Returns a paginated envelope `{ data, total, page, per_page,
        last_page }`. The `status` and `source` filters accept the
        sentinel value `all` (treated as no filter). `search` matches
        name / email / company.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: status,      in: query, schema: { type: string }, description: "lead status (or `all`)" }
        - { name: source,      in: query, schema: { type: string }, description: "lead source (or `all`)" }
        - { name: assigned_to, in: query, schema: { type: string, format: uuid } }
        - { name: search,      in: query, schema: { type: string } }
        - { name: page,        in: query, schema: { type: integer, minimum: 1 } }
        - { name: per_page,    in: query, schema: { type: integer, minimum: 10, maximum: 100 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:      { type: array, items: { $ref: '#/components/schemas/Lead' } }
                  total:     { type: integer }
                  page:      { type: integer }
                  per_page:  { type: integer }
                  last_page: { type: integer }
    post:
      summary: Create a lead
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:        { type: string, maxLength: 255 }
                email:       { type: string, format: email, nullable: true }
                phone:       { type: string, nullable: true }
                company:     { type: string, nullable: true }
                source:      { type: string, description: "Defaults to `other`" }
                status:      { type: string, description: "Defaults to `new`" }
                assigned_to: { type: string, format: uuid, nullable: true }
                notes:       { type: string, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Lead' }
        '422':
          description: Validation failed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /crm/leads/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get a lead with its activity history
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Lead' }
        '404':
          description: Lead not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      summary: Update a lead
      description: |
        Mutable fields: name, email, phone, company, source, status,
        assigned_to, notes. Only keys present in the body are updated;
        `updated_at` is stamped server-side.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:        { type: string }
                email:       { type: string, format: email, nullable: true }
                phone:       { type: string, nullable: true }
                company:     { type: string, nullable: true }
                source:      { type: string }
                status:      { type: string }
                assigned_to: { type: string, format: uuid, nullable: true }
                notes:       { type: string, nullable: true }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Lead' }
        '404':
          description: Lead not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Delete a lead and its activities
      description: |
        Cascades into `crm_activities` for the same `lead_id` + `org_id`.
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '404':
          description: Lead not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /crm/leads/{id}/convert:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Convert a lead into a client + opening deal
      description: |
        Creates a `clients` row from the lead's contact fields, creates
        a `crm_deals` row in the `prospecting` stage (probability 10)
        attached to the org's default pipeline, and flips the lead to
        `status: converted` (stamping `converted_to_client_id` /
        `converted_at`). Returns ids of the new rows.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                deal_title: { type: string, description: "Optional override; defaults to `Deal — {lead.name}`" }
                deal_value: { type: number }
                currency:   { type: string, description: "Defaults to EUR" }
      responses:
        '200':
          description: Converted
          content:
            application/json:
              schema:
                type: object
                properties:
                  lead:      { $ref: '#/components/schemas/Lead' }
                  client_id: { type: string, format: uuid }
                  deal_id:   { type: string, format: uuid }
        '400':
          description: Lead already converted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Lead not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /crm/deals:
    get:
      summary: List deals with optional filters
      description: |
        Returns an unpaginated array (controller has no LIMIT clause).
        `stage` accepts the sentinel `all` (no filter). `sort` is one of
        `created_at` (default), `value`, `close`, `stage`, `prob`; `dir`
        defaults to `DESC`. `search` matches deal title and joined
        client name/company.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: stage,       in: query, schema: { type: string } }
        - { name: assigned_to, in: query, schema: { type: string, format: uuid } }
        - { name: pipeline_id, in: query, schema: { type: string, format: uuid } }
        - { name: date_from,   in: query, schema: { type: string, format: date }, description: "Filters by expected_close_date >=" }
        - { name: date_to,     in: query, schema: { type: string, format: date }, description: "Filters by expected_close_date <=" }
        - { name: search,      in: query, schema: { type: string } }
        - { name: sort,        in: query, schema: { type: string, enum: [created_at, value, close, stage, prob] } }
        - { name: dir,         in: query, schema: { type: string, enum: [ASC, DESC] } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Deal' }
    post:
      summary: Create a deal
      description: |
        `pipeline_id` defaults to the org's default pipeline (or the
        first one if none is flagged default). `stage` defaults to
        `prospecting`, `probability` to 10, `currency` to EUR.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:               { type: string, maxLength: 255 }
                lead_id:             { type: string, format: uuid, nullable: true }
                client_id:           { type: string, format: uuid, nullable: true }
                pipeline_id:         { type: string, format: uuid, nullable: true }
                value:               { type: number }
                currency:            { type: string }
                stage:
                  type: string
                  enum: [prospecting, qualification, proposal, negotiation, closed_won, closed_lost]
                probability:         { type: integer }
                expected_close_date: { type: string, format: date, nullable: true }
                assigned_to:         { type: string, format: uuid, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Deal' }
        '422':
          description: Validation failed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /crm/deals/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get a deal with its activity history
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Deal' }
        '404':
          description: Deal not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      summary: Update a deal
      description: |
        Mutable fields: title, client_id, lead_id, pipeline_id, value,
        currency, stage, probability, expected_close_date, assigned_to,
        lost_reason. Only keys present in the body are updated.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:               { type: string }
                client_id:           { type: string, format: uuid, nullable: true }
                lead_id:             { type: string, format: uuid, nullable: true }
                pipeline_id:         { type: string, format: uuid, nullable: true }
                value:               { type: number }
                currency:            { type: string }
                stage:
                  type: string
                  enum: [prospecting, qualification, proposal, negotiation, closed_won, closed_lost]
                probability:         { type: integer }
                expected_close_date: { type: string, format: date, nullable: true }
                assigned_to:         { type: string, format: uuid, nullable: true }
                lost_reason:         { type: string, nullable: true }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Deal' }
        '404':
          description: Deal not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Delete a deal and its activities
      description: |
        Cascades into `crm_activities` for the same `deal_id` + `org_id`.
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '404':
          description: Deal not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /crm/deals/{id}/stage:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    patch:
      summary: Move a deal to a different stage
      description: |
        Validates `stage` against the canonical six values and auto-sets
        `probability` to a per-stage default (10/25/50/75/100/0) unless
        the body provides an explicit `probability`. crmApi.js only
        sends `{ stage }`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [stage]
              properties:
                stage:
                  type: string
                  enum: [prospecting, qualification, proposal, negotiation, closed_won, closed_lost]
                probability: { type: integer, description: "Optional override — not sent by crmApi.js" }
      responses:
        '200':
          description: Stage updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  stage:   { type: string }
        '400':
          description: Invalid stage
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Deal not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /crm/deals/{id}/close:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Close a deal as won or lost
      description: |
        Flips `stage` to `closed_won` (probability 100) or `closed_lost`
        (probability 0), stamps `actual_close_date = today`, and stores
        `lost_reason` when `won == false` and a reason is provided.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [won]
              properties:
                won:    { type: boolean }
                reason: { type: string, description: "Stored as `lost_reason` when won is false" }
      responses:
        '200':
          description: Closed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  stage:   { type: string, enum: [closed_won, closed_lost] }
        '404':
          description: Deal not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /crm/activities:
    get:
      summary: List recent CRM activities
      description: |
        Returns the most recent 200 activities for the org, ordered by
        `date` desc. `type` accepts the sentinel `all` (no filter). The
        `deal_id` / `lead_id` / `client_id` filters scope the list to a
        single entity.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: type,      in: query, schema: { type: string } }
        - { name: deal_id,   in: query, schema: { type: string, format: uuid } }
        - { name: lead_id,   in: query, schema: { type: string, format: uuid } }
        - { name: client_id, in: query, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/CrmActivity' }
    post:
      summary: Log a CRM activity
      description: |
        Polymorphic — at most one of `deal_id` / `lead_id` / `client_id`
        should be set (controller does not enforce exclusivity).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, subject, date]
              properties:
                type:             { type: string, description: "call | email | meeting | note | task | …" }
                subject:          { type: string, maxLength: 255 }
                date:             { type: string, format: date-time }
                deal_id:          { type: string, format: uuid, nullable: true }
                lead_id:          { type: string, format: uuid, nullable: true }
                client_id:        { type: string, format: uuid, nullable: true }
                description:      { type: string, nullable: true }
                duration_minutes: { type: integer, nullable: true }
                outcome:          { type: string, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CrmActivity' }
        '422':
          description: Validation failed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /crm/activities/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    delete:
      summary: Delete a CRM activity
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '404':
          description: Activity not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /crm/analytics/pipeline:
    get:
      summary: Pipeline metrics summary
      description: |
        Aggregates totals, win rate, average cycle time (days from
        created_at to actual_close_date for `closed_won`), this-month
        wins, weighted forecast (active deals only) and a per-stage
        breakdown. The `period` query parameter is currently
        accepted but unused by the controller body (kept for forward
        compat).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: pipeline_id, in: query, schema: { type: string, format: uuid } }
        - { name: period,      in: query, schema: { type: integer }, description: "Days lookback — accepted but currently unused in aggregations" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  total_pipeline_value: { type: number }
                  weighted_forecast:    { type: number }
                  total_deals:          { type: integer }
                  avg_deal_size:        { type: number }
                  win_rate:             { type: number, description: "Percentage, 1 decimal" }
                  avg_cycle_time:       { type: number, description: "Average days from create to actual_close_date for closed_won" }
                  won_count_this_month: { type: integer }
                  won_value_this_month: { type: number }
                  by_stage:
                    type: array
                    items:
                      type: object
                      properties:
                        stage:       { type: string }
                        deal_count:  { type: integer }
                        stage_value: { type: number }

  /crm/analytics/forecast:
    get:
      summary: Sales forecast over the next N months
      description: |
        Returns one row per month starting at the current month. For
        each month: `weighted` (Σ value*probability/100 for active
        deals whose `expected_close_date` falls in the month), `pipeline`
        (Σ value, same scope), `deal_count`, and `won_value` (Σ value
        of `closed_won` whose `actual_close_date` is in that month).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: months, in: query, schema: { type: integer, minimum: 1, maximum: 12 }, description: "Defaults to 6, clamped 1–12" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    month:      { type: string, description: "Short label, e.g. `May 2026`" }
                    year:       { type: integer }
                    month_num:  { type: integer, description: "1-12" }
                    weighted:   { type: number }
                    pipeline:   { type: number }
                    deal_count: { type: integer }
                    won_value:  { type: number }

  /crm/analytics/funnel:
    get:
      summary: Lead → deal conversion funnel
      description: |
        Computes lead/deal counts within the lookback window plus
        derived rates and a per-source breakdown. `period` defaults to
        90 days.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: period, in: query, schema: { type: integer }, description: "Lookback in days (default 90)" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  period_days:       { type: integer }
                  total_leads:       { type: integer }
                  qualified_leads:   { type: integer }
                  converted_leads:   { type: integer }
                  total_deals:       { type: integer }
                  won_deals:         { type: integer }
                  lead_qualify_rate: { type: number, description: "Percentage, 1 decimal" }
                  lead_convert_rate: { type: number, description: "Percentage, 1 decimal" }
                  deal_win_rate:     { type: number, description: "Percentage, 1 decimal" }
                  sources:
                    type: array
                    items:
                      type: object
                      properties:
                        source: { type: string }
                        cnt:    { type: integer }

  # ── Work Schedules (Phase 4 — ScheduleController) ───────────────────────
  /schedules:
    get:
      summary: List work schedules for the current org
      description: |
        Returns active work_schedules with `assigned_count` (direct
        assignees), `direct_users_count` (mirror of assigned_count) and
        an `assigned_roles` array sourced from
        `schedule_role_assignments`. Gated by `billing.manage`.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/WorkSchedule' }
    post:
      summary: Create a work schedule
      description: |
        Validation rules: `name` required (≤120 chars); `weekly_hours`
        between 1 and 80 when supplied; `break_minutes` between 0 and
        600. `pattern` is normalised to all 7 day keys, each holding
        `{start, end}` slots in HH:MM (end must be after start).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:          { type: string, maxLength: 120 }
                weekly_hours:  { type: number, nullable: true, minimum: 1, maximum: 80 }
                pattern:
                  type: object
                  description: "Keys mon|tue|wed|thu|fri|sat|sun, each an array of {start, end} slots (HH:MM)"
                  additionalProperties: true
                default_start: { type: string, nullable: true, description: "HH:MM" }
                default_end:   { type: string, nullable: true, description: "HH:MM" }
                break_minutes: { type: integer, nullable: true, minimum: 0, maximum: 600 }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkSchedule' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /schedules/role-assignments:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List role → schedule assignments
      description: |
        Returns one row per role currently mapped to a schedule for the
        org, sorted by canonical role precedence (owner → viewer).
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ScheduleRoleAssignment' }

  /schedules/role-assignments/{role}:
    parameters:
      - { name: role, in: path, required: true, schema: { type: string, enum: [owner, admin, manager, member, viewer] } }
      - $ref: '#/components/parameters/OrgIdHeader'
    put:
      summary: Upsert a role → schedule mapping
      description: |
        Validates that the schedule belongs to the org. A null /
        empty-string `schedule_id` removes the mapping (same effect as
        DELETE — the JS client uses DELETE for that path).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [schedule_id]
              properties:
                schedule_id: { type: string, format: uuid, nullable: true }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:     { type: boolean }
                  role:        { type: string }
                  schedule_id: { type: string, format: uuid, nullable: true }
        '422':
          description: Invalid role or schedule not in this org
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Remove a role → schedule mapping
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '422':
          description: Invalid role
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /schedules/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get a single schedule (with inlined assignees)
      description: |
        The detail response includes `assignees` so the assignees-mode
        UI renders in a single round-trip.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkSchedule' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      summary: Update a work schedule
      description: |
        PATCH-like semantics — missing fields fall back to the existing
        row's values. Same validation rules as create.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:          { type: string, maxLength: 120 }
                weekly_hours:  { type: number, nullable: true, minimum: 1, maximum: 80 }
                pattern:
                  type: object
                  additionalProperties: true
                default_start: { type: string, nullable: true }
                default_end:   { type: string, nullable: true }
                break_minutes: { type: integer, nullable: true, minimum: 0, maximum: 600 }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkSchedule' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Soft-delete a schedule
      description: |
        Sets `is_active = 0` and clears `organization_users.schedule_id`
        on any member that still references it.
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /schedules/{id}/assignees:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List members assigned to a schedule
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ScheduleAssignee' }
        '404':
          description: Schedule not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    post:
      summary: Bulk-assign members to a schedule
      description: |
        Body shape `{ person_ids: string[] }` where each id is an
        `organization_users.id`. Reports per-id success so the UI can
        render partial-success states.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [person_ids]
              properties:
                person_ids:
                  type: array
                  items: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  assigned: { type: array, items: { type: string, format: uuid } }
                  failed:   { type: array, items: { type: string, format: uuid } }
        '422':
          description: Body shape error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /schedules/{id}/assignees/{personId}:
    parameters:
      - { name: id,       in: path, required: true, schema: { type: string, format: uuid } }
      - { name: personId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    delete:
      summary: Unassign one member from a schedule
      description: |
        The `id` segment is informational — unassign is per-person
        (clears `organization_users.schedule_id` for that row regardless
        of which schedule was passed in the URL).
      responses:
        '200':
          description: Unassigned
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '404':
          description: Member not found in this org
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Legacy per-user employee schedules (EmployeeScheduleController) ─────
  /employee-schedules:
    get:
      summary: List per-user employee_schedules (legacy)
      description: |
        Joins `employee_schedules` to `users` so each row carries
        `user_name` / `user_email`. New code should use the
        `WorkSchedule` family on `/schedules*`.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/EmployeeSchedule' }

  /employee-schedules/{userId}:
    parameters:
      - { name: userId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get a member's employee_schedule (synthesises a default)
      description: |
        Returns a synthetic default row when no `employee_schedules`
        row exists yet (timezone=UTC, work_days=[1..5],
        09:00-17:00); the synthesised row has no `id`.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EmployeeSchedule' }
        '404':
          description: User not in org
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      summary: Upsert a member's employee_schedule
      description: |
        `work_days` is stored as JSON; the controller accepts either a
        JSON-encoded string or a plain array and normalises to JSON
        before persisting.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                timezone:   { type: string, nullable: true, maxLength: 100 }
                work_days:
                  oneOf:
                    - { type: string, description: "JSON-encoded array, e.g. '[1,2,3,4,5]'" }
                    - { type: array, items: { type: integer, minimum: 1, maximum: 7 } }
                  nullable: true
                work_start: { type: string, nullable: true, description: "HH:MM" }
                work_end:   { type: string, nullable: true, description: "HH:MM" }
      responses:
        '200':
          description: Upserted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EmployeeSchedule' }
        '404':
          description: User not in org
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Holidays (legacy — EmployeeScheduleController under /holidays) ──────
  /holidays:
    get:
      summary: List holidays for a year
      description: |
        Reads `org_holidays` filtered by `YEAR(date) = ?`. Returns the
        raw rows (including the legacy `is_recurring` TINYINT column).
        A second, richer endpoint family lives under `/org/holidays`
        (HolidayController) — that one is not exposed via scheduleApi.js.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: year, in: query, schema: { type: integer }, description: "Defaults to current year" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Holiday' }
    post:
      summary: Create a holiday
      description: |
        Body accepts `recurring` (alias) which is mapped to the
        `is_recurring` column. There is no `country_code` field on the
        legacy table.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, date]
              properties:
                name:      { type: string, maxLength: 255 }
                date:      { type: string, format: date }
                recurring:
                  oneOf:
                    - { type: boolean }
                    - { type: integer, enum: [0, 1] }
                  nullable: true
                  description: "Mapped to the `is_recurring` column"
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Holiday' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /holidays/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    put:
      summary: Update a holiday
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:         { type: string }
                date:         { type: string, format: date }
                is_recurring:
                  oneOf:
                    - { type: boolean }
                    - { type: integer, enum: [0, 1] }
                  nullable: true
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Holiday' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Delete a holiday
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Time Off (legacy — EmployeeScheduleController under /time-off) ──────
  /time-off:
    get:
      summary: List time-off entries
      description: |
        Joins `employee_time_off` with `users` to inline `user_name` /
        `user_email` and joins approver. Filters: `user_id` (or alias
        `member_id`), `status`, `year` (matched against
        `YEAR(start_date)`).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: user_id,   in: query, schema: { type: string, format: uuid } }
        - { name: member_id, in: query, schema: { type: string, format: uuid }, description: "Alias for user_id (scheduleApi.js sends this name)" }
        - { name: status,    in: query, schema: { type: string, enum: [pending, approved, rejected] } }
        - { name: year,      in: query, schema: { type: integer } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/TimeOffRequest' }
    post:
      summary: Create a time-off entry
      description: |
        Body accepts `reason` as an alias for `note`; the controller
        maps both to the DB `note` column. `user_id` defaults to the
        caller; non-self values must belong to the current org. `status`
        is initialised to `pending`.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [start_date, end_date, type]
              properties:
                start_date: { type: string, format: date }
                end_date:   { type: string, format: date }
                type:       { type: string, maxLength: 50 }
                user_id:    { type: string, format: uuid, nullable: true, description: "Defaults to the caller" }
                reason:     { type: string, nullable: true, description: "Alias for `note` (controller maps both)" }
                note:       { type: string, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TimeOffRequest' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /time-off/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    put:
      summary: Update a time-off entry (also used to approve / reject)
      description: |
        scheduleApi.js exposes `approveTimeOffRequest(id)` and
        `rejectTimeOffRequest(id, reason)` as helpers — both POST a
        `status` change to this same endpoint. When `status` is sent it
        is validated against the enum, and `approved_by` is stamped to
        the caller's user id.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status:
                  type: string
                  enum: [pending, approved, rejected]
                start_date: { type: string, format: date }
                end_date:   { type: string, format: date }
                type:       { type: string }
                reason:     { type: string, nullable: true, description: "Alias for `note`" }
                note:       { type: string, nullable: true }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TimeOffRequest' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Delete a time-off entry
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Leave Management (LeaveController) ──────────────────────────────────
  /leave/policies:
    get:
      summary: List leave policies (lazy-seeds Spanish defaults)
      description: |
        Returns all policies for the org. The first call seeds the
        canonical Spanish defaults (Vacaciones, Asuntos propios,
        Enfermedad, Maternidad, Paternidad, Permiso sin sueldo) when
        the org has none.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/LeavePolicy' }
    post:
      summary: Create a leave policy (admin/owner)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, leave_type]
              properties:
                name:                 { type: string }
                leave_type:           { type: string }
                days_per_year:        { type: number }
                accrual_method:       { type: string, description: "Defaults to 'annual'" }
                max_carryover:        { type: number }
                requires_approval:    { type: integer, enum: [0, 1] }
                requires_document:    { type: integer, enum: [0, 1] }
                min_notice_days:      { type: integer }
                max_consecutive_days: { type: integer, nullable: true }
                color:                { type: string, description: "Defaults to '#3b82f6'" }
                is_paid:              { type: integer, enum: [0, 1] }
                country:              { type: string, description: "Defaults to 'ES'" }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeavePolicy' }
        '403':
          description: Admin/owner required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /leave/policies/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    put:
      summary: Update a leave policy (admin/owner)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true # TODO — controller whitelists 13 keys; capture above on createPolicy
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeavePolicy' }
        '403':
          description: Admin/owner required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Delete a leave policy (admin/owner)
      description: |
        Returns 409 when the policy still has any `leave_requests` —
        deactivate (`is_active = 0`) instead.
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '403':
          description: Admin/owner required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: Policy has leave requests
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /leave/balances:
    get:
      summary: Team leave balances grid (manager+)
      description: |
        Returns one row per active employee × policy with the full
        `entitled / used / pending / carried_over / adjustments /
        available` breakdown. Returns 503 when the `employees_view`
        compatibility view is missing (run migrations).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: year,        in: query, schema: { type: integer }, description: "Defaults to current year" }
        - { name: department,  in: query, schema: { type: string } }
        - { name: employee_id, in: query, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  year:     { type: integer }
                  policies:
                    type: array
                    items: { $ref: '#/components/schemas/LeavePolicy' }
                  employees:
                    type: array
                    items:
                      type: object
                      properties:
                        employee_id:   { type: string, format: uuid }
                        employee_name: { type: string }
                        department:    { type: string }
                        position:      { type: string }
                        balances:
                          type: array
                          items:
                            type: object
                            properties:
                              policy_id:    { type: string, format: uuid }
                              policy_name:  { type: string }
                              leave_type:   { type: string }
                              color:        { type: string }
                              entitled:     { type: number }
                              used:         { type: number }
                              pending:      { type: number }
                              carried_over: { type: number }
                              adjustments:  { type: number }
                              available:    { type: number }
        '403':
          description: Manager+ required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /leave/balances/me:
    get:
      summary: Caller's own leave balances
      description: |
        Resolves the caller's `employee_id` via `employees_view` and
        returns one entry per active policy (synthesises defaults from
        the policy when no balance row exists yet).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: year, in: query, schema: { type: integer }, description: "Defaults to current year" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  employee_id: { type: string, format: uuid, nullable: true }
                  year:        { type: integer }
                  balances:
                    type: array
                    items:
                      type: object
                      properties:
                        policy_id:            { type: string, format: uuid }
                        policy_name:          { type: string }
                        leave_type:           { type: string }
                        color:                { type: string }
                        days_per_year:        { type: number }
                        requires_approval:    { type: boolean }
                        requires_document:    { type: boolean }
                        min_notice_days:      { type: integer }
                        max_consecutive_days: { type: integer, nullable: true }
                        is_paid:              { type: boolean }
                        entitled:             { type: number }
                        used:                 { type: number }
                        pending:              { type: number }
                        carried_over:         { type: number }
                        adjustments:          { type: number }
                        available:            { type: number }

  /leave/balances/adjust:
    post:
      summary: Adjust an employee's balance (admin/owner)
      description: |
        Adds the signed `adjustment` value to the existing
        `leave_balances.adjustments`. Creates the balance row when none
        exists yet (seeded with the policy's `days_per_year` as
        entitlement).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [employee_id, policy_id, year, adjustment]
              properties:
                employee_id: { type: string, format: uuid }
                policy_id:   { type: string, format: uuid }
                year:        { type: integer }
                adjustment:  { type: number, description: "Signed delta in days" }
      responses:
        '200':
          description: Updated balance row
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeaveBalance' }
        '403':
          description: Admin/owner required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Employee or policy not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /leave/balances/carry-over:
    post:
      summary: Year-end carry-over (admin/owner)
      description: |
        Iterates active policies with `max_carryover > 0`, copies up to
        `min(available, max_carryover)` from each from-year balance
        into the to-year row (creating it if absent).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                from_year: { type: integer, description: "Defaults to current year - 1" }
                to_year:   { type: integer, description: "Defaults to current year" }
      responses:
        '200':
          description: Done
          content:
            application/json:
              schema:
                type: object
                properties:
                  processed: { type: integer, description: "Number of (employee, policy) rows carried" }
                  from_year: { type: integer }
                  to_year:   { type: integer }
        '403':
          description: Admin/owner required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '400':
          description: to_year must be greater than from_year
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /leave/requests:
    get:
      summary: List leave requests (manager+)
      description: |
        Paginated. Status filter accepts the sentinel `all`. Joins
        `leave_policies`, `employees_view`, and `users` (approver) for
        inline labels.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: status,      in: query, schema: { type: string, description: "pending|approved|rejected|cancelled or 'all'" } }
        - { name: employee_id, in: query, schema: { type: string, format: uuid } }
        - { name: policy_id,   in: query, schema: { type: string, format: uuid } }
        - { name: date_from,   in: query, schema: { type: string, format: date }, description: "Filters lr.end_date >= ?" }
        - { name: date_to,     in: query, schema: { type: string, format: date }, description: "Filters lr.start_date <= ?" }
        - { name: page,        in: query, schema: { type: integer, minimum: 1 } }
        - { name: per_page,    in: query, schema: { type: integer, minimum: 10, maximum: 100 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:      { type: array, items: { $ref: '#/components/schemas/LeaveRequest' } }
                  total:     { type: integer }
                  page:      { type: integer }
                  per_page:  { type: integer }
                  last_page: { type: integer }
        '403':
          description: Manager+ required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    post:
      summary: Create a leave request
      description: |
        Resolves the caller's `employee_id`. Validates: no overlap
        with pending/approved requests; sufficient balance (when
        `days_per_year > 0`); minimum notice; max consecutive days.
        Auto-approves when the policy's `requires_approval = 0`.
        Calendar blocks are created for approved/pending requests, and
        approved vacation is mirrored into `timesheet_entries`.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [policy_id, start_date, end_date]
              properties:
                policy_id:      { type: string, format: uuid }
                start_date:     { type: string, format: date }
                end_date:       { type: string, format: date }
                half_day_start: { type: boolean, description: "First day counts as 0.5" }
                half_day_end:   { type: boolean, description: "Last day counts as 0.5" }
                reason:         { type: string, nullable: true }
                document_url:   { type: string, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeaveRequest' }
        '404':
          description: Policy not found / inactive
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: Overlapping pending/approved request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Validation, balance, notice, or max-consecutive failure
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /leave/requests/mine:
    get:
      summary: Caller's own leave requests for a year
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: year, in: query, schema: { type: integer }, description: "Defaults to current year" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/LeaveRequest' }
                  employee_id: { type: string, format: uuid, nullable: true }

  /leave/requests/pending-approvals:
    get:
      summary: Pending approvals queue (manager+)
      description: |
        Returns the pending requests plus this-month approval / rejection
        counts so the manager dashboard can render KPI tiles.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  pending:
                    type: array
                    items: { $ref: '#/components/schemas/LeaveRequest' }
                  approved_this_month: { type: integer }
                  rejected_this_month: { type: integer }
        '403':
          description: Manager+ required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /leave/requests/{id}/approve:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Approve a pending leave request (manager+)
      description: |
        Moves the matching balance from `pending` to `used`. Mirrors
        approved vacation into `timesheet_entries`. Empty body.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        '200':
          description: Approved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeaveRequest' }
        '403':
          description: Manager+ required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Not in `pending` state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /leave/requests/{id}/reject:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Reject a pending leave request (manager+)
      description: |
        Releases the pending balance and removes calendar blocks. The
        rejection reason is persisted on `leave_requests.rejection_reason`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, nullable: true }
      responses:
        '200':
          description: Rejected
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeaveRequest' }
        '403':
          description: Manager+ required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Not in `pending` state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /leave/requests/{id}/cancel:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Cancel a pending or approved leave request
      description: |
        Either the requester or a manager+ can cancel. Past leaves
        (`start_date < today`) cannot be cancelled. Restores the
        balance and removes calendar blocks. Empty body.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        '200':
          description: Cancelled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeaveRequest' }
        '403':
          description: Not the requester / not a manager
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Past leave or not in pending/approved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /leave/calendar:
    get:
      summary: Team leave calendar for a month
      description: |
        Returns the month grid plus per-employee `schedules`
        (resolved via ScheduleService — direct then role-based) and
        `holidays` (recurring rows expanded onto the month's year).
        Also folds in self-reported `category='vacation'` timesheet
        entries when no formal request exists for the same day.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: year,             in: query, schema: { type: integer } }
        - { name: month,            in: query, schema: { type: integer, minimum: 1, maximum: 12 } }
        - { name: include_inactive, in: query, schema: { type: integer, enum: [0, 1] }, description: "When 1, include off-boarded employees" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  year:      { type: integer }
                  month:     { type: integer }
                  first_day: { type: string, format: date }
                  last_day:  { type: string, format: date }
                  employees:
                    type: array
                    items:
                      type: object
                      additionalProperties: true # TODO — projection mixes employees_view fields (id, user_id, first/last name, dept, position, status, is_active)
                  grid:
                    type: object
                    description: "Map of employee_id → date → block descriptor (half_day, policy_name, color, leave_type, status, request_id)"
                    additionalProperties: true
                  schedules:
                    type: object
                    description: "Map of employee_id → resolved schedule (id, name, pattern, weekly_hours, break_minutes, hours_by_day)"
                    additionalProperties: true
                  holidays:
                    type: array
                    items:
                      type: object
                      properties:
                        date: { type: string, format: date }
                        name: { type: string }

  /leave/report:
    get:
      summary: Leave reporting (manager+)
      description: |
        Aggregates approved leaves for the year — by type, by department,
        monthly pattern, top 10 employees, plus an org-wide
        `absenteeism_rate` (working days assumed = 261).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: year, in: query, schema: { type: integer } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  year:             { type: integer }
                  by_type:
                    type: array
                    items:
                      type: object
                      properties:
                        leave_type: { type: string }
                        name:       { type: string }
                        color:      { type: string }
                        total_days: { type: number }
                        requests:   { type: integer }
                  by_department:
                    type: array
                    items:
                      type: object
                      properties:
                        department: { type: string, nullable: true }
                        total_days: { type: number }
                        requests:   { type: integer }
                        employees:  { type: integer }
                  monthly_pattern:
                    type: array
                    items:
                      type: object
                      properties:
                        month:      { type: integer }
                        total_days: { type: number }
                        requests:   { type: integer }
                  top_employees:
                    type: array
                    items:
                      type: object
                      properties:
                        first_name: { type: string, nullable: true }
                        last_name:  { type: string, nullable: true }
                        department: { type: string, nullable: true }
                        total_days: { type: number }
                        requests:   { type: integer }
                  total_employees:  { type: integer }
                  total_absences:   { type: number }
                  absenteeism_rate: { type: number, description: "Percentage, 2 decimals; 0 when no capacity" }
        '403':
          description: Manager+ required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /leave/upcoming:
    get:
      summary: Upcoming approved leaves (manager+)
      description: |
        Returns approved leaves whose `start_date` falls between today
        and `today + days` (clamped 1-90, default 14).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: days, in: query, schema: { type: integer, minimum: 1, maximum: 90 }, description: "Defaults to 14" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/LeaveRequest' }
        '403':
          description: Manager+ required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Payroll V2 (PayrollV2Controller — Spanish gross-to-net) ─────────────
  /payroll-v2/compensation:
    get:
      summary: List employee compensation rows
      description: |
        Returns one row per `employee_compensation` joined with
        `employees_view` (first_name / last_name / email / department /
        position / is_active). Gated by `billing.payroll`.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PayrollCompensation' }

  /payroll-v2/compensation/{employeeId}:
    parameters:
      - { name: employeeId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get an employee's compensation row
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayrollCompensation' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      summary: Upsert an employee's compensation row
      description: |
        Validates the employee belongs to the org. Inserts or updates
        the matching `employee_compensation` row. `extra_payments`
        defaults to 14 (Spain's two extra pagas), `currency` to EUR,
        `contract_type` to indefinido.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [gross_annual, pay_frequency, effective_date]
              properties:
                gross_annual:           { type: number }
                pay_frequency:
                  type: string
                  enum: [monthly, biweekly, weekly]
                extra_payments:         { type: integer, description: "12 = prorrateo, 14 = with separate June + December" }
                currency:               { type: string, description: "Defaults to EUR" }
                effective_date:         { type: string, format: date }
                social_security_number: { type: string, nullable: true }
                irpf_pct:               { type: number, nullable: true }
                contract_type:          { type: string, description: "Defaults to indefinido" }
                work_group:             { type: string, nullable: true }
                seniority_date:         { type: string, format: date, nullable: true }
      responses:
        '200':
          description: Upserted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayrollCompensation' }
        '404':
          description: Employee not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /payroll-v2/runs:
    get:
      summary: List payroll runs for a year
      description: |
        Returns rows from `payroll_runs_v2` for the year, including
        `employee_count` (count of attached payslips). Sorted by month
        desc, then type asc.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: year, in: query, schema: { type: integer }, description: "Defaults to current year" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PayrollRun' }
    post:
      summary: Create a payroll run (draft)
      description: |
        Rejected with 409 when an (org, year, month, type) tuple
        already exists.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [period_year, period_month, type]
              properties:
                period_year:  { type: integer }
                period_month: { type: integer, minimum: 1, maximum: 12 }
                type:
                  type: string
                  enum: [ordinary, extra_june, extra_december, liquidacion, bonus]
                notes:        { type: string, nullable: true }
      responses:
        '201':
          description: Created (status = draft)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayrollRun' }
        '409':
          description: Duplicate (org, year, month, type)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /payroll-v2/runs/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get a payroll run with inlined payslips
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayrollRun' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /payroll-v2/runs/{id}/calculate:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: "Calculate the run (engine: gross-to-net)"
      description: |
        Allowed only when status is `draft` or `calculating`.
        Computes per-employee SS (employee + employer), IRPF
        (progressive — falls back to latest available year's brackets
        when the run year has none), net pay, employer cost, and YTD
        accumulators. Replaces any prior payslips for the run, updates
        the run totals and flips status to `calculated`. Failures
        reset status back to `draft`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        '200':
          description: Calculated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayrollRun' }
        '400':
          description: Run not in draft/calculating
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /payroll-v2/runs/{id}/approve:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Approve a calculated run
      description: |
        Allowed only from `calculated`. Stamps `approved_by` with the
        caller's user id.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        '200':
          description: Approved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayrollRun' }
        '400':
          description: Run not in `calculated` state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /payroll-v2/runs/{id}/paid:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Mark an approved run as paid
      description: |
        Allowed only from `approved`. `paid_date` defaults to today
        when omitted.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                paid_date: { type: string, format: date, nullable: true }
      responses:
        '200':
          description: Paid
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayrollRun' }
        '400':
          description: Run not in `approved` state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /payroll-v2/runs/{id}/cancel:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Cancel a payroll run
      description: |
        Forbidden once the run has been marked `paid`. Otherwise flips
        status to `cancelled`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        '200':
          description: Cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400':
          description: Paid runs cannot be cancelled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /payroll-v2/payslips/{payslipId}:
    parameters:
      - { name: payslipId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get a single payslip with run + compensation context
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayrollPayslip' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /payroll-v2/summary:
    get:
      summary: Payroll run summary for a year
      description: |
        Excludes `cancelled` and `draft` runs from totals and the
        monthly breakdown. `pending_count` includes draft + calculated.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: year, in: query, schema: { type: integer } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  year:                 { type: integer }
                  monthly:
                    type: array
                    items:
                      type: object
                      properties:
                        period_month:        { type: integer }
                        total_gross:         { type: number }
                        total_net:           { type: number }
                        total_employer_cost: { type: number }
                        total_deductions:    { type: number }
                        run_count:           { type: integer }
                  totals:
                    type: object
                    properties:
                      ytd_gross:         { type: number }
                      ytd_net:           { type: number }
                      ytd_employer_cost: { type: number }
                      ytd_deductions:    { type: number }
                  pending_count:        { type: integer, description: "draft + calculated runs in the year" }
                  employees_on_payroll: { type: integer }

  /payroll-v2/annual-summary:
    get:
      summary: Annual per-employee totals
      description: |
        Aggregates per-employee gross / IRPF / SS / net / employer cost
        across all non-draft / non-cancelled runs in the year. The
        optional `employee_id` filter narrows to a single employee.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: year,        in: query, schema: { type: integer } }
        - { name: employee_id, in: query, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    employee_id:         { type: string, format: uuid }
                    employee_name:       { type: string }
                    total_gross:         { type: number }
                    total_irpf:          { type: number }
                    total_ss:            { type: number }
                    total_net:           { type: number }
                    total_employer_cost: { type: number }
                    year:                { type: integer }

  /payroll-v2/irpf-table:
    get:
      summary: IRPF brackets for a year
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: year, in: query, schema: { type: integer } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/IrpfBracket' }

  /payroll-v2/ss-rates:
    get:
      summary: Social-security contribution rates for a year
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: year, in: query, schema: { type: integer } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SsContributionRate' }

  # ── Tax Calendar & Estimates ─────────────────────────────────────
  /tax/calendar:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Spanish autonomo tax deadlines with filing status
      description: |
        Returns the canonical Spanish quarterly/annual deadlines for Modelos
        303, 130, 390, and 100 for the requested year. Each entry includes a
        derived `status` field (`filed`, `overdue`, or `pending`) based on
        whether a matching expense with `category='taxes'` exists for that
        period. Requires Business plan (`billing.invoices` gate).
      parameters:
        - { name: year, in: query, schema: { type: integer }, description: "Defaults to current year" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    model:       { type: string, description: "e.g. 303, 130, 390" }
                    quarter:     { type: string, description: "Q1 | Q2 | Q3 | Q4 | annual" }
                    deadline:    { type: string, format: date }
                    description: { type: string }
                    status:      { type: string, description: "filed | overdue | pending" }

  /tax/estimate:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Estimated Modelo 303 (IVA) and Modelo 130 (IRPF) for a quarter
      description: |
        Auto-calculates from live invoice and approved-expense data.
        `model_303` covers output tax, deductible input tax, and net payable.
        `model_130` covers revenue, deductible expenses, net income, and the
        20% provisional payment. Requires Business plan.
      parameters:
        - { name: quarter, in: query, schema: { type: string }, description: "Q1 | Q2 | Q3 | Q4 (default Q1)" }
        - { name: year, in: query, schema: { type: integer }, description: "Defaults to current year" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  quarter: { type: string }
                  year:    { type: integer }
                  model_303:
                    type: object
                    properties:
                      base:            { type: number }
                      iva_repercutido: { type: number }
                      iva_soportado:   { type: number }
                      result:          { type: number }
                  model_130:
                    type: object
                    properties:
                      ingresos:          { type: number }
                      gastos_deducibles: { type: number }
                      rendimiento:       { type: number }
                      cuota_20pct:       { type: number }
        '400':
          description: Invalid quarter value
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Multi-Country Tax Reporting ───────────────────────────────────
  /tax-multi/jurisdictions:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List tax jurisdictions configured for the org
      description: |
        Returns all jurisdictions with aggregated `obligation_count` and
        `active_obligation_count`. Primary jurisdiction listed first.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/TaxJurisdiction' }
    post:
      summary: Create a tax jurisdiction
      description: |
        If this is the org's first jurisdiction it is automatically set as
        primary. Creating a jurisdiction auto-seeds obligations and VAT rates
        from country-specific templates (`tax_obligation_templates`,
        `tax_rate_templates`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [country_code]
              properties:
                country_code:       { type: string, description: "ISO 3166-1 alpha-2 (2 chars, uppercased)" }
                name:               { type: string }
                tax_regime:         { type: string, description: "e.g. general, autonomo" }
                fiscal_year_start:  { type: integer, description: "Month number, defaults to 1" }
                vat_number:         { type: string, nullable: true }
                tax_id:             { type: string, nullable: true }
                is_primary:         { type: integer, description: "0/1 — clears any existing primary" }
      responses:
        '201':
          description: Jurisdiction created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaxJurisdiction' }
        '400':
          description: Invalid country_code
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /tax-multi/jurisdictions/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
      - $ref: '#/components/parameters/OrgIdHeader'
    put:
      summary: Update a tax jurisdiction
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:               { type: string }
                tax_regime:         { type: string }
                fiscal_year_start:  { type: integer }
                vat_number:         { type: string, nullable: true }
                tax_id:             { type: string, nullable: true }
                is_active:          { type: integer, description: "0/1" }
                is_primary:         { type: integer, description: "0/1 — clears any existing primary" }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaxJurisdiction' }
        '404':
          description: Jurisdiction not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Delete a tax jurisdiction (and its linked obligations / rates)
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
        '404':
          description: Jurisdiction not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /tax-multi/rates:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List tax rates for a jurisdiction
      parameters:
        - { name: jurisdiction_id, in: query, required: true, schema: { type: integer } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/TaxRate' }
    post:
      summary: Create a tax rate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [jurisdiction_id, name, rate]
              properties:
                jurisdiction_id: { type: integer }
                name:            { type: string }
                rate:            { type: number, description: "Percentage, e.g. 21.0" }
                type:            { type: string, description: "vat | reduced_vat | zero_vat | exempt (default: vat)" }
                is_default:      { type: integer, description: "0/1 — clears existing default for same type" }
                effective_from:  { type: string, format: date }
                effective_to:    { type: string, format: date, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaxRate' }
        '404':
          description: Jurisdiction not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /tax-multi/rates/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
      - $ref: '#/components/parameters/OrgIdHeader'
    put:
      summary: Update a tax rate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:           { type: string }
                rate:           { type: number }
                type:           { type: string }
                is_default:     { type: integer }
                effective_from: { type: string, format: date }
                effective_to:   { type: string, format: date, nullable: true }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaxRate' }
        '404':
          description: Tax rate not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /tax-multi/obligations:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List tax obligations for the org
      description: |
        Returns obligations with joined jurisdiction data and filing
        aggregates for the current year. Optionally filtered by jurisdiction.
      parameters:
        - { name: jurisdiction_id, in: query, schema: { type: integer } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/TaxObligation' }

  /tax-multi/calendar:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Multi-jurisdiction tax calendar for a year
      description: |
        For each active obligation generates period entries (monthly /
        quarterly / annual) with computed `due_date`, `status`, and linked
        filing if one exists. Returns gracefully with an empty `calendar`
        array when the underlying migration has not been applied yet.
      parameters:
        - { name: year, in: query, schema: { type: integer }, description: "Defaults to current year" }
        - { name: jurisdiction_id, in: query, schema: { type: integer } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  year:         { type: integer }
                  calendar:
                    type: array
                    items: { $ref: '#/components/schemas/TaxCalendarEntry' }
                  generated_at: { type: string, format: date-time }

  /tax-multi/filings:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List tax filings
      parameters:
        - { name: jurisdiction_id, in: query, schema: { type: integer } }
        - { name: year,            in: query, schema: { type: integer } }
        - { name: quarter,         in: query, schema: { type: integer } }
        - { name: status,          in: query, schema: { type: string } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/TaxFiling' }
    post:
      summary: Create a tax filing for a period
      description: |
        Creates a filing in `not_started` status with an auto-computed
        `due_date` based on the obligation's `frequency` and `due_day`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [obligation_id, period_year]
              properties:
                obligation_id:  { type: integer }
                period_year:    { type: integer }
                period_quarter: { type: integer, nullable: true, description: "1-4 for quarterly obligations" }
                period_month:   { type: integer, nullable: true, description: "1-12 for monthly obligations" }
      responses:
        '201':
          description: Filing created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaxFiling' }
        '404':
          description: Obligation not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /tax-multi/filings/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
      - $ref: '#/components/parameters/OrgIdHeader'
    put:
      summary: Update a tax filing
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status:             { type: string }
                calculated_amount:  { type: number }
                filed_amount:       { type: number }
                filed_date:         { type: string, format: date }
                paid_date:          { type: string, format: date }
                notes:              { type: string, nullable: true }
                document_url:       { type: string, nullable: true }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaxFiling' }
        '404':
          description: Filing not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /tax-multi/filings/{id}/calculate:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Calculate the tax amount for a filing
      description: |
        Computes the tax payable from `tax_transactions` for the filing's
        period. Supports VAT-type models (303, USTVA, CA3, …), IRPF / income
        models (130, 100, EÜR, …), withholding models (111, 115), and a
        generic fallback. Sets `status=calculated` and persists
        `calculated_amount` and `calculation_data` JSON. Returns the
        calculation breakdown.
      responses:
        '200':
          description: Calculation result
          content:
            application/json:
              schema:
                type: object
                properties:
                  filing_id:          { type: integer }
                  calculated_amount:  { type: number }
                  status:             { type: string }
                  calculation_data:   { type: object, additionalProperties: true }
        '404':
          description: Filing not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /tax-multi/filings/{id}/mark-filed:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Mark a tax filing as filed with the tax authority
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                filed_date:   { type: string, format: date, description: "Defaults to today" }
                filed_amount: { type: number, nullable: true }
      responses:
        '200':
          description: Filing marked as filed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaxFiling' }
        '404':
          description: Filing not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /tax-multi/filings/{id}/mark-paid:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Mark a tax filing as paid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                paid_date: { type: string, format: date, description: "Defaults to today" }
      responses:
        '200':
          description: Filing marked as paid
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaxFiling' }
        '404':
          description: Filing not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /tax-multi/transactions/sync:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Sync invoices and approved expenses into tax_transactions
      description: |
        Upserts `tax_transactions` rows from issued invoices (sales) and
        approved expenses (purchases). Transaction type (sale, intra_eu_sale,
        export, purchase, intra_eu_purchase, import) is classified from the
        org's primary-jurisdiction country vs. counterparty country. IRPF
        withholding rows are created as additional `type=withholding` entries.
        Defaults to the org's primary jurisdiction if `jurisdiction_id` is
        omitted.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                from_date:       { type: string, format: date, description: "Defaults to start of current year" }
                to_date:         { type: string, format: date, description: "Defaults to today" }
                jurisdiction_id: { type: integer, description: "Defaults to primary jurisdiction" }
      responses:
        '200':
          description: Sync result
          content:
            application/json:
              schema:
                type: object
                properties:
                  synced:          { type: integer }
                  from_date:       { type: string, format: date }
                  to_date:         { type: string, format: date }
                  jurisdiction_id: { type: integer }
        '400':
          description: No jurisdiction found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /tax-multi/transactions:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: List tax transactions with pagination
      parameters:
        - { name: jurisdiction_id, in: query, schema: { type: integer } }
        - { name: type,            in: query, schema: { type: string }, description: "Filter by transaction type" }
        - { name: from_date,       in: query, schema: { type: string, format: date } }
        - { name: to_date,         in: query, schema: { type: string, format: date } }
        - { name: limit,           in: query, schema: { type: integer, default: 100, maximum: 500 } }
        - { name: offset,          in: query, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TaxTransaction' }
                  total:  { type: integer }
                  limit:  { type: integer }
                  offset: { type: integer }

  /tax-multi/vat-summary:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: VAT output vs input summary grouped by rate
      parameters:
        - { name: jurisdiction_id, in: query, required: true, schema: { type: integer } }
        - { name: year,            in: query, schema: { type: integer } }
        - { name: quarter,         in: query, schema: { type: integer }, description: "1-4; omit for annual" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  period_from:    { type: string, format: date }
                  period_to:      { type: string, format: date }
                  output_by_rate:
                    type: array
                    items: { type: object, properties: { tax_rate: { type: number }, base: { type: number }, tax: { type: number }, tx_count: { type: integer } } }
                  input_by_rate:
                    type: array
                    items: { type: object, properties: { tax_rate: { type: number }, base: { type: number }, tax: { type: number }, tx_count: { type: integer } } }
                  total_output:  { type: number }
                  total_input:   { type: number }
                  net_payable:   { type: number }

  /tax-multi/reports/347:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Modelo 347 — annual third-party transactions report
      description: |
        Spanish Modelo 347: declaración anual de operaciones con terceros.
        Aggregates domestic transactions by counterparty, filtering out
        intra-EU, import/export, and withholding (covered by other models).
        Threshold: ≥ 3.005,06€. Returns quarterly sub-totals per counterparty.
      parameters:
        - { name: year, in: query, schema: { type: integer } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  year:       { type: integer }
                  threshold:  { type: number }
                  count:      { type: integer }
                  operations:
                    type: array
                    items:
                      type: object
                      properties:
                        group_key:             { type: string }
                        counterparty_name:     { type: string }
                        counterparty_tax_id:   { type: string, nullable: true }
                        counterparty_country:  { type: string, nullable: true }
                        annual_total:          { type: number }
                        q1_total:              { type: number }
                        q2_total:              { type: number }
                        q3_total:              { type: number }
                        q4_total:              { type: number }
                        operation_count:       { type: integer }
                        primary_type:          { type: string }

  /tax-multi/reports/349:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Modelo 349 — intra-EU transactions report
      description: |
        Spanish Modelo 349: declaración recapitulativa de operaciones
        intracomunitarias. Aggregates `intra_eu_sale` and `intra_eu_purchase`
        transactions grouped by counterparty + country + type. Can be
        filtered by quarter.
      parameters:
        - { name: year,    in: query, schema: { type: integer } }
        - { name: quarter, in: query, schema: { type: integer }, description: "1-4; omit for annual" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  year:    { type: integer }
                  quarter: { type: integer, nullable: true }
                  count:   { type: integer }
                  operations:
                    type: array
                    items:
                      type: object
                      properties:
                        counterparty_name:    { type: string }
                        counterparty_tax_id:  { type: string, nullable: true }
                        counterparty_country: { type: string }
                        type:                 { type: string }
                        base_total:           { type: number }
                        tax_total:            { type: number }
                        operation_count:      { type: integer }

  /tax-multi/reports/vat-return:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Full VAT-return data for a jurisdiction and period
      description: |
        Returns the jurisdiction row, a full VAT summary (output/input by
        rate + net payable), and an intra-EU breakdown by country for the
        requested period. Period resolution order: month > quarter > year.
      parameters:
        - { name: jurisdiction_id, in: query, required: true, schema: { type: integer } }
        - { name: year,    in: query, schema: { type: integer } }
        - { name: quarter, in: query, schema: { type: integer }, description: "1-4" }
        - { name: month,   in: query, schema: { type: integer }, description: "1-12" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  jurisdiction: { $ref: '#/components/schemas/TaxJurisdiction' }
                  period_from:  { type: string, format: date }
                  period_to:    { type: string, format: date }
                  vat_summary:
                    type: object
                    properties:
                      output_by_rate: { type: array, items: { type: object, additionalProperties: true } }
                      input_by_rate:  { type: array, items: { type: object, additionalProperties: true } }
                      total_output:   { type: number }
                      total_input:    { type: number }
                      net_payable:    { type: number }
                  intra_eu:
                    type: array
                    items: { type: object, additionalProperties: true }
        '404':
          description: Jurisdiction not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /tax-multi/reports/summary:
    parameters:
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Cross-jurisdiction tax summary for a year
      description: |
        For every active jurisdiction returns all filings for the year with
        aggregated `total_payable`, `filed_count`, `pending_count`, and
        `overdue_count`. Also returns a `grand_total` across all jurisdictions.
      parameters:
        - { name: year, in: query, schema: { type: integer } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  year:  { type: integer }
                  grand_total: { type: number }
                  jurisdictions:
                    type: array
                    items:
                      type: object
                      properties:
                        jurisdiction:   { $ref: '#/components/schemas/TaxJurisdiction' }
                        filings:
                          type: array
                          items: { $ref: '#/components/schemas/TaxFiling' }
                        total_payable:  { type: number }
                        filed_count:    { type: integer }
                        pending_count:  { type: integer }
                        overdue_count:  { type: integer }

  # ── GitHub Integration ───────────────────────────────────────────
  /github/status:
    get:
      summary: Whether the calling user has a GitHub OAuth identity (and repo scope)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GithubStatus' }

  /auth/github/integration-url:
    get:
      summary: OAuth URL for repo-scope upgrade
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  url:   { type: string }
                  state: { type: string }

  /auth/github/integration-callback:
    post:
      summary: Exchange the OAuth code from the integration redirect for an upgraded token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code:  { type: string }
                state: { type: string, nullable: true }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  scopes:  { type: string, description: "Space-separated scope list returned by GitHub" }
        '400':
          description: Missing code or invalid/expired state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /github/repos:
    get:
      summary: List GitHub repos visible to the user (paginated by GitHub)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: page, in: query, schema: { type: integer, minimum: 1 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/GithubRepo' }
        '403':
          description: Integration not authorised — needs repo scope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /github/link:
    post:
      summary: Link a GitHub repo to a project (creates a webhook + dispatches an initial full sync)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id, repo_id, repo_full_name]
              additionalProperties: true
              properties:
                project_id:          { type: string, format: uuid }
                repo_id:             { type: integer, description: "GitHub repo id" }
                repo_full_name:      { type: string }
                repo_url:            { type: string, nullable: true }
                sync_direction:      { type: string, description: "pull | push | both — defaults to pull" }
                sync_issues:         { type: boolean }
                sync_comments:       { type: boolean }
                sync_prs:            { type: boolean }
                sync_commits:        { type: boolean }
                status_mapping:      { type: object, additionalProperties: true }
                label_mapping:       { type: object, additionalProperties: true }
                conflict_resolution: { type: string, description: "last_write_wins | …" }
      responses:
        '201':
          description: Link created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GithubRepoLink' }
        '409':
          description: Repository already linked to this project
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /github/links:
    get:
      summary: List GitHub repo links for the current org
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/GithubRepoLink' }

  /github/links/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      summary: Repo-link detail with linked-issue / PR / commit counts
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GithubRepoLink' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      summary: Update sync configuration on a repo link
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              properties:
                sync_direction:      { type: string }
                sync_issues:         { type: boolean }
                sync_comments:       { type: boolean }
                sync_prs:            { type: boolean }
                sync_commits:        { type: boolean }
                conflict_resolution: { type: string }
                status:              { type: string }
                status_mapping:      { type: object, additionalProperties: true }
                label_mapping:       { type: object, additionalProperties: true }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GithubRepoLink' }
    delete:
      summary: Unlink a repo (best-effort deletes the GitHub webhook)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  /github/links/{id}/sync:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      summary: Manually dispatch a sync job for a repo link
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                type: { type: string, description: "full | incremental — defaults to incremental" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:   { type: boolean }
                  sync_type: { type: string }

  /github/links/{id}/sync-log:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      summary: Recent sync attempts for a repo link
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/GithubSyncLogEntry' }

  /github/sync/manual:
    post:
      summary: Admin — dispatch incremental sync for every linked repo on a project (or org-wide)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: project_id, in: query, schema: { type: string, format: uuid }, description: "Optional — restrict to one project" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GithubManualSyncResult' }

  /github/sync/backfill:
    post:
      summary: Admin — retroactively scan commits + PRs and create 3XA issues
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: project_id, in: query, schema: { type: string, format: uuid } }
        - { name: since,      in: query, schema: { type: string, format: date }, description: "YYYY-MM-DD; defaults to 90 days ago" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GithubBackfillStats' }
        '400':
          description: Invalid `since` parameter
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /github/disconnect-info:
    get:
      summary: Pre-flight stats for the destructive disconnect action
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GithubDisconnectInfo' }

  /github/disconnect:
    post:
      summary: Full disconnect — unlink every repo + delete the calling user's GitHub OAuth identity
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:        { type: boolean }
                  repos_unlinked: { type: integer }

  /issues/{issueId}/github:
    parameters:
      - { name: issueId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      summary: GitHub link + linked PRs and commits for an issue
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IssueGithubData' }
        '404':
          description: Issue not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Intake Forms ─────────────────────────────────────────────────
  /projects/{projectId}/intake-forms:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      summary: List intake forms for a project
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/IntakeForm' }
    post:
      summary: Create an intake form
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, fields]
              additionalProperties: true
              properties:
                title:          { type: string }
                description:    { type: string, nullable: true }
                fields:
                  oneOf:
                    - type: array
                      items: { $ref: '#/components/schemas/IntakeFormField' }
                    - { type: string, description: "JSON-encoded array (also accepted)" }
                auto_assign_to: { type: string, format: uuid, nullable: true }
                auto_priority:  { type: string, description: "low | medium | high | critical" }
                auto_status:    { type: string }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IntakeForm' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /intake-forms/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    put:
      summary: Update an intake form
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              properties:
                title:          { type: string }
                description:    { type: string, nullable: true }
                fields:
                  oneOf:
                    - type: array
                      items: { $ref: '#/components/schemas/IntakeFormField' }
                    - { type: string }
                auto_assign_to: { type: string, format: uuid, nullable: true }
                auto_priority:  { type: string }
                auto_status:    { type: string }
                is_active:      { type: boolean }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IntakeForm' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Delete an intake form (also deletes its submissions)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  /forms/{slug}:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
    get:
      summary: Public — fetch a published intake form by slug (no auth)
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IntakeFormPublic' }
        '404':
          description: Form not found or inactive
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /forms/{slug}/submit:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
    post:
      summary: Public — submit an intake form (creates an issue; rate-limited 10/h/IP, honeypot via `website_url`)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              additionalProperties: true
              description: |
                Free-form payload — all keys defined in the form's
                `fields` are accepted, plus `title`, optional
                `description` and `priority`. Send `website_url=""` to
                pass the honeypot.
              properties:
                title:       { type: string }
                description: { type: string, nullable: true }
                priority:    { type: string, description: "low | medium | high | critical" }
                website_url: { type: string, description: "Honeypot — must be empty/absent" }
      responses:
        '200':
          description: Submission accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IntakeFormSubmissionResult' }
        '400':
          description: Validation error (e.g. missing title)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: Rate limited (10/h/IP)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Financial Statements ─────────────────────────────────────────
  /statements/balance-sheet:
    get:
      summary: Balance sheet (with optional comparative period)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: as_of_date,  in: query, schema: { type: string, format: date } }
        - { name: comparative, in: query, schema: { type: integer, enum: [0, 1] }, description: "1 = include prior-year snapshot" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BalanceSheetResponse' }
        '403':
          description: Plan does not include billing
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /statements/cash-flow:
    get:
      summary: Cash-flow statement (indirect method)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: from_date,   in: query, schema: { type: string, format: date } }
        - { name: to_date,     in: query, schema: { type: string, format: date } }
        - { name: comparative, in: query, schema: { type: integer, enum: [0, 1] } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CashFlowResponse' }

  /statements/ratios:
    get:
      summary: Liquidity / solvency / profitability / efficiency ratios
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: as_of_date, in: query, schema: { type: string, format: date } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FinancialRatiosResponse' }

  /statements/comparative:
    get:
      summary: Side-by-side P&L + balance sheet for two periods
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: period_a_start, in: query, schema: { type: string, format: date } }
        - { name: period_a_end,   in: query, schema: { type: string, format: date } }
        - { name: period_b_start, in: query, schema: { type: string, format: date } }
        - { name: period_b_end,   in: query, schema: { type: string, format: date } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ComparativeStatement' }

  # ── General Ledger ───────────────────────────────────────────────
  /gl/accounts:
    get:
      summary: Chart of accounts with running totals
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/LedgerAccount' }
    post:
      summary: Create a GL account
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, name, type, balance_type]
              additionalProperties: true
              properties:
                code:         { type: string }
                name:         { type: string }
                type:         { type: string, description: "asset | liability | equity | revenue | expense" }
                parent_id:    { type: string, format: uuid, nullable: true }
                is_header:    { type: boolean }
                balance_type: { type: string, description: "debit | credit" }
                currency:     { type: string }
                is_active:    { type: boolean }
                description:  { type: string, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LedgerAccount' }
        '409':
          description: Duplicate account code
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /gl/accounts/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      summary: GL account detail with last 50 transactions
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LedgerAccount' }
    put:
      summary: Update a GL account (code/name/parent/type/etc.)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LedgerAccount' }
    delete:
      summary: Delete a GL account (blocked if it has transactions or sub-accounts)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
        '409':
          description: Account has transactions or sub-accounts
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /gl/accounts/{accountId}/ledger:
    parameters:
      - { name: accountId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      summary: Posted journal lines for an account with running balance
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to,   in: query, schema: { type: string, format: date } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccountLedger' }

  /gl/journal-entries:
    get:
      summary: List journal entries (paginated, filterable)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: page,        in: query, schema: { type: integer, minimum: 1 } }
        - { name: limit,       in: query, schema: { type: integer, minimum: 10, maximum: 100 } }
        - { name: status,      in: query, schema: { type: string, description: "draft | posted | reversed" } }
        - { name: source_type, in: query, schema: { type: string, description: "manual | invoice | expense | payroll | asset | adjustment" } }
        - { name: from,        in: query, schema: { type: string, format: date } }
        - { name: to,          in: query, schema: { type: string, format: date } }
        - { name: search,      in: query, schema: { type: string } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JournalEntriesPage' }
    post:
      summary: Create a journal entry (must balance debits = credits)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [date, description, lines]
              properties:
                date:        { type: string, format: date }
                description: { type: string }
                reference:   { type: string, nullable: true }
                source_type: { type: string }
                source_id:   { type: string, format: uuid, nullable: true }
                lines:
                  type: array
                  minItems: 2
                  items:
                    type: object
                    required: [account_id]
                    properties:
                      account_id:     { type: string, format: uuid }
                      debit:          { type: number }
                      credit:         { type: number }
                      description:    { type: string, nullable: true }
                      cost_center_id: { type: string, format: uuid, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JournalEntry' }

  /gl/journal-entries/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      summary: Journal entry detail (with `lines` inlined)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JournalEntry' }

  /gl/journal-entries/{id}/post:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      summary: Post a draft journal entry (refuses if the period is closed/locked)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JournalEntry' }
        '409':
          description: Already posted, or accounting period is closed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /gl/journal-entries/{id}/reverse:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      summary: Create a reversal entry that swaps debits and credits of the original
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                date: { type: string, format: date, description: "Defaults to today" }
      responses:
        '201':
          description: Reversal created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JournalEntry' }

  /gl/trial-balance:
    get:
      summary: Trial balance for a year (or year+month)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: year,  in: query, schema: { type: integer } }
        - { name: month, in: query, schema: { type: integer, minimum: 0, maximum: 12 }, description: "0 = full year" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TrialBalance' }

  /gl/periods:
    get:
      summary: Status of every month in a fiscal year (open / closed / locked)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: year, in: query, schema: { type: integer } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/LedgerPeriod' }

  /gl/periods/close:
    post:
      summary: Close a single month (locks new postings until reopened)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [year, month]
              properties:
                year:  { type: integer }
                month: { type: integer, minimum: 1, maximum: 12 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LedgerPeriod' }
        '409':
          description: Period already locked
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Gamification ─────────────────────────────────────────────────
  /gamification/leaderboard:
    get:
      summary: Weekly leaderboard (top 50 by `points_week`)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK — empty array if the gamification tables don't exist yet
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/LeaderboardEntry' }

  /gamification/my-stats:
    get:
      summary: Calling user's points, streak, rank and unlocked achievements
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GamificationStats' }

  /gamification/achievements:
    get:
      summary: Every achievement definition with the calling user's unlock status
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Achievement' }

  # ── Global Search ────────────────────────────────────────────────
  /search:
    get:
      summary: Cross-entity full-text search (projects, issues, docs, contacts, invoices, expenses)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: q,     in: query, required: true, schema: { type: string, minLength: 2 } }
        - { name: types, in: query, schema: { type: string, description: "CSV; defaults to all 6 types" } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 50 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SearchResults' }

  # ── Profiles ─────────────────────────────────────────────────────
  /profile/{username}:
    parameters:
      - { name: username, in: path, required: true, schema: { type: string } }
    get:
      summary: Public profile (or minimal card if private and viewer is not the owner)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PublicProfile' }
        '404':
          description: User not found / blocked
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /profile/{username}/activity:
    parameters:
      - { name: username, in: path, required: true, schema: { type: string } }
    get:
      summary: Activity feed (cursor-paginated by `created_at`)
      parameters:
        - { name: cursor, in: query, schema: { type: string, format: date-time } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProfileActivityFeed' }

  /profile/{username}/heatmap:
    parameters:
      - { name: username, in: path, required: true, schema: { type: string } }
    get:
      summary: 365-day activity heatmap with selectable comparison period
      parameters:
        - { name: period, in: query, schema: { type: string, description: "week | month | quarter — defaults to month" } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProfileHeatmap' }

  /settings/profile:
    get:
      summary: Calling user's full own profile
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PublicProfile' }
    patch:
      summary: Update profile fields (display_name, headline, bio, urls, theme, …)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              properties:
                username:     { type: string }
                display_name: { type: string }
                headline:     { type: string, nullable: true }
                bio:          { type: string, nullable: true }
                location:     { type: string, nullable: true }
                timezone:     { type: string, nullable: true }
                website_url:  { type: string, nullable: true }
                linkedin_url: { type: string, nullable: true }
                theme:        { type: string, description: "light | dark | alpine | deep-dark | null" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProfileSettings' }
        '422':
          description: Validation error (username taken, invalid URL, …)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /settings/privacy:
    patch:
      summary: Update privacy toggles (visibility / DM rules / show_* flags)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProfilePrivacy' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProfilePrivacy' }

  /settings/profile/pins:
    patch:
      summary: Update pinned projects (max 6, must be in shared orgs)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pinned_projects]
              properties:
                pinned_projects:
                  type: array
                  maxItems: 6
                  items: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PinnedProjectsResult' }

  /settings/profile/experiences:
    get:
      summary: List own experience entries
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ProfileExperience' }
    post:
      summary: Add an experience entry
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, company]
              properties:
                title:       { type: string }
                company:     { type: string }
                location:    { type: string, nullable: true }
                start_date:  { type: string, format: date, nullable: true }
                end_date:    { type: string, format: date, nullable: true }
                is_current:  { type: boolean }
                description: { type: string, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProfileExperience' }

  /settings/profile/experiences/reorder:
    patch:
      summary: Reorder experience entries (zero-based positions)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids: { type: array, items: { type: string } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  /settings/profile/experiences/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    patch:
      summary: Update an experience entry
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProfileExperience' }
    delete:
      summary: Delete an experience entry
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  /settings/profile/education:
    get:
      summary: List own education entries
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ProfileEducation' }
    post:
      summary: Add an education entry
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [institution]
              properties:
                institution:    { type: string }
                degree:         { type: string, nullable: true }
                field_of_study: { type: string, nullable: true }
                start_year:     { type: integer, nullable: true }
                end_year:       { type: integer, nullable: true }
                description:    { type: string, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProfileEducation' }

  /settings/profile/education/reorder:
    patch:
      summary: Reorder education entries
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids: { type: array, items: { type: string } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  /settings/profile/education/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    patch:
      summary: Update an education entry
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProfileEducation' }
    delete:
      summary: Delete an education entry
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  /settings/profile/certifications:
    get:
      summary: List own certifications
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ProfileCertification' }
    post:
      summary: Add a certification
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, issuing_org]
              properties:
                title:          { type: string }
                issuing_org:    { type: string }
                issue_date:     { type: string, format: date, nullable: true }
                expiry_date:    { type: string, format: date, nullable: true }
                credential_id:  { type: string, nullable: true }
                credential_url: { type: string, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProfileCertification' }

  /settings/profile/certifications/reorder:
    patch:
      summary: Reorder certifications
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids: { type: array, items: { type: string } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  /settings/profile/certifications/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    patch:
      summary: Update a certification
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProfileCertification' }
    delete:
      summary: Delete a certification
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  /people/search:
    get:
      summary: Search users by username/name/email-prefix (rate-limited)
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, minLength: 2 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PersonSearchResult' }

  /orgs/search:
    get:
      summary: Org autocomplete — merges `organizations.name` + unique `user_experiences.company` strings
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, minLength: 2 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrgSearchResults' }

  /org/{slug}:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
    get:
      summary: Public organisation profile page
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrgProfile' }
        '404':
          description: Organisation not found / suspended / private and viewer is anonymous
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /org/{slug}/follow:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
    post:
      summary: Follow an organisation
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrgFollowResult' }
    delete:
      summary: Unfollow an organisation
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrgFollowResult' }

  # ── Social ───────────────────────────────────────────────────────
  /social/follow/{userId}:
    parameters:
      - { name: userId, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      summary: Follow a user (auto-rejected if either side has blocked the other)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SocialFollowResult' }
        '409':
          description: Already following
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      summary: Unfollow a user
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SocialFollowResult' }

  /social/block/{userId}:
    parameters:
      - { name: userId, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      summary: Block a user (auto-unfollows in both directions)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SocialBlockResult' }
    delete:
      summary: Unblock a user
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SocialBlockResult' }

  /social/blocked:
    get:
      summary: List the calling user's blocks (max 200)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SocialBlockedList' }

  /social/{username}/followers:
    parameters:
      - { name: username, in: path, required: true, schema: { type: string } }
    get:
      summary: Followers of a user (cursor-paginated, 20/page)
      parameters:
        - { name: cursor, in: query, schema: { type: string, format: date-time } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SocialFollowers' }

  /social/{username}/following:
    parameters:
      - { name: username, in: path, required: true, schema: { type: string } }
    get:
      summary: Accounts a user follows (cursor-paginated, 20/page)
      parameters:
        - { name: cursor, in: query, schema: { type: string, format: date-time } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SocialFollowing' }


  # ── Sprint 10: teamApi.js + issueApi.js residue ──────────────────

  # ── Employees (HR) ─────────────────────────────────────────────────
  /employees:
    get:
      summary: List employees (HR)
      description: Requires `billing.payroll` capability.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: search,            in: query, schema: { type: string } }
        - { name: active,            in: query, schema: { type: string }, description: "0 or 1; ignored when include_inactive=1" }
        - { name: include_inactive,  in: query, schema: { type: string, enum: ['1'] } }
        - { name: department,        in: query, schema: { type: string } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Employee' }
    post:
      summary: Create an employee
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [first_name, last_name]
              properties:
                first_name:  { type: string, maxLength: 100 }
                last_name:   { type: string, maxLength: 100 }
                email:       { type: string, format: email }
                position:    { type: string }
                department:  { type: string }
                base_salary: { type: number }
                currency:    { type: string, description: "ISO 4217 — defaults to EUR" }
                hire_date:   { type: string, format: date }
                user_id:     { type: string, format: uuid, nullable: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Employee' }

  /employees/fields:
    get:
      summary: Distinct department + position values for the org
      description: |
        Despite the route name, this returns a set of distinct dept /
        position strings — not a dynamic field-definition list.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EmployeeFields' }

  /employees/bulk:
    post:
      summary: Bulk activate / deactivate / delete employees
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [employee_ids, action]
              properties:
                employee_ids:
                  type: array
                  items: { type: string, format: uuid }
                action:
                  type: string
                  enum: [activate, deactivate, delete]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EmployeeBulkResult' }

  /employees/department-stats:
    get:
      summary: Headcount + active count per department
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EmployeeDepartmentStats' }

  /employees/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get an employee
      description: Includes the last 10 payroll items as `payroll_history`.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Employee' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      summary: Update an employee
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                first_name:  { type: string }
                last_name:   { type: string }
                email:       { type: string, format: email }
                position:    { type: string }
                department:  { type: string }
                base_salary: { type: number }
                currency:    { type: string }
                hire_date:   { type: string, format: date }
                user_id:     { type: string, format: uuid, nullable: true }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Employee' }
    delete:
      summary: Hard-delete an employee
      description: Refuses if the employee has payroll history; deactivate instead.
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '409':
          description: Has payroll history
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /employees/{id}/deactivate:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Toggle active flag (NOT a one-way deactivate)
      description: |
        Flips `is_active` and mirrors the change into
        `organization_users.status`. Despite the route name, calling it
        on an inactive employee re-activates them.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:   { type: boolean }
                  is_active: { type: integer, enum: [0, 1] }

  # ── Payroll (legacy v1 engine) ────────────────────────────────────
  /payroll:
    get:
      summary: List legacy payroll runs
      description: Distinct from `/payroll-v2/runs`. List rows include `employee_count`.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PayrollRunLegacy' }
    post:
      summary: Create a payroll run (draft)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, period_start, period_end]
              properties:
                name:         { type: string, maxLength: 255 }
                period_start: { type: string, format: date }
                period_end:   { type: string, format: date }
                items:
                  type: array
                  items: { $ref: '#/components/schemas/PayrollRunLegacyItem' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayrollRunLegacy' }

  /payroll/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get a payroll run with items + signatures
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayrollRunLegacy' }
    put:
      summary: Update a payroll run
      description: When `items` is provided, the existing items are replaced wholesale and totals recomputed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:   { type: string }
                status: { type: string, enum: [draft, approved, paid] }
                items:
                  type: array
                  items: { $ref: '#/components/schemas/PayrollRunLegacyItem' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayrollRunLegacy' }
    delete:
      summary: Delete a payroll run (and its items)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  /payroll/{id}/approve:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Approve a draft payroll run
      description: Requires an existing approver signature row and a `draft` status.
      responses:
        '200':
          description: Approved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayrollRunLegacy' }
        '400':
          description: Not draft / approver signature missing
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /payroll/{id}/sign:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Add a creator/approver signature to a draft run
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signer_role, signature_mode, signature_data, signer_name]
              properties:
                signer_role:    { type: string, enum: [creator, approver] }
                signature_mode: { type: string, enum: [draw, typed] }
                signature_data: { type: string }
                signer_name:    { type: string, maxLength: 255 }
                signer_title:   { type: string }
                typed_text:     { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayrollSignature' }
        '400':
          description: Run not in draft status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: Caller is not the creator (when signer_role=creator)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /payroll/{id}/export:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Bundle a payroll run for client-side PDF rendering
      description: |
        Returns the run + items + signatures + org name + a base64
        data URI for the org logo. Not a binary stream.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayrollExportData' }

  # ── Capacity ─────────────────────────────────────────────────────
  /capacity:
    get:
      summary: Per-member capacity report (open issues + estimated hours)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CapacityResult' }

  /capacity/threshold:
    get:
      summary: Get the org's overload threshold (hours/week)
      description: |
        Both GET and POST are served by the same `overloadThreshold`
        method (verb-switched). Stored under
        `org_branding.settings.capacity_overload_threshold`.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CapacityThreshold' }
    post:
      summary: Set the org's overload threshold (owner/admin)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [overload_threshold]
              properties:
                overload_threshold: { type: number, minimum: 1, maximum: 168 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CapacityThreshold' }
        '403':
          description: Insufficient permissions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Notification preferences ─────────────────────────────────────
  /notifications/preferences:
    get:
      summary: Get notification preferences for the user/org
      description: |
        Defaults are all-on for any notification type without a saved
        preference row. The `push` field is included even when the
        underlying column hasn't been migrated yet (defaults to 1).
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NotificationPreferences' }
    put:
      summary: Save notification preferences
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [preferences]
              properties:
                preferences:
                  type: array
                  items: { $ref: '#/components/schemas/NotificationPreferenceRow' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  /notifications/digest:
    get:
      summary: Get digest settings for the user/org
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NotificationDigest' }
    put:
      summary: Update digest settings
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                frequency: { type: string, enum: [daily, weekly] }
                enabled:   { type: integer, enum: [0, 1] }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  # ── Push (web + native) ──────────────────────────────────────────
  /push/subscribe:
    post:
      summary: Save a Web Push subscription
      description: Body accepts either `{endpoint, keys.p256dh, keys.auth}` (PushSubscription.toJSON) or flat `{endpoint, p256dh, auth}`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [endpoint]
              properties:
                endpoint: { type: string }
                keys:
                  type: object
                  properties:
                    p256dh: { type: string }
                    auth:   { type: string }
                p256dh: { type: string }
                auth:   { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PushSubscriptionResult' }

  /push/unsubscribe:
    post:
      summary: Remove a Web Push subscription by endpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [endpoint]
              properties:
                endpoint: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  unsubscribed: { type: boolean }

  /push/unregister-device:
    post:
      summary: Remove a native device push token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  unregistered: { type: boolean }
                  message:      { type: string }

  /push/test:
    post:
      summary: Send a test push to all of the caller's subscriptions
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:      { type: boolean }
                  message: { type: string }

  /push/track:
    post:
      summary: Record a SW-level push analytics event (clicked / dismissed)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                event:           { type: string, enum: [clicked, dismissed] }
                type:            { type: string }
                notification_id: { type: string, nullable: true }
                tag:             { type: string, nullable: true }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }

  # ── Favorites ────────────────────────────────────────────────────
  /favorites:
    get:
      summary: List the caller's favorites in the current org
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FavoritesList' }
    post:
      summary: Add a favorite (project or issue)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [entity_type, entity_id]
              properties:
                entity_type: { type: string, enum: [project, issue] }
                entity_id:   { type: string, format: uuid }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Favorite' }
        '409':
          description: Already favorited
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /favorites/reorder:
    put:
      summary: Reorder favorites
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order]
              properties:
                order:
                  type: array
                  items:
                    type: object
                    required: [id, sort_order]
                    properties:
                      id:         { type: string, format: uuid }
                      sort_order: { type: integer }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  /favorites/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    delete:
      summary: Remove a favorite
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  # ── Organisations (residue) ──────────────────────────────────────
  /orgs:
    post:
      summary: Create an organisation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 255 }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }

  /orgs/join:
    post:
      summary: Join an organisation by invite code
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrgJoinByCode' }
      responses:
        '200':
          description: OK / already a member
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }
        '404':
          description: Invalid invite code
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # /orgs/search — already defined earlier (sprint 9). OrgSearchResult schema kept for reuse.

  /orgs/{orgId}:
    parameters:
      - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Get the current organisation
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }
    put:
      summary: Update org settings (PUT — name required)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              additionalProperties: true # TODO — many optional fiscal/billing fields (legal_name, tax_id, fiscal_*, billing_email, default_currency, …)
              properties:
                name:               { type: string, maxLength: 255 }
                logo_url:           { type: string, maxLength: 500 }
                legal_name:         { type: string }
                tax_id:             { type: string }
                fiscal_address:     { type: string }
                fiscal_city:        { type: string }
                fiscal_postal_code: { type: string }
                fiscal_country:     { type: string, description: "ISO 3166-1 alpha-2" }
                billing_email:      { type: string, format: email }
                default_currency:   { type: string, description: "ISO 4217" }
                default_locale:     { type: string }
                timezone:           { type: string }
                iban:               { type: string }
                bic:                { type: string }
                bank_name:          { type: string }
                whitelabel_config:  { type: object, additionalProperties: true }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }
    patch:
      summary: Partial update of org settings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }
    delete:
      summary: Delete an organisation (owner-only)
      description: Requires `confirm_name` matching the org's current name (query or body).
      parameters:
        - { name: confirm_name, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400':
          description: Confirmation mismatch
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: Caller is not the owner
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /orgs/{orgId}/logo:
    parameters:
      - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Upload an organisation logo
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }

  # ── Dashboard ────────────────────────────────────────────────────
  /dashboard:
    get:
      summary: Org dashboard summary (camelCase keys)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - name: from
          in: query
          required: false
          schema: { type: string, format: date }
          description: Start of the window applied to issue counts (inclusive). Used by the chip-bar on the dashboard view.
        - name: to
          in: query
          required: false
          schema: { type: string, format: date }
          description: End of the window applied to issue counts (inclusive).
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DashboardSummary' }

  /insights/{projectId}:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Project-level insights (v5)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true # TODO — DashboardController::insights output

  # ── Whitelabel ───────────────────────────────────────────────────
  /settings/whitelabel:
    get:
      summary: Get the current org's whitelabel config
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Whitelabel' }
    put:
      summary: Replace the whitelabel config (Business / Enterprise)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Whitelabel' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Whitelabel' }
        '403':
          description: Plan or role not permitted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Addons (org-level store, not platform-admin) ─────────────────
  /addons:
    get:
      summary: Addon catalog with installed flags
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AddonCatalogResult' }

  /addons/{slug}/install:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Install an addon (owner/admin)
      responses:
        '200':
          description: Installed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
                  slug:    { type: string }

  /addons/{slug}/uninstall:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Uninstall an addon (owner/admin)
      responses:
        '200':
          description: Uninstalled
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
                  slug:    { type: string }

  /addons/{slug}/reviews:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Reviews for an addon (plus the caller's own rating/review)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AddonReviewsResult' }

  /addons/{slug}/rate:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Rate an addon 1-5
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rating]
              properties:
                rating: { type: integer, minimum: 1, maximum: 5 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
                  rating:  { type: integer }

  /addons/{slug}/review:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Submit a text review for an installed addon
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string, minLength: 10, maxLength: 2000 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }

  # ── Integrations (v5) ────────────────────────────────────────────
  /integrations:
    get:
      summary: List integrations (catalog merged with org's connected rows)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Integration' }

  /integrations/{id}/connect:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: "Integration `type`, e.g. `slack`" }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Connect / reconnect an integration
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                config: { type: object, additionalProperties: true }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IntegrationToggleResult' }

  /integrations/{id}/disconnect:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Disconnect an integration
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IntegrationToggleResult' }

  # ── Issues — bulk + PDF/MD export ────────────────────────────────
  /issues/bulk:
    post:
      summary: Bulk action on issues
      description: |
        Non-admin users are silently constrained to issues assigned to
        them. The `delete` action is owner/admin only.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [issue_ids, action]
              properties:
                issue_ids:
                  type: array
                  items: { type: string, format: uuid }
                action:
                  type: string
                  enum: [status, priority, assignee, sprint, archive, delete]
                value:
                  description: Required for status / priority; optional (id or null) for assignee / sprint.
                  oneOf:
                    - { type: string }
                    - { type: 'null' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BulkIssueActionResult' }

  /issues/export-pdf:
    post:
      summary: Export selected issues as PDF or markdown (binary stream)
      description: |
        Streams `application/pdf` or `text/markdown` directly — NOT a
        JSON envelope. The JS wrapper reads it as a Blob.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [issue_ids]
              properties:
                issue_ids:
                  type: array
                  items: { type: string, format: uuid }
                mode:                { type: string, enum: [report, claude], default: report }
                format:              { type: string, enum: [pdf, md], default: pdf }
                include_comments:    { type: boolean, default: true }
                include_attachments: { type: boolean, default: true }
                title:               { type: string, default: "Task Report" }
      responses:
        '200':
          description: PDF or markdown bytes
          content:
            application/pdf:
              schema: { type: string, format: binary }
            text/markdown:
              schema: { type: string }

  # ── Issue comments (edit + reactions) ────────────────────────────
  /issues/{issueId}/comments/{commentId}:
    parameters:
      - { name: issueId,   in: path, required: true, schema: { type: string, format: uuid } }
      - { name: commentId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    put:
      summary: Edit a comment (author or admin/manager only)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  /issues/{issueId}/comments/{commentId}/reactions:
    parameters:
      - { name: issueId,   in: path, required: true, schema: { type: string, format: uuid } }
      - { name: commentId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    post:
      summary: Toggle a reaction emoji on a comment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [emoji]
              properties:
                emoji: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:   { type: boolean }
                  reactions:
                    type: object
                    additionalProperties:
                      type: array
                      items: { type: string, format: uuid }
                    description: Emoji to array of user ids that reacted

  # ── Sprints ──────────────────────────────────────────────────────
  /sprints:
    get:
      summary: List sprints (org-wide or for a single project)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: project_id, in: query, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Sprint' }
    post:
      summary: Create a sprint
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id, name]
              properties:
                project_id: { type: string, format: uuid }
                name:       { type: string, maxLength: 255 }
                goal:       { type: string }
                start_date: { type: string, format: date }
                end_date:   { type: string, format: date }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sprint' }

  /sprints/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    put:
      summary: Update a sprint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:       { type: string }
                goal:       { type: string }
                status:     { type: string, enum: [planning, active, completed] }
                start_date: { type: string, format: date, nullable: true }
                end_date:   { type: string, format: date, nullable: true }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sprint' }
    delete:
      summary: Delete a sprint
      responses:
        '200': { description: Deleted }

  # ── Issue dependencies ───────────────────────────────────────────
  /dependencies:
    get:
      summary: List dependencies (org-wide or filtered by issue)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: issue_id, in: query, schema: { type: string, format: uuid }, description: "Returns deps where the issue is on either side" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/IssueDependency' }
    post:
      summary: Create a dependency between two issues
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [source_id, target_id]
              properties:
                source_id: { type: string, format: uuid }
                target_id: { type: string, format: uuid }
                type:      { type: string, enum: [blocks, blocked_by, relates_to], default: blocks }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IssueDependency' }

  /dependencies/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    delete:
      summary: Delete a dependency
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  # ── Saved issue filters ──────────────────────────────────────────
  /filters:
    get:
      summary: List the caller's saved filters
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/IssueFilter' }
    post:
      summary: Save a filter
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, filters]
              properties:
                name:    { type: string, maxLength: 255 }
                filters: { type: object, additionalProperties: true }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IssueFilter' }

  /filters/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    delete:
      summary: Delete a saved filter
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

  # ── Activity feed ────────────────────────────────────────────────
  /activity:
    get:
      summary: List activity log entries (paginated)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: project_id,  in: query, schema: { type: string, format: uuid } }
        - { name: user_id,     in: query, schema: { type: string, format: uuid } }
        - { name: action,      in: query, schema: { type: string }, description: "Comma-separated list of action types" }
        - { name: entity_type, in: query, schema: { type: string } }
        - { name: entity_id,   in: query, schema: { type: string } }
        - { name: date_from,   in: query, schema: { type: string, format: date } }
        - { name: date_to,     in: query, schema: { type: string, format: date } }
        - { name: limit,       in: query, schema: { type: integer, minimum: 1, maximum: 200 } }
        - { name: offset,      in: query, schema: { type: integer, minimum: 0 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ActivityList' }

  # ── Analytics (Pro+) ─────────────────────────────────────────────
  /analytics:
    get:
      summary: Analytics overview
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { type: object, additionalProperties: true } # TODO — AnalyticsController::overview shape

  /analytics/trends:
    get:
      summary: Trend metrics for a project
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: project_id, in: query, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { type: object, additionalProperties: true } # TODO

  /analytics/cycle-time:
    get:
      summary: Cycle-time stats for a project
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: project_id, in: query, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { type: object, additionalProperties: true } # TODO

  /analytics/export:
    get:
      summary: Export analytics for a project
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: project_id, in: query, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { type: object, additionalProperties: true } # TODO

  # ── Search ───────────────────────────────────────────────────────
  # /search — already defined earlier (sprint 9). GlobalSearchResult kept for reuse.

  # ── CSV exports (return JSON-wrapped CSV, not blobs) ─────────────
  /exports/activity:
    get:
      summary: Export activity log as CSV (Business)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: project_id, in: query, schema: { type: string, format: uuid } }
        - { name: date_from,  in: query, schema: { type: string, format: date } }
        - { name: date_to,    in: query, schema: { type: string, format: date } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CsvExport' }

  /exports/notifications:
    get:
      summary: Export notifications as CSV (Business)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CsvExport' }

  /exports/issues:
    get:
      summary: Export all org issues as CSV (Business)
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CsvExport' }

  # ── My Work residue ──────────────────────────────────────────────
  /mywork/bulk:
    post:
      summary: Bulk modify issues from MyWork
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [issue_ids, action]
              properties:
                issue_ids:
                  type: array
                  items: { type: string, format: uuid }
                action: { type: string, enum: [status, priority, assignee, assign, archive, delete] }
                value:
                  oneOf:
                    - { type: string }
                    - { type: 'null' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MyWorkBulkResult' }
        '422':
          description: All submitted ids stale or forbidden
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MyWorkBulkResult' }

  /mywork/views:
    get:
      summary: List the caller's saved MyWork views
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SavedView' }
    post:
      summary: Create a saved MyWork view
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:      { type: string }
                filters:   { type: object, additionalProperties: true }
                sort_by:   { type: string }
                group_by:  { type: string }
                is_shared: { type: integer, enum: [0, 1] }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:   { type: string, format: uuid }
                  name: { type: string }

  /mywork/views/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    delete:
      summary: Delete a saved MyWork view
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }

  /mywork/export:
    get:
      summary: Export the caller's MyWork rows as CSV
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: status,   in: query, schema: { type: string } }
        - { name: priority, in: query, schema: { type: string } }
        - { name: tags,     in: query, schema: { type: string } }
        - { name: project,  in: query, schema: { type: string } }
        - { name: due,      in: query, schema: { type: string, enum: [today, week, overdue, all] } }
        - { name: search,   in: query, schema: { type: string } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CsvExport' }

  /mywork/cross-org:
    get:
      summary: MyWork rows across every org the caller belongs to
      description: Identity-scoped — does not require X-Org-Id.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { type: object, additionalProperties: true } # TODO — MyWorkController::crossOrg shape

  # ── Task Feed (project-scoped) ───────────────────────────────────
  /tasks/feed:
    get:
      summary: Task feed for a single project
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: project_id,       in: query, required: true, schema: { type: string, format: uuid } }
        - { name: include_archived, in: query, schema: { type: boolean } }
        - { name: status,           in: query, schema: { type: string }, description: "Comma-separated subset of Backlog,To Do,In Progress,In Review,Done" }
        - { name: priority,         in: query, schema: { type: string }, description: "Comma-separated subset of critical,high,medium,low,none" }
        - { name: assignee,         in: query, schema: { type: string }, description: "user id, 'me', or 'unassigned'" }
        - { name: due,              in: query, schema: { type: string, enum: [today, week, overdue, custom] } }
        - { name: due_from,         in: query, schema: { type: string, format: date } }
        - { name: due_to,           in: query, schema: { type: string, format: date } }
        - { name: tag,              in: query, schema: { type: string } }
        - { name: search,           in: query, schema: { type: string } }
        - { name: sort,             in: query, schema: { type: string }, description: "field:dir, e.g. created_at:desc" }
        - { name: page,             in: query, schema: { type: integer, minimum: 1 } }
        - { name: per_page,         in: query, schema: { type: integer } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { type: object, additionalProperties: true } # TODO — TaskFeedController::index full shape

  /tasks/feed/bulk:
    post:
      summary: Bulk modify issues from the task feed
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true # TODO — same surface as MyWork bulk
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /tasks/feed/views:
    get:
      summary: List saved task-feed views
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SavedView' }
    post:
      summary: Create a saved task-feed view
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true # TODO — same surface as MyWork view create
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /tasks/feed/views/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    delete:
      summary: Delete a saved task-feed view
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }

  /tasks/feed/export:
    get:
      summary: Export the task feed as CSV
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
        - { name: project_id, in: query, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CsvExport' }

  # ── Imports ──────────────────────────────────────────────────────
  /imports/parse:
    post:
      summary: Parse an uploaded import file (CSV / XLSX / JSON)
      description: Admin-only. Persists a row in `imports` keyed by the returned `import_id`.
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImportParseResult' }

  /imports/execute:
    post:
      summary: Execute a parsed import against a project
      parameters:
        - $ref: '#/components/parameters/OrgIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rows, project_id]
              properties:
                rows:
                  type: array
                  items: { type: object, additionalProperties: true }
                project_id:    { type: string, format: uuid }
                import_id:     { type: string, format: uuid }
                field_mapping: { type: object, additionalProperties: true }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImportExecuteResult' }

  # ── Estimation + Burndown ────────────────────────────────────────
  /projects/{projectId}/estimation-accuracy:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Per-member estimation accuracy + history
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EstimationAccuracy' }

  /projects/{projectId}/sprints/{sprintId}/burndown:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: sprintId,  in: path, required: true, schema: { type: string, format: uuid } }
      - $ref: '#/components/parameters/OrgIdHeader'
    get:
      summary: Daily burndown / burnup data for a sprint
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SprintBurndown' }
