openapi: 3.1.0
info:
  title: Waterproofing Operations API
  version: 0.2.0
  description: Project-centered waterproofing operations API
servers:
  - url: /api
    description: Base API path
tags:
  - { name: health, description: Health operations }
  - { name: dashboard, description: Company pipeline overview }
  - { name: projects, description: Project lifecycle and workspace }
  - { name: crm, description: Customers and import preview }
  - { name: costs, description: Master cost list }
  - { name: language, description: Proposal language catalog }
  - { name: settings, description: User and company settings }
  - {
      name: team,
      description: "Team invitations, memberships, and workspace switching",
    }
  - { name: documents, description: "Project plans, photos, and job documents" }
  - {
      name: estimating,
      description: "Estimate snapshots and worksheet workbooks",
    }
paths:
  /healthz:
    get:
      operationId: healthCheck
      tags: [health]
      responses:
        "200":
          description: Healthy
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthStatus" }
  /dashboard:
    get:
      operationId: getDashboard
      tags: [dashboard]
      responses:
        "200":
          description: Dashboard data
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Dashboard" }
  /projects:
    get:
      operationId: listProjects
      tags: [projects]
      responses:
        "200":
          description: Company projects
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Project" }
    post:
      operationId: createProject
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectInput" }
      responses:
        "201":
          description: Created project
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
  /projects/export.csv:
    get:
      operationId: exportOpportunitiesCsv
      tags: [projects]
      parameters:
        - in: query
          name: search
          required: false
          schema: { type: string }
          description: Case-insensitive project or customer search
        - in: query
          name: pipeline
          required: false
          schema:
            type: string
            enum: [all, open, won, lost]
            default: all
          description: Opportunity pipeline group
      responses:
        "200":
          description: Company opportunity pipeline as CSV
          headers:
            Content-Disposition:
              schema: { type: string }
              description: Attachment filename
          content:
            text/csv:
              schema: { type: string, format: binary }
  /projects/export.xlsx:
    get:
      operationId: exportOpportunitiesXlsx
      tags: [projects]
      parameters:
        - in: query
          name: search
          required: false
          schema: { type: string }
          description: Case-insensitive project or customer search
        - in: query
          name: pipeline
          required: false
          schema:
            type: string
            enum: [all, open, won, lost]
            default: all
          description: Opportunity pipeline group
      responses:
        "200":
          description: Company opportunity pipeline as an Excel workbook
          headers:
            Content-Disposition:
              schema: { type: string }
              description: Attachment filename
          content:
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
              schema: { type: string, format: binary }
  /projects/{projectId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      operationId: getProject
      tags: [projects]
      responses:
        "200":
          description: Project
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
    patch:
      operationId: updateProject
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectUpdate" }
      responses:
        "200":
          description: Updated project
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
    delete:
      operationId: deleteProject
      tags: [projects]
      responses:
        "200":
          description: Deleted project
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeleteResult" }
  /projects/{projectId}/workspace:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      operationId: getProjectWorkspace
      tags: [projects]
      responses:
        "200":
          description: Project workspace
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProjectWorkspace" }
  /projects/{projectId}/convert:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      operationId: convertProjectToJob
      tags: [projects]
      responses:
        "200":
          description: Sold project
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
  /projects/{projectId}/notes:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      operationId: createProjectNote
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectNoteInput" }
      responses:
        "201":
          description: Created note
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProjectNote" }
  /projects/{projectId}/contacts:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    put:
      operationId: replaceOpportunityContacts
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OpportunityContactListInput" }
      responses:
        "200":
          description: Updated opportunity contacts
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/OpportunityContact" }
    post:
      operationId: createOpportunityContact
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewOpportunityContactInput" }
      responses:
        "201":
          description: Created and assigned contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OpportunityContact" }
  /projects/{projectId}/updates:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      operationId: createOpportunityUpdate
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OpportunityUpdateInput" }
      responses:
        "201":
          description: Created opportunity update
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OpportunityUpdate" }
  /opportunity-updates/{updateId}:
    parameters:
      - in: path
        name: updateId
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: updateOpportunityUpdate
      tags: [projects]
      description: >-
        Correct a logged call/email/text/meeting/note. Company-scoped through
        the update's parent project.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OpportunityUpdateEdit" }
      responses:
        "200":
          description: Updated opportunity update
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OpportunityUpdate" }
        "404":
          description: Update not found in this company
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
    delete:
      operationId: deleteOpportunityUpdate
      tags: [projects]
      description: Remove a logged communication entry.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeleteResult" }
        "404":
          description: Update not found in this company
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /project-notes/{noteId}:
    parameters:
      - in: path
        name: noteId
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: updateProjectNote
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectNoteInput" }
      responses:
        "200":
          description: Updated note
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProjectNote" }
    delete:
      operationId: deleteProjectNote
      tags: [projects]
      responses:
        "200":
          description: Deleted note
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeleteResult" }
  /projects/{projectId}/scopes:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      operationId: createProjectScope
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScopeInput" }
      responses:
        "201":
          description: Created scope
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Scope" }
  /scopes/{scopeId}:
    parameters:
      - in: path
        name: scopeId
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: updateProjectScope
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScopeUpdate" }
      responses:
        "200":
          description: Updated scope
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Scope" }
  /projects/{projectId}/estimates:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      operationId: createEstimate
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EstimateInput" }
      responses:
        "201":
          description: Created estimate
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Estimate" }
  /estimates/{estimateId}/refresh-pricing:
    parameters:
      - $ref: "#/components/parameters/EstimateId"
    post:
      operationId: refreshEstimatePricing
      tags: [projects]
      responses:
        "200":
          description: Refreshed pricing snapshot
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Estimate" }
  /worksheet-templates:
    get:
      operationId: listWorksheetTemplates
      tags: [estimating]
      responses:
        "200":
          description: Worksheet template library
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/WorksheetTemplate" }
  /worksheet-templates/{templateId}:
    parameters:
      - in: path
        name: templateId
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: updateWorksheetTemplate
      tags: [estimating]
      description: >-
        Per-company template settings. Today: the template-level Target Gross
        Margin override (owner/admin only). The override is frozen into each
        estimate's pricing-settings snapshot when the estimate is created or its
        pricing is refreshed, so existing pinned worksheets do not change price
        until Refresh Pricing — same behaviour as the company Target Gross Margin.
        Precedence at calc time: worksheet input → this override → definition
        value → company Settings. See docs/estimating-templates/pricing-model.md.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorksheetTemplateUpdate" }
      responses:
        "200":
          description: Updated template
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetTemplate" }
        "403":
          description: Only owners or admins can change template pricing settings
        "404":
          description: Template not found
  /estimates/{estimateId}:
    parameters:
      - $ref: "#/components/parameters/EstimateId"
    get:
      operationId: getEstimateWorkbook
      tags: [estimating]
      responses:
        "200":
          description: Estimate workbook with ordered worksheets
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EstimateWorkbook" }
    patch:
      operationId: updateEstimate
      tags: [estimating]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EstimateUpdate" }
      responses:
        "200":
          description: Updated estimate
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Estimate" }
    delete:
      operationId: deleteEstimate
      tags: [estimating]
      responses:
        "204":
          description: Estimate deleted
  /estimates/{estimateId}/travel:
    parameters:
      - $ref: "#/components/parameters/EstimateId"
    patch:
      operationId: updateEstimateTravel
      tags: [estimating]
      description: >-
        Project-level travel & drive time (calculated once per estimate, never
        per worksheet — see docs/estimating-templates/travel-and-mobilization.md).
        Partial update; omitted fields keep their stored value. Editing one-way
        minutes/miles by hand sets routeSource to "manual". Recalculates every
        engine worksheet (their drive-time rows are suppressed while travel is
        enabled) and the estimate totals, then returns the workbook.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EstimateTravelUpdate" }
      responses:
        "200":
          description: Workbook with the updated travel inputs, travel result and financial summary
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EstimateWorkbook" }
        "422":
          description: Invalid travel inputs
  /estimates/{estimateId}/travel/route-lookup:
    parameters:
      - $ref: "#/components/parameters/EstimateId"
    post:
      operationId: lookupEstimateTravelRoute
      tags: [estimating]
      description: >-
        Look up one-way drive minutes and miles from the company travel start
        address (Settings; falls back to the company address) to the project
        address using the configured routing provider (Google Distance Matrix
        when GOOGLE_MAPS_API_KEY is set; a deterministic mock when
        ROUTING_PROVIDER=mock; otherwise unavailable). Never blocks estimating:
        status "unavailable" with a reason when the credential is missing or an
        address is blank, "failed" when the provider errored — manual minutes
        and miles keep working. On "ok" the values are stored with routeSource
        "google"/"mock" and the workbook is recalculated.
      responses:
        "200":
          description: Lookup outcome
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TravelRouteLookupResult" }
  /estimates/{estimateId}/duplicate:
    parameters:
      - $ref: "#/components/parameters/EstimateId"
    post:
      operationId: duplicateEstimate
      tags: [estimating]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EstimateDuplicate" }
      responses:
        "201":
          description: Duplicated estimate
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Estimate" }
  /estimates/{estimateId}/worksheets:
    parameters:
      - $ref: "#/components/parameters/EstimateId"
    post:
      operationId: createWorksheet
      tags: [estimating]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorksheetCreate" }
      responses:
        "201":
          description: Created worksheet instance
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetDetail" }
  /estimates/{estimateId}/worksheets/reorder:
    parameters:
      - $ref: "#/components/parameters/EstimateId"
    post:
      operationId: reorderWorksheets
      tags: [estimating]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorksheetOrder" }
      responses:
        "200":
          description: Workbook with the new worksheet order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EstimateWorkbook" }
  /estimates/{estimateId}/reprice-preview:
    parameters:
      - $ref: "#/components/parameters/EstimateId"
    get:
      operationId: getEstimateRepricePreview
      tags: [estimating]
      responses:
        "200":
          description: Combined old vs new bid totals if every outdated interactive worksheet were re-priced to its template's active version
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EstimateRepricePreview" }
        "404":
          description: Estimate not found
        "409":
          description: No worksheet in this estimate is on outdated pricing
  /estimates/{estimateId}/reprice:
    parameters:
      - $ref: "#/components/parameters/EstimateId"
    post:
      operationId: repriceEstimate
      tags: [estimating]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RepriceEstimateRequest" }
      responses:
        "200":
          description: Every outdated interactive worksheet repointed to its active pricing version and re-evaluated; returns the refreshed workbook
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EstimateWorkbook" }
        "404":
          description: Estimate not found
        "409":
          description: Nothing outdated to re-price, or pricing/worksheets changed since the confirmed preview (nothing was applied)
  /worksheets/{worksheetId}:
    parameters:
      - $ref: "#/components/parameters/WorksheetId"
    get:
      operationId: getWorksheet
      tags: [estimating]
      responses:
        "200":
          description: Worksheet detail
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetDetail" }
    patch:
      operationId: updateWorksheet
      tags: [estimating]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorksheetUpdate" }
      responses:
        "200":
          description: Updated worksheet
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetDetail" }
        "409":
          description: The worksheet changed since the client loaded it (stale baseUpdatedAt)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetConflict" }
    delete:
      operationId: deleteWorksheet
      tags: [estimating]
      responses:
        "204":
          description: Worksheet deleted
  /worksheets/{worksheetId}/duplicate:
    parameters:
      - $ref: "#/components/parameters/WorksheetId"
    post:
      operationId: duplicateWorksheet
      tags: [estimating]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorksheetDuplicate" }
      responses:
        "201":
          description: Duplicated worksheet
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetDetail" }
  /worksheets/{worksheetId}/input-values:
    parameters:
      - $ref: "#/components/parameters/WorksheetId"
    put:
      operationId: saveWorksheetInputValues
      tags: [estimating]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorksheetValuesUpdate" }
      responses:
        "200":
          description: Worksheet with saved input values
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetDetail" }
        "409":
          description: The worksheet changed since the client loaded it (stale baseRevision)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetConflict" }
  /worksheets/{worksheetId}/custom-cost-rows:
    parameters:
      - $ref: "#/components/parameters/WorksheetId"
    put:
      operationId: saveWorksheetCustomCostRows
      tags: [estimating]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              { $ref: "#/components/schemas/WorksheetCustomCostRowsUpdate" }
      responses:
        "200":
          description: Worksheet with saved custom cost rows
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetDetail" }
        "409":
          description: The worksheet changed since the client loaded it (stale baseRevision)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetConflict" }
  /worksheets/{worksheetId}/buildable:
    parameters:
      - $ref: "#/components/parameters/WorksheetId"
    put:
      operationId: saveWorksheetBuildable
      tags: [estimating]
      description: Full-replacement save of a Blank Misc buildable worksheet's normalized state. Only valid for worksheets whose immutable template sourceKey is blank-misc.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorksheetBuildableUpdate" }
      responses:
        "200":
          description: Worksheet with the saved buildable state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetDetail" }
        "409":
          description: The worksheet changed since the client loaded it (stale baseRevision)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetConflict" }
        "422":
          description: The buildable state failed validation
  /worksheets/{worksheetId}/interactive:
    parameters:
      - $ref: "#/components/parameters/WorksheetId"
    get:
      operationId: getInteractiveWorksheet
      tags: [estimating]
      responses:
        "200":
          description: Interactive worksheet with its pinned template definition, saved inputs, and evaluation
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InteractiveWorksheet" }
  /worksheets/{worksheetId}/interactive-inputs:
    parameters:
      - $ref: "#/components/parameters/WorksheetId"
    put:
      operationId: saveInteractiveWorksheetInputs
      tags: [estimating]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InteractiveInputValuesSave" }
      responses:
        "200":
          description: Inputs saved; worksheet re-evaluated against the pinned template version
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InteractiveWorksheet" }
        "409":
          description: The worksheet changed since the client loaded it (stale baseRevision), or the worksheet is not interactive
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetConflict" }
  /worksheets/{worksheetId}/reprice-preview:
    parameters:
      - $ref: "#/components/parameters/WorksheetId"
    get:
      operationId: getWorksheetRepricePreview
      tags: [estimating]
      responses:
        "200":
          description: Old vs new totals if the worksheet were re-priced to the template's active version
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetRepricePreview" }
        "404":
          description: Worksheet not found
        "409":
          description: Worksheet is not interactive or already on the active pricing version
  /estimates/{estimateId}/reprice-events:
    parameters:
      - $ref: "#/components/parameters/EstimateId"
    get:
      operationId: listEstimateRepriceEvents
      tags: [estimating]
      responses:
        "200":
          description: Re-price history across every worksheet in the estimate, most recent first
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EstimateRepriceEventList" }
        "404":
          description: Estimate not found
  /worksheets/{worksheetId}/reprice-events:
    parameters:
      - $ref: "#/components/parameters/WorksheetId"
    get:
      operationId: listWorksheetRepriceEvents
      tags: [estimating]
      responses:
        "200":
          description: Re-price history for the worksheet, most recent first
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorksheetRepriceEventList" }
        "404":
          description: Worksheet not found
  /worksheets/{worksheetId}/reprice:
    parameters:
      - $ref: "#/components/parameters/WorksheetId"
    post:
      operationId: repriceWorksheet
      tags: [estimating]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RepriceWorksheetRequest" }
      responses:
        "200":
          description: Worksheet repointed to the active pricing version and re-evaluated with existing inputs
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InteractiveWorksheet" }
        "404":
          description: Worksheet not found
        "409":
          description: Worksheet is not interactive, already current, or pricing changed since the preview was shown
  /worksheets/{worksheetId}/proposal:
    parameters:
      - $ref: "#/components/parameters/WorksheetId"
    post:
      operationId: createProposalFromWorksheet
      tags: [estimating]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProposalFromWorksheetInput" }
      responses:
        "201":
          description: Draft proposal created from the worksheet's current evaluation
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Proposal" }
        "404":
          description: Worksheet not found
        "409":
          description: Worksheet is not interactive or has no final price yet
        "422":
          description: Project proposal metadata is incomplete
        "500":
          description: The proposal document could not be rendered
  /template-pricing:
    get:
      operationId: listTemplatePricing
      tags: [estimating]
      responses:
        "200":
          description: Pricing parameters for the company's interactive estimating templates
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TemplatePricingList" }
  /template-pricing/{templateId}/publish:
    parameters:
      - in: path
        name: templateId
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: publishTemplatePricing
      tags: [estimating]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TemplatePricingPublish" }
      responses:
        "200":
          description: New template version published; existing worksheets stay pinned to their original version
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TemplatePricing" }
        "403":
          description: Only company owners/admins can publish pricing
        "404":
          description: Template not found or not an interactive template
        "409":
          description: Pricing changed since it was loaded (stale base version)
        "422":
          description: Invalid parameter values
  /template-pricing/{templateId}/versions:
    parameters:
      - in: path
        name: templateId
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: listTemplatePricingVersions
      tags: [estimating]
      responses:
        "200":
          description: Pricing version history for a template, newest first, with per-version parameter changes
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TemplatePricingHistory" }
        "404":
          description: Template not found or not an interactive template
  /projects/{projectId}/proposals:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      operationId: createProposal
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProposalInput" }
      responses:
        "201":
          description: Created proposal version
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Proposal" }
        "422":
          description: Project proposal metadata is incomplete
        "500":
          description: The proposal document could not be rendered
  /proposal-sections/{sectionId}:
    parameters:
      - in: path
        name: sectionId
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: updateProposalSection
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProposalSectionUpdate" }
      responses:
        "200":
          description: Updated proposal section
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProposalSection" }
        "404":
          description: Proposal section not found
  /proposals/{proposalId}/document:
    parameters:
      - $ref: "#/components/parameters/ProposalId"
    get:
      operationId: downloadProposalDocument
      tags: [projects]
      responses:
        "200":
          description: Proposal Word document
          headers:
            Content-Disposition:
              schema: { type: string }
          content:
            application/vnd.openxmlformats-officedocument.wordprocessingml.document:
              schema: { type: string, format: binary }
        "404":
          description: Proposal not found
        "409":
          description: Proposal snapshot is incomplete and cannot be rendered
        "500":
          description: The proposal document could not be rendered
  /projects/{projectId}/rfis:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      operationId: createRfi
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RfiInput" }
      responses:
        "201":
          description: Created RFI
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Rfi" }
  /rfis/{rfiId}:
    parameters:
      - in: path
        name: rfiId
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: updateRfi
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RfiUpdate" }
      responses:
        "200":
          description: Updated RFI
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Rfi" }
  /projects/{projectId}/change-orders:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      operationId: createChangeOrder
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChangeOrderInput" }
      responses:
        "201":
          description: Created change order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChangeOrder" }
        "409":
          description: Project is not an active Job
  /change-orders/{changeOrderId}:
    parameters:
      - in: path
        name: changeOrderId
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: updateChangeOrder
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChangeOrderUpdate" }
      responses:
        "200":
          description: Updated change order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChangeOrder" }
  /projects/{projectId}/ai-messages:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      operationId: sendAiMessage
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AiMessageInput" }
      responses:
        "201":
          description: AI exchange with any proposed draft actions
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AiChatExchange" }
        "502":
          description: The AI model call failed; no assistant content was fabricated
        "503":
          description: AI drafting is not configured for this workspace
  /ai-actions/{actionId}/confirm:
    parameters:
      - $ref: "#/components/parameters/AiActionId"
    post:
      operationId: confirmAiAction
      tags: [projects]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AiActionConfirmInput" }
      responses:
        "200":
          description: Confirmed action executed through validated endpoints
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AiProposedAction" }
        "409":
          description: |
            Action already resolved, business rule rejected the write, or the
            stored record changed after the reviewer opened the diff (stale
            snapshot). When code is AI_UPDATE_STALE the body carries staleFields
            listing which field names no longer matched the before snapshot.
          content:
            application/json:
              schema:
                oneOf:
                  - description: Stale snapshot — reviewer must re-acknowledge the diff
                    type: object
                    required:
                      [code, error, current, staleFields, action, attribution]
                    properties:
                      code:
                        type: string
                        enum: [AI_UPDATE_STALE]
                      error:
                        type: string
                      current:
                        type: object
                        additionalProperties: true
                      staleFields:
                        type: array
                        items:
                          type: string
                        description: Field names that changed since the AI drafted the action; empty when staleness was detected via a concurrent confirmation
                      action:
                        $ref: "#/components/schemas/AiProposedAction"
                      attribution:
                        nullable: true
                        type: object
                        required: [editorName, editedAt]
                        properties:
                          editorName:
                            type: string
                          editedAt:
                            type: string
                            format: date-time
                  - description: Already resolved or business rule violation
                    type: object
                    properties:
                      error:
                        type: string
        "422":
          description: Payload failed schema validation
  /ai-actions/{actionId}/reject:
    parameters:
      - $ref: "#/components/parameters/AiActionId"
    post:
      operationId: rejectAiAction
      tags: [projects]
      responses:
        "200":
          description: Rejected action
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AiProposedAction" }
  /crm:
    get:
      operationId: listCustomers
      tags: [crm]
      responses:
        "200":
          description: Customers
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Customer" }
    post:
      operationId: createCustomer
      tags: [crm]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerInput" }
      responses:
        "201":
          description: Created customer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Customer" }
  /crm/export.csv:
    get:
      operationId: exportCustomersCsv
      tags: [crm]
      parameters:
        - in: query
          name: search
          required: false
          schema: { type: string }
          description: Case-insensitive contact, company, or email search
      responses:
        "200":
          description: Company and contact directory as CSV
          headers:
            Content-Disposition:
              schema: { type: string }
              description: Attachment filename
          content:
            text/csv:
              schema: { type: string, format: binary }
  /crm/export.xlsx:
    get:
      operationId: exportCustomersXlsx
      tags: [crm]
      parameters:
        - in: query
          name: search
          required: false
          schema: { type: string }
          description: Case-insensitive contact, company, or email search
      responses:
        "200":
          description: Company and contact directory as an Excel workbook
          headers:
            Content-Disposition:
              schema: { type: string }
              description: Attachment filename
          content:
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
              schema: { type: string, format: binary }
  /crm/directory.csv:
    get:
      operationId: exportCrmDirectoryCsv
      tags: [crm]
      parameters:
        - in: query
          name: search
          required: false
          schema: { type: string }
          description: Case-insensitive company, contact, email, or phone search
        - in: query
          name: archived
          required: false
          schema: { type: string, enum: [active, archived, all] }
          description: Which directory records to include (default active)
      responses:
        "200":
          description: >-
            The CRM directory itself — one row per contact, plus companies with
            no contacts and individuals with no company. Distinct from
            /crm/export.csv, which exports bid-opportunity records.
          headers:
            Content-Disposition:
              schema: { type: string }
              description: Attachment filename
          content:
            text/csv:
              schema: { type: string, format: binary }
  /crm/directory.xlsx:
    get:
      operationId: exportCrmDirectoryXlsx
      tags: [crm]
      parameters:
        - in: query
          name: search
          required: false
          schema: { type: string }
          description: Case-insensitive company, contact, email, or phone search
        - in: query
          name: archived
          required: false
          schema: { type: string, enum: [active, archived, all] }
          description: Which directory records to include (default active)
      responses:
        "200":
          description: The CRM directory as an Excel workbook
          headers:
            Content-Disposition:
              schema: { type: string }
              description: Attachment filename
          content:
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
              schema: { type: string, format: binary }
  /crm/import-preview:
    post:
      operationId: previewCrmImport
      tags: [crm]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CrmImportPreviewInput" }
      responses:
        "200":
          description: Non-persisting import preview
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CrmImportPreview" }
  /crm/{customerId}:
    parameters:
      - in: path
        name: customerId
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: updateCustomer
      tags: [crm]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerInput" }
      responses:
        "200":
          description: Updated customer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Customer" }
    delete:
      operationId: deleteCustomer
      tags: [crm]
      responses:
        "200":
          description: Deleted customer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeleteResult" }
  /crm/companies:
    get:
      operationId: listCrmCompanies
      tags: [crm]
      description: >-
        Tenant-scoped CRM company directory. Filter by `q` (case-insensitive
        substring of name/email/phone/address) and `archived`
        (active|archived|all; defaults to active).
      parameters:
        - in: query
          name: q
          required: false
          schema: { type: string }
        - in: query
          name: archived
          required: false
          schema: { type: string, enum: [active, archived, all] }
      responses:
        "200":
          description: CRM companies
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CrmCompany" }
    post:
      operationId: createCrmCompany
      tags: [crm]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CrmCompanyInput" }
      responses:
        "201":
          description: Created CRM company
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CrmCompany" }
        "409":
          description: A CRM company with this name already exists for the tenant
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /crm/companies/{crmCompanyId}:
    parameters:
      - in: path
        name: crmCompanyId
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: updateCrmCompany
      tags: [crm]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CrmCompanyUpdate" }
      responses:
        "200":
          description: Updated CRM company
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CrmCompany" }
        "404":
          description: CRM company not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: A CRM company with this name already exists for the tenant
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
    delete:
      operationId: archiveCrmCompany
      tags: [crm]
      description: Soft-delete (archive) a CRM company. Contacts keep their history.
      responses:
        "200":
          description: Archived CRM company
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CrmCompany" }
        "404":
          description: CRM company not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /crm/contacts:
    get:
      operationId: listCrmContacts
      tags: [crm]
      description: >-
        Tenant-scoped CRM contact directory. Filter by `q`
        (contact name/email/phone or associated company name), `archived`
        (active|archived|all; defaults to active), and
        `crmCompanyId` (a company id, or `none` for individuals).
      parameters:
        - in: query
          name: q
          required: false
          schema: { type: string }
        - in: query
          name: archived
          required: false
          schema: { type: string, enum: [active, archived, all] }
        - in: query
          name: crmCompanyId
          required: false
          schema: { type: string }
      responses:
        "200":
          description: CRM contacts
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CrmContact" }
    post:
      operationId: createCrmContact
      tags: [crm]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CrmContactInput" }
      responses:
        "201":
          description: Created CRM contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CrmContact" }
        "404":
          description: The associated CRM company was not found for the tenant
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: A duplicate contact exists or the associated company is archived
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /crm/contacts/{crmContactId}:
    parameters:
      - in: path
        name: crmContactId
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: updateCrmContact
      tags: [crm]
      description: >-
        Update a CRM contact. Set `crmCompanyId` to a company id to associate
        with that CRM company, or explicitly to null to make the contact an
        individual. Omit the field to leave the association unchanged.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CrmContactUpdate" }
      responses:
        "200":
          description: Updated CRM contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CrmContact" }
        "404":
          description: CRM contact (or the associated CRM company) not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: A duplicate contact exists or the associated company is archived
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
    delete:
      operationId: archiveCrmContact
      tags: [crm]
      description: Soft-delete (archive) a CRM contact.
      responses:
        "200":
          description: Archived CRM contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CrmContact" }
        "404":
          description: CRM contact not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /cost-items:
    get:
      operationId: listCostItems
      tags: [costs]
      responses:
        "200":
          description: Master cost list
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CostItem" }
    post:
      operationId: createCostItem
      tags: [costs]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CostItemInput" }
      responses:
        "201":
          description: Created cost item
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CostItem" }
  /cost-items/import-master-materials:
    post:
      operationId: importMasterMaterialCatalog
      tags: [cost-items]
      responses:
        "200":
          description: Idempotent import result for the uploaded master material workbook
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CostItemImportResult" }
  /cost-items/{costItemId}:
    parameters:
      - in: path
        name: costItemId
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: updateCostItem
      tags: [costs]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CostItemInput" }
      responses:
        "200":
          description: Updated cost item
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CostItem" }
    delete:
      operationId: deleteCostItem
      tags: [costs]
      responses:
        "200":
          description: Deleted cost item
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeleteResult" }
  /proposal-language:
    get:
      operationId: listProposalLanguage
      tags: [language]
      responses:
        "200":
          description: Proposal language catalog
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ProposalLanguage" }
    post:
      operationId: createProposalLanguage
      tags: [language]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProposalLanguageInput" }
      responses:
        "201":
          description: Created catalog entry
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProposalLanguage" }
  /proposal-language/{languageId}:
    parameters:
      - in: path
        name: languageId
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: updateProposalLanguage
      tags: [language]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProposalLanguageInput" }
      responses:
        "200":
          description: Updated language and created a new version
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProposalLanguage" }
    delete:
      operationId: deleteProposalLanguage
      tags: [language]
      responses:
        "200":
          description: Deleted language entry
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeleteResult" }
  /members:
    get:
      operationId: listMembers
      tags: [settings]
      responses:
        "200":
          description: Active company members with their roles
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/TeamMember" }
  /members/{userId}:
    parameters:
      - in: path
        name: userId
        required: true
        schema: { type: string, format: uuid }
    delete:
      operationId: removeMember
      tags: [settings]
      responses:
        "200":
          description: Member deactivated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeleteResult" }
        "403":
          description: Only owners can remove members
        "404":
          description: Member not found
        "422":
          description: Cannot remove yourself
    patch:
      operationId: updateMemberRole
      tags: [settings]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MemberRoleUpdate" }
      responses:
        "200":
          description: Updated team member
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TeamMember" }
        "403":
          description: Caller is not an owner or admin, or admin attempting a forbidden transition
        "404":
          description: Member not found in this company
        "409":
          description: Cannot demote the last owner
  /invitations:
    get:
      operationId: listInvitations
      tags: [team]
      summary: Invitations issued by the caller's company
      description: >-
        Readable by any active member, viewers included. Returns every
        invitation ever issued by the caller's company, newest first, with a
        derived status (a `pending` row past its `expiresAt` reads as
        `expired`). `acceptUrl` is a live join link rather than information, so
        it is only populated for a caller who could issue an invitation
        themselves; everyone else sees the invitation with `acceptUrl: null`.
      responses:
        "200":
          description: Invitations for this company
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Invitation" }
    post:
      operationId: createInvitation
      tags: [team]
      summary: Invite someone to the caller's company
      description: >-
        Owner/admin only. Re-inviting an address that already has an
        outstanding invitation refreshes that same invitation (new token, new
        expiry, possibly a new role) rather than creating a second one. An
        address belonging to a member whose membership was deactivated may be
        re-invited; accepting reactivates the existing membership row.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InvitationInput" }
      responses:
        "200":
          description: Invitation created or refreshed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invitation" }
        "403":
          description: Only owners and admins can invite; admins cannot invite owners
        "409":
          description: That address is already an active member of this company
        "422":
          description: Cannot invite yourself
  /invitations/lookup:
    get:
      operationId: lookupInvitation
      tags: [team]
      summary: Preview an invitation by token
      description: >-
        Requires a signed-in Clerk user but **not** a workspace - a brand-new
        user can call this before any company exists for them, and doing so
        never provisions one.
      parameters:
        - in: query
          name: token
          required: true
          schema: { type: string, minLength: 1 }
      responses:
        "200":
          description: What this invitation offers, and whether the caller can accept it
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InvitationPreview" }
        "404":
          description: No invitation matches that token
  /invitations/accept:
    post:
      operationId: acceptInvitation
      tags: [team]
      summary: Accept an invitation
      description: >-
        Requires a signed-in Clerk user whose e-mail matches the invitation.
        Does not require an existing workspace and never provisions one. A
        caller who already belongs to another company keeps that membership;
        the newly joined company simply becomes their active workspace and both
        remain listed under `/workspaces`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InvitationAcceptInput" }
      responses:
        "200":
          description: Membership created or reactivated; this company is now active
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InvitationAcceptResult" }
        "403":
          description: The invitation was addressed to a different e-mail
        "404":
          description: No invitation matches that token
        "409":
          description: The invitation is expired, revoked, or already accepted
  /invitations/{invitationId}:
    parameters:
      - in: path
        name: invitationId
        required: true
        schema: { type: string, format: uuid }
    delete:
      operationId: revokeInvitation
      tags: [team]
      summary: Revoke a pending invitation
      responses:
        "200":
          description: Invitation revoked
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invitation" }
        "403":
          description: Only owners and admins can revoke invitations
        "404":
          description: No such invitation in this company
        "409":
          description: Only a pending invitation can be revoked
  /my-invitations:
    get:
      operationId: listMyInvitations
      tags: [team]
      summary: Outstanding invitations addressed to the caller
      description: >-
        Requires a signed-in Clerk user but not a workspace, and never
        provisions one. Lets someone who already has their own workspace see
        and accept an invitation without a link.
      responses:
        "200":
          description: Pending, unexpired invitations for the caller's e-mail
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/InvitationPreview" }
  /workspaces:
    get:
      operationId: listWorkspaces
      tags: [team]
      summary: Companies the caller is an active member of
      responses:
        "200":
          description: The caller's active memberships, active workspace first
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Workspace" }
  /workspaces/{companyId}/activate:
    parameters:
      - in: path
        name: companyId
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: activateWorkspace
      tags: [team]
      summary: Make one of the caller's memberships the active workspace
      description: >-
        Every subsequent request resolves its company context to this company.
        Only the caller's own active memberships are selectable, so this can
        never reach another tenant's data.
      responses:
        "200":
          description: The now-active workspace
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Workspace" }
        "404":
          description: The caller is not an active member of that company
  /proposal-terms:
    get:
      operationId: listProposalTerms
      tags: [settings]
      description: >-
        The company's proposal "guidelines" — the boilerplate general
        conditions and proposal terms printed on every proposal — in document
        order. Seeded from the shipped defaults the first time they are read,
        so this never returns an empty list.
      responses:
        "200":
          description: Proposal terms in document order
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ProposalTerm" }
  /proposal-terms/{tag}:
    parameters:
      - in: path
        name: tag
        required: true
        description: Immutable slot identifier (e.g. TermsAboveGrade).
        schema: { type: string }
    put:
      operationId: updateProposalTerm
      tags: [settings]
      description: >-
        Replace one term's body. The previous text is never overwritten — a new
        version row is written and becomes active, so earlier wording stays
        recoverable.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProposalTermUpdate" }
      responses:
        "200":
          description: Updated term
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProposalTerm" }
        "403":
          description: Only owners or admins can change proposal terms
        "404":
          description: No such term slot
  /proposal-terms/{tag}/reset:
    parameters:
      - in: path
        name: tag
        required: true
        schema: { type: string }
    post:
      operationId: resetProposalTerm
      tags: [settings]
      description: Restore one term to the shipped default text (as a new version).
      responses:
        "200":
          description: Term restored to the default
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProposalTerm" }
        "403":
          description: Only owners or admins can change proposal terms
        "404":
          description: No such term slot
  /company-logo/upload-url:
    post:
      operationId: requestCompanyLogoUploadUrl
      tags: [settings]
      description: Presigned URL for uploading the company logo printed on the proposal.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CompanyLogoUploadRequest" }
      responses:
        "200":
          description: Presigned upload URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CompanyLogoUploadUrl" }
        "400":
          description: Upload rejected — file exceeds the size limit or is not a supported image type
        "403":
          description: Only owners or admins can change the company logo
        "503":
          description: Object storage is not configured
  /company-logo:
    put:
      operationId: setCompanyLogo
      tags: [settings]
      description: Attach a previously uploaded object as the company logo.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CompanyLogoUpdate" }
      responses:
        "200":
          description: Stored logo
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CompanyLogo" }
        "403":
          description: Only owners or admins can change the company logo
        "503":
          description: Object storage is not configured
    delete:
      operationId: clearCompanyLogo
      tags: [settings]
      description: Remove the company logo; both proposal logo slots print blank.
      responses:
        "200":
          description: Logo cleared
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CompanyLogo" }
        "403":
          description: Only owners or admins can change the company logo
  /settings:
    get:
      operationId: getSettings
      tags: [settings]
      responses:
        "200":
          description: Profile and company settings
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Settings" }
    patch:
      operationId: updateSettings
      tags: [settings]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SettingsInput" }
      responses:
        "200":
          description: Updated settings
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Settings" }
  /projects/{projectId}/documents:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      operationId: listProjectDocuments
      tags: [documents]
      responses:
        "200":
          description: Project documents
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ProjectDocument" }
    post:
      operationId: createProjectDocument
      tags: [documents]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectDocumentInput" }
      responses:
        "201":
          description: Created document record
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProjectDocument" }
  /projects/{projectId}/documents/upload-url:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      operationId: requestDocumentUploadUrl
      tags: [documents]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DocumentUploadRequest" }
      responses:
        "200":
          description: Presigned upload URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DocumentUploadUrl" }
        "400":
          description: Upload rejected — file exceeds the size limit or has a disallowed content type
  /documents/{documentId}:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    delete:
      operationId: deleteProjectDocument
      tags: [documents]
      responses:
        "200":
          description: Deleted document
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeleteResult" }
  /documents/{documentId}/download:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    get:
      operationId: downloadProjectDocument
      tags: [documents]
      parameters:
        - in: query
          name: disposition
          required: false
          schema: { type: string, enum: [inline, attachment] }
      responses:
        "200":
          description: Document file stream
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
components:
  parameters:
    ProjectId:
      in: path
      name: projectId
      required: true
      schema: { type: string, format: uuid }
    EstimateId:
      in: path
      name: estimateId
      required: true
      schema: { type: string, format: uuid }
    WorksheetId:
      in: path
      name: worksheetId
      required: true
      schema: { type: string, format: uuid }
    AiActionId:
      in: path
      name: actionId
      required: true
      schema: { type: string, format: uuid }
    DocumentId:
      in: path
      name: documentId
      required: true
      schema: { type: string, format: uuid }
    ProposalId:
      in: path
      name: proposalId
      required: true
      schema: { type: string, format: uuid }
  schemas:
    ProjectDocument:
      type: object
      required: [id, projectId, name, kind, status, createdAt]
      properties:
        id: { type: string, format: uuid }
        projectId: { type: string, format: uuid }
        name: { type: string }
        kind: { type: string }
        status: { type: string }
        contentType: { type: ["string", "null"] }
        sizeBytes: { type: ["integer", "null"] }
        createdAt: { type: string, format: date-time }
    ProjectDocumentInput:
      type: object
      required: [documentId]
      properties:
        documentId: { type: string, format: uuid }
    DocumentUploadRequest:
      type: object
      required: [name, kind, contentType, size]
      properties:
        name: { type: string, minLength: 1 }
        kind: { type: string, minLength: 1 }
        contentType:
          type: string
          minLength: 1
          description: Must be one of the allowed document content types (see shared upload-limits constants in @workspace/api-zod)
        size:
          type: integer
          minimum: 1
          maximum: 26214400
          description: File size in bytes; max 25 MB (MAX_DOCUMENT_UPLOAD_BYTES)
    DocumentUploadUrl:
      type: object
      required: [uploadURL, documentId]
      properties:
        uploadURL: { type: string }
        documentId: { type: string, format: uuid }
    DeleteResult:
      type: object
      required: [success]
      properties:
        success: { type: boolean }
    HealthStatus:
      type: object
      required: [status]
      properties:
        status: { type: string }
    ProjectStatus:
      type: string
      enum:
        [Open, Estimating, Bid Submitted, Sold / In Progress, Complete, Lost]
    DashboardBucket:
      type: object
      required: [key, label, count, value]
      properties:
        key: { type: string }
        label: { type: string }
        count: { type: integer }
        value: { type: number }
    Dashboard:
      type: object
      required: [buckets, recentProjects, followUps]
      properties:
        buckets:
          type: array
          items: { $ref: "#/components/schemas/DashboardBucket" }
        recentProjects:
          type: array
          items: { $ref: "#/components/schemas/Project" }
        followUps:
          type: array
          items: { $ref: "#/components/schemas/FollowUpReminder" }
    FollowUpReminder:
      type: object
      required:
        [
          projectId,
          projectName,
          customerName,
          assignedEstimator,
          status,
          followUpDate,
          latestUpdate,
        ]
      properties:
        projectId: { type: string, format: uuid }
        projectName: { type: string }
        customerName: { type: ["string", "null"] }
        assignedEstimator: { type: ["string", "null"] }
        status: { $ref: "#/components/schemas/ProjectStatus" }
        followUpDate: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
        latestUpdate: { $ref: "#/components/schemas/OpportunityUpdate" }
    Project:
      type: object
      required:
        [
          id,
          name,
          jobNumber,
          prevailingWage,
          planDate,
          bidSubmissionDate,
          status,
          bidAmount,
          gpPercent,
          isDemo,
          updatedAt,
        ]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        jobNumber: { type: ["string", "null"] }
        prevailingWage: { type: ["boolean", "null"] }
        planDate: { type: ["string", "null"], pattern: '^\d{4}-\d{2}-\d{2}$' }
        bidSubmissionDate:
          { type: ["string", "null"], pattern: '^\d{4}-\d{2}-\d{2}$' }
        address: { type: ["string", "null"] }
        estimatedStart: { type: ["string", "null"], format: date }
        customerId: { type: ["string", "null"], format: uuid }
        customerName: { type: ["string", "null"] }
        contactName: { type: ["string", "null"] }
        contactEmail: { type: ["string", "null"] }
        contactPhone: { type: ["string", "null"] }
        fieldContactName: { type: ["string", "null"] }
        status: { $ref: "#/components/schemas/ProjectStatus" }
        bidAmount: { type: number }
        contractValue: { type: number }
        gpPercent: { type: number }
        assignedEstimator: { type: ["string", "null"] }
        isDemo: { type: boolean }
        updatedAt: { type: string, format: date-time }
        contacts:
          type: array
          items: { $ref: "#/components/schemas/OpportunityContact" }
        latestUpdate: { $ref: "#/components/schemas/OpportunityUpdate" }
        nextFollowUp:
          { type: ["string", "null"], pattern: '^\d{4}-\d{2}-\d{2}$' }
        notes: { type: ["string", "null"] }
        proposalAmount:
          type: ["number", "null"]
          description: >-
            Amount on the latest proposal version for this project, or null
            when no proposal exists yet. This is the figure the pipeline export
            reports; `bidAmount` is the denormalised copy on the project row.
        proposalStatus:
          type: ["string", "null"]
          description: Status of the latest proposal version, or null when none exists.
    ProjectInput:
      type: object
      required: [name]
      description: >-
        How the bid is attached to a customer, in precedence order:
        `crmContactId` (optionally with `crmCompanyId`) selects from the CRM
        directory and the server resolves — creating on first use — the backing
        customer record, which is what keeps the directory the only place a
        person or firm is typed in; otherwise `customerId` attaches an existing
        customer record directly. Supplying neither is accepted and creates an
        unassigned bid: pre-existing behaviour that the product UI never
        produces, since contacts and activity both require a customer. Both
        fields are therefore optional at the schema level rather than an
        exclusive `oneOf`, so existing callers keep working.
      properties:
        name: { type: string, minLength: 1 }
        crmCompanyId:
          type: ["string", "null"]
          format: uuid
          description: CRM directory company to bid for. Omit or null for an individual.
        crmContactId:
          type: string
          format: uuid
          description: CRM directory contact to bid for; becomes the primary contact.
        jobNumber: { type: string, minLength: 1 }
        prevailingWage: { type: boolean }
        planDate: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
        bidSubmissionDate: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
        address: { type: string }
        estimatedStart: { type: string, format: date }
        customerId: { type: string, format: uuid }
        customerName: { type: string }
        contactName: { type: string }
        contactEmail: { type: string }
        contactPhone: { type: string }
        fieldContactName: { type: string }
        status: { $ref: "#/components/schemas/ProjectStatus" }
    ProjectUpdate:
      type: object
      properties:
        name: { type: string, minLength: 1 }
        jobNumber: { type: ["string", "null"] }
        prevailingWage: { type: ["boolean", "null"] }
        planDate: { type: ["string", "null"], pattern: '^\d{4}-\d{2}-\d{2}$' }
        bidSubmissionDate:
          { type: ["string", "null"], pattern: '^\d{4}-\d{2}-\d{2}$' }
        address: { type: ["string", "null"] }
        estimatedStart: { type: ["string", "null"], format: date }
        customerId: { type: ["string", "null"], format: uuid }
        status: { $ref: "#/components/schemas/ProjectStatus" }
        bidAmount: { type: number }
        contractValue: { type: number }
        gpPercent: { type: number }
    ProjectNote:
      type: object
      required: [id, projectId, position, kind, content]
      properties:
        id: { type: string, format: uuid }
        projectId: { type: string, format: uuid }
        position: { type: integer }
        kind: { type: string }
        reference: { type: ["string", "null"] }
        content: { type: string }
        lastEditorName: { type: ["string", "null"] }
        lastEditedAt: { type: ["string", "null"], format: date-time }
    ProjectNoteInput:
      type: object
      required: [kind, content]
      properties:
        kind: { type: string }
        reference: { type: string }
        content: { type: string, minLength: 1 }
        position: { type: integer }
    CustomerContact:
      type: object
      required: [id, customerId, name, isPrimary]
      properties:
        id: { type: string, format: uuid }
        customerId: { type: string, format: uuid }
        name: { type: string }
        email: { type: ["string", "null"] }
        phone: { type: ["string", "null"] }
        jobTitle: { type: ["string", "null"] }
        isPrimary: { type: boolean }
        unavailable:
          type: boolean
          description: >-
            True when this contact is still assigned to the opportunity but is
            no longer selectable from the customer's directory company — it was
            archived, or moved to a different company. Included so the client
            can keep showing the assignment instead of silently dropping it on
            the next save.
    OpportunityContact:
      type: object
      required: [contactId, customerId, name, role, isPrimary]
      properties:
        contactId: { type: string, format: uuid }
        customerId: { type: string, format: uuid }
        name: { type: string }
        email: { type: ["string", "null"] }
        phone: { type: ["string", "null"] }
        jobTitle: { type: ["string", "null"] }
        role: { type: string }
        isPrimary: { type: boolean }
    OpportunityContactInput:
      type: object
      required: [contactId, role, isPrimary]
      properties:
        contactId: { type: string, format: uuid }
        role: { type: string, minLength: 1 }
        isPrimary: { type: boolean }
    OpportunityContactListInput:
      type: object
      required: [contacts]
      properties:
        contacts:
          type: array
          items: { $ref: "#/components/schemas/OpportunityContactInput" }
    NewOpportunityContactInput:
      type: object
      required: [name, role, isPrimary]
      properties:
        name: { type: string, minLength: 1 }
        email: { type: string }
        phone: { type: string }
        jobTitle: { type: string }
        role: { type: string, minLength: 1 }
        isPrimary: { type: boolean }
    OpportunityUpdate:
      type: object
      required:
        [id, projectId, updateType, content, occurredAt, authorName, createdAt]
      properties:
        id: { type: string, format: uuid }
        projectId: { type: string, format: uuid }
        updateType: { type: string, enum: [Call, Email, Text, Meeting, Note] }
        content: { type: string }
        occurredAt: { type: string, format: date-time }
        followUpDate:
          { type: ["string", "null"], pattern: '^\d{4}-\d{2}-\d{2}$' }
        authorName: { type: string }
        createdAt: { type: string, format: date-time }
    OpportunityUpdateInput:
      type: object
      required: [updateType, content]
      properties:
        updateType: { type: string, enum: [Call, Email, Text, Meeting, Note] }
        content: { type: string, minLength: 1 }
        occurredAt: { type: string, format: date-time }
        followUpDate: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
    OpportunityUpdateEdit:
      type: object
      description: >-
        Every field is optional; omitted fields keep their stored value. Send
        `followUpDate: null` to clear the scheduled follow-up.
      properties:
        updateType: { type: string, enum: [Call, Email, Text, Meeting, Note] }
        content: { type: string, minLength: 1 }
        occurredAt: { type: string, format: date-time }
        followUpDate:
          { type: ["string", "null"], pattern: '^\d{4}-\d{2}-\d{2}$' }
    ScopeValue:
      type: object
      required: [label, type, value]
      properties:
        label: { type: string }
        type: { type: string, enum: [number, text, select, boolean] }
        value: { type: string }
        unit: { type: ["string", "null"] }
    ScopeOption:
      type: object
      required: [name, description, price, includeInProposal]
      properties:
        name: { type: string }
        description: { type: string }
        price: { type: number }
        includeInProposal: { type: boolean }
    Scope:
      type: object
      required: [id, projectId, position, name, description, inputs, options]
      properties:
        id: { type: string, format: uuid }
        projectId: { type: string, format: uuid }
        position: { type: integer }
        name: { type: string }
        description: { type: string }
        system: { type: ["string", "null"] }
        lastEditorName: { type: ["string", "null"] }
        lastEditedAt: { type: ["string", "null"], format: date-time }
        inputs:
          type: array
          items: { $ref: "#/components/schemas/ScopeValue" }
        options:
          type: array
          items: { $ref: "#/components/schemas/ScopeOption" }
    ScopeInput:
      type: object
      required: [name, description]
      properties:
        name: { type: string, minLength: 1 }
        description: { type: string }
        system: { type: string }
        position: { type: integer }
        inputs:
          type: array
          items: { $ref: "#/components/schemas/ScopeValue" }
        options:
          type: array
          items: { $ref: "#/components/schemas/ScopeOption" }
    ScopeUpdate:
      type: object
      properties:
        name: { type: string, minLength: 1 }
        description: { type: string }
        system: { type: ["string", "null"] }
    Estimate:
      type: object
      required:
        [
          id,
          projectId,
          name,
          status,
          duplicatedFromEstimateId,
          pricingSnapshotAt,
          worksheetCount,
          baseBidTotal,
          optionsTotal,
          grossProfit,
          gpPercent,
          travelPrice,
          createdAt,
          updatedAt,
        ]
      properties:
        id: { type: string, format: uuid }
        projectId: { type: string, format: uuid }
        name: { type: string }
        status: { type: string }
        duplicatedFromEstimateId: { type: ["string", "null"], format: uuid }
        pricingSnapshotAt: { type: string, format: date-time }
        worksheetCount: { type: integer }
        baseBidTotal:
          type: ["number", "null"]
          description: >-
            Sum of base-bid worksheet selling prices plus options explicitly
            marked included, PLUS the estimate-level travel price (project-level
            travel & drive time) when travel is enabled. Null when
            financialSummary.pricingStatus is `invalid` — unavailable, not zero.
        travelPrice:
          type: ["number", "null"]
          description: >-
            Estimate-level travel & mobilization customer price included in
            baseBidTotal (0 when travel is off/unconfigured). Null when travel
            itself carries a blocking error.
        optionsTotal:
          type: ["number", "null"]
          description: >-
            Sum of selling prices for option worksheets shown as add-ons (not
            included, not excluded). Null when pricingStatus is `invalid`.
        grossProfit:
          {
            type: ["number", "null"],
            description: "Null when pricingStatus is `invalid`.",
          }
        gpPercent:
          {
            type: ["number", "null"],
            description: "Null when pricingStatus is `invalid`.",
          }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    EstimateInput:
      type: object
      properties:
        name: { type: string, minLength: 1 }
    EstimateUpdate:
      type: object
      properties:
        name: { type: string, minLength: 1 }
        status:
          type: string
          enum: [Draft, Active, Submitted, Superseded, Archived]
    EstimateDuplicate:
      type: object
      properties:
        name: { type: string, minLength: 1 }
    ProjectRef:
      type: object
      required: [id, name, jobNumber]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        jobNumber: { type: ["string", "null"] }
    WorksheetTemplate:
      type: object
      required:
        [
          id,
          name,
          description,
          isActive,
          activeVersion,
          interactive,
          sourceKey,
          engine,
          targetGrossMarginPercent,
        ]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: ["string", "null"] }
        isActive: { type: boolean }
        activeVersion: { type: integer }
        interactive:
          type: boolean
          description: True when the active version carries a formula-driven (estimating-template/v1) definition.
        sourceKey:
          type: ["string", "null"]
          description: Immutable library key (e.g. tremco-250gc, tremco-paraseal-lg, dexcellent, rock-pockets-burrito-drain, blank-misc); null for legacy hand-made templates.
        engine:
          type: ["string", "null"]
          description: Engine key of the active version's definition (tremco-250gc | tremco-paraseal-lg | upi-dexcelent | rock-pockets-burrito-drain) or null.
        targetGrossMarginPercent:
          type: ["number", "null"]
          description: >-
            Per-company template Target Gross Margin override (0 < x < 100). Null =
            inherit (definition value, else company Settings). Applies to
            estimates created / repriced after it is set.
    WorksheetTemplateUpdate:
      type: object
      properties:
        targetGrossMarginPercent:
          type: ["number", "null"]
          exclusiveMinimum: 0
          exclusiveMaximum: 100
          description: Null clears the override. 0 is not a valid margin (use null to inherit).
    WorksheetFinancials:
      type: object
      required:
        [
          totalMaterial,
          totalLabor,
          totalSubcontract,
          totalCost,
          sellingPrice,
          grossProfit,
          gpPercent,
        ]
      properties:
        totalMaterial: { type: ["number", "null"] }
        totalLabor: { type: ["number", "null"] }
        totalSubcontract: { type: ["number", "null"] }
        totalCost: { type: ["number", "null"] }
        sellingPrice:
          type: ["number", "null"]
          description: >-
            Customer-facing price for this scope, rounded up to the next whole
            dollar. Every field here is null when THIS worksheet carries a
            blocking pricing error — unavailable, not zero.
        grossProfit: { type: ["number", "null"] }
        gpPercent: { type: ["number", "null"] }
    WorksheetItem:
      type: object
      required:
        [
          id,
          estimateId,
          templateId,
          templateName,
          title,
          worksheetType,
          relatedBaseWorksheetId,
          optionInclusion,
          sortOrder,
          summary,
          updatedAt,
          revision,
          interactive,
        ]
      properties:
        id: { type: string, format: uuid }
        estimateId: { type: string, format: uuid }
        templateId: { type: string, format: uuid }
        templateName: { type: string }
        interactive: { type: boolean }
        title: { type: string }
        worksheetType:
          type: string
          enum: [base, option]
        relatedBaseWorksheetId: { type: ["string", "null"], format: uuid }
        optionInclusion:
          type: string
          enum: [excluded, shown, included]
        sortOrder: { type: integer }
        summary: { $ref: "#/components/schemas/WorksheetFinancials" }
        warningCount:
          type: integer
          description: Config-severity warnings from the last calculation; 0 = pricing complete.
        revision:
          type: integer
          description: Monotonic save token; echo as baseRevision when saving inputs.
        pricingOutdated:
          type: boolean
          description: True when this interactive worksheet is pinned to a pricing version older than the template's active version. Only populated in workbook listings.
        updatedAt: { type: string, format: date-time }
    WorksheetInputDefinition:
      type: object
      required:
        [
          id,
          fieldKey,
          label,
          inputType,
          unit,
          defaultValue,
          required,
          sortOrder,
          config,
        ]
      properties:
        id: { type: string, format: uuid }
        fieldKey: { type: string }
        label: { type: string }
        inputType: { type: string }
        unit: { type: ["string", "null"] }
        defaultValue: { type: ["string", "null"] }
        required: { type: boolean }
        sortOrder: { type: integer }
        config:
          type: object
          additionalProperties: true
          description: Presentation metadata (section, select options, help text, visibility rules).
    WorksheetInputValue:
      type: object
      required: [inputDefinitionId, value]
      properties:
        inputDefinitionId: { type: string, format: uuid }
        value: { type: ["string", "null"] }
    WorksheetMaterialItem:
      type: object
      required:
        [
          id,
          costItemId,
          description,
          quantity,
          unit,
          coverageRate,
          wastePercent,
          unitCost,
          extendedCost,
          sortOrder,
        ]
      properties:
        id: { type: string, format: uuid }
        costItemId: { type: ["string", "null"], format: uuid }
        description: { type: string }
        quantity: { type: number }
        unit: { type: string }
        coverageRate: { type: ["number", "null"] }
        wastePercent: { type: number }
        unitCost: { type: number }
        extendedCost: { type: number }
        sortOrder: { type: integer }
    WorksheetLaborItem:
      type: object
      required:
        [
          id,
          laborTask,
          inputQuantity,
          productionRate,
          hours,
          laborRate,
          extendedCost,
          sortOrder,
        ]
      properties:
        id: { type: string, format: uuid }
        laborTask: { type: string }
        inputQuantity: { type: number }
        productionRate: { type: ["number", "null"] }
        hours: { type: number }
        laborRate: { type: number }
        extendedCost: { type: number }
        sortOrder: { type: integer }
    WorksheetCustomCostRow:
      type: object
      required:
        [id, kind, description, quantity, unitCost, extendedCost, sortOrder]
      properties:
        id: { type: string, format: uuid }
        kind:
          type: string
          enum: [material, labor]
        description: { type: string }
        quantity:
          type: number
          description: Signed quantity entered by the user.
        unitCost:
          type: number
          minimum: 0
          description: Non-negative unit price.
        extendedCost:
          type: number
          description: Server-computed signed extension (read-only).
        sortOrder: { type: integer }
    WorksheetCustomCostRowInput:
      type: object
      required: [id, description, quantity, unitCost]
      properties:
        id:
          type: string
          format: uuid
          description: Stable client-supplied row id.
        description: { type: string }
        quantity:
          type: number
          description: Signed quantity.
        unitCost:
          type: number
          minimum: 0
          description: Non-negative unit price. The extension is computed server-side and never accepted from the client.
    WorksheetCustomCostRowsUpdate:
      type: object
      required: [baseRevision]
      properties:
        baseRevision:
          type: integer
          description: The worksheet revision the client loaded; the save is rejected with 409 when the worksheet changed since.
        materialRows:
          type: array
          description: Full replacement set of material rows. Omit the field entirely to preserve the existing material rows.
          items: { $ref: "#/components/schemas/WorksheetCustomCostRowInput" }
        laborRows:
          type: array
          description: Full replacement set of labor rows. Omit the field entirely to preserve the existing labor rows.
          items: { $ref: "#/components/schemas/WorksheetCustomCostRowInput" }
    BuildableSimpleRow:
      type: object
      required:
        [id, item, description, quantity, unitCost, markupPercent, sortOrder]
      properties:
        id: { type: string, format: uuid }
        item: { type: string }
        description: { type: string }
        quantity:
          type: number
          description: Signed for labor_other; non-negative otherwise.
        unitCost:
          type: number
          minimum: 0
        markupPercent:
          type: number
          minimum: 0
          description: Pass-through markup percent; only meaningful for subcontractor rows, 0 otherwise.
        sortOrder: { type: integer }
    BuildableCompositeRow:
      type: object
      required:
        [
          id,
          item,
          description,
          materialQuantity,
          materialUnitCost,
          laborQuantity,
          laborUnitCost,
          sortOrder,
        ]
      properties:
        id: { type: string, format: uuid }
        item: { type: string }
        description: { type: string }
        materialQuantity: { type: number, minimum: 0 }
        materialUnitCost: { type: number, minimum: 0 }
        laborQuantity: { type: number, minimum: 0 }
        laborUnitCost: { type: number, minimum: 0 }
        sortOrder: { type: integer }
    BuildableTravelPerDiem:
      type: object
      required: [people, days, ratePerPersonDay, markupPercent]
      properties:
        people: { type: number, minimum: 0 }
        days: { type: number, minimum: 0 }
        ratePerPersonDay: { type: number, minimum: 0 }
        markupPercent: { type: number, minimum: 0 }
    BuildableTravelHotel:
      type: object
      required: [rooms, nights, nightlyRate, markupPercent]
      properties:
        rooms: { type: number, minimum: 0 }
        nights: { type: number, minimum: 0 }
        nightlyRate: { type: number, minimum: 0 }
        markupPercent: { type: number, minimum: 0 }
    BuildableTravel:
      type: object
      required: [perDiem, hotel]
      properties:
        perDiem: { $ref: "#/components/schemas/BuildableTravelPerDiem" }
        hotel: { $ref: "#/components/schemas/BuildableTravelHotel" }
    BuildablePricedCategory:
      type: object
      required: [rawCost, customerPrice]
      properties:
        rawCost: { type: number }
        customerPrice: { type: number }
    BuildablePricedDriveCategory:
      type: object
      required: [rawCost, customerPrice, hours]
      properties:
        rawCost: { type: number }
        customerPrice: { type: number }
        hours: { type: number }
    BuildableSummary:
      type: object
      required:
        [
          material,
          labor,
          driveTime,
          sloping,
          subcontractor,
          outOfTown,
          worksheetTotal,
          grossProfit,
          gpPercent,
        ]
      properties:
        material: { $ref: "#/components/schemas/BuildablePricedCategory" }
        labor: { $ref: "#/components/schemas/BuildablePricedCategory" }
        driveTime: { $ref: "#/components/schemas/BuildablePricedDriveCategory" }
        sloping: { $ref: "#/components/schemas/BuildablePricedCategory" }
        subcontractor: { $ref: "#/components/schemas/BuildablePricedCategory" }
        outOfTown: { $ref: "#/components/schemas/BuildablePricedCategory" }
        worksheetTotal: { type: number }
        grossProfit: { type: number }
        gpPercent: { type: number }
    BuildableWorksheetState:
      type: object
      required:
        [
          renderer,
          scope,
          material,
          labor,
          drive_time,
          sloping,
          subcontractor,
          labor_other,
          travel,
          adjustmentType,
          adjustmentValue,
        ]
      properties:
        renderer:
          type: string
          enum: [blank-misc]
        scope:
          type: array
          items: { $ref: "#/components/schemas/BuildableCompositeRow" }
        material:
          type: array
          items: { $ref: "#/components/schemas/BuildableSimpleRow" }
        labor:
          type: array
          items: { $ref: "#/components/schemas/BuildableSimpleRow" }
        drive_time:
          type: array
          items: { $ref: "#/components/schemas/BuildableSimpleRow" }
        sloping:
          type: array
          items: { $ref: "#/components/schemas/BuildableCompositeRow" }
        subcontractor:
          type: array
          items: { $ref: "#/components/schemas/BuildableSimpleRow" }
        labor_other:
          type: array
          items: { $ref: "#/components/schemas/BuildableSimpleRow" }
        travel: { $ref: "#/components/schemas/BuildableTravel" }
        adjustmentType:
          type: string
          enum: [none, amount, percent]
        adjustmentValue: { type: number }
    BuildableWorksheet:
      type: object
      required: [state, summary]
      properties:
        state: { $ref: "#/components/schemas/BuildableWorksheetState" }
        summary: { $ref: "#/components/schemas/BuildableSummary" }
    WorksheetBuildableUpdate:
      type: object
      required: [baseRevision, state]
      properties:
        baseRevision:
          type: integer
          description: The worksheet revision the client loaded; the save is rejected with 409 when the worksheet changed since.
        state: { $ref: "#/components/schemas/BuildableWorksheetState" }
    WorksheetDetail:
      type: object
      required:
        [
          id,
          estimateId,
          templateId,
          templateName,
          templateVersion,
          title,
          worksheetType,
          relatedBaseWorksheetId,
          optionInclusion,
          sortOrder,
          notes,
          summary,
          inputDefinitions,
          inputValues,
          materialItems,
          laborItems,
          customMaterialRows,
          customLaborRows,
          createdAt,
          updatedAt,
          revision,
          interactive,
          freightPricingOutdated,
        ]
      properties:
        id: { type: string, format: uuid }
        estimateId: { type: string, format: uuid }
        templateId: { type: string, format: uuid }
        templateName: { type: string }
        templateVersion: { type: integer }
        interactive: { type: boolean }
        title: { type: string }
        worksheetType:
          type: string
          enum: [base, option]
        relatedBaseWorksheetId: { type: ["string", "null"], format: uuid }
        optionInclusion:
          type: string
          enum: [excluded, shown, included]
        sortOrder: { type: integer }
        notes: { type: string }
        summary: { $ref: "#/components/schemas/WorksheetFinancials" }
        inputDefinitions:
          type: array
          items: { $ref: "#/components/schemas/WorksheetInputDefinition" }
        inputValues:
          type: array
          items: { $ref: "#/components/schemas/WorksheetInputValue" }
        materialItems:
          type: array
          items: { $ref: "#/components/schemas/WorksheetMaterialItem" }
        laborItems:
          type: array
          items: { $ref: "#/components/schemas/WorksheetLaborItem" }
        customMaterialRows:
          type: array
          description: User-defined material cost rows appended to the worksheet, ordered by sortOrder.
          items: { $ref: "#/components/schemas/WorksheetCustomCostRow" }
        customLaborRows:
          type: array
          description: User-defined labor cost rows appended to the worksheet, ordered by sortOrder.
          items: { $ref: "#/components/schemas/WorksheetCustomCostRow" }
        calculation:
          type: object
          additionalProperties: true
          description: >-
            Live formula-engine output (quantities, hours, pricing, warnings) for
            engine-backed worksheets. Labor rows include, when active, a
            "move-on-labor" row (production bucket: Move-Ons × Move-On Labor
            Rate, no hours) and a "drive-time" row (drive bucket: Drive-Time
            Hours or the Settings Drive Hour Minimum × the Settings drive rate,
            present only when the worksheet's Include Drive Time = Yes). Per-operation
            minimum hours apply only when the worksheet's Apply Minimum Labor
            Charge = Yes. `pricing` has no company-wide minimum-labor
            adjustment (removed; see docs/estimating-templates/labor-minimums-move-ons-drive-time.md).
            `pricing` carries totalCost (Total Direct Job Cost: loaded buckets +
            subcontractor + out-of-town, no overhead), grossProfit, gpPercent
            (Gross Margin %, before overhead), overheadRatePercent,
            allocatedOverhead (= final price × overhead rate, reporting only),
            estimatedNetProfit (= grossProfit − allocatedOverhead; shown as
            "Estimated Operating Profit (after allocated overhead)" — the field
            name is kept for API stability) and estimatedNetMarginPercent
            ("Est. Operating Margin %"); see docs/estimating-templates/pricing-model.md.
        templateDefinition:
          type: object
          additionalProperties: true
          description: Engine template definition (materials, labor rates, constants) for engine-backed worksheets.
        snapshotPrices:
          type: object
          additionalProperties: true
          description: Frozen unit costs keyed by material source key, from the estimate's latest pricing snapshot.
        settingsSnapshot:
          type: object
          additionalProperties: true
          description: Normalized pricing settings captured with the estimate's latest pricing snapshot.
        freightPricingOutdated:
          type: boolean
          description: True only when this Tremco 250GC worksheet uses freight terms that differ from the company's current rule.
        templateSourceKey:
          type: ["string", "null"]
          description: The immutable template sourceKey. Present so clients can detect buildable worksheet types (e.g. blank-misc) without inspecting the mutable title.
        buildableWorksheet:
          allOf:
            - $ref: "#/components/schemas/BuildableWorksheet"
          description: The normalized buildable state and priced summary. Present only for blank-misc worksheets.
        projectPrevailingWage: { type: ["boolean", "null"] }
        estimateTravel:
          type: ["object", "null"]
          description: >-
            Estimate-level travel status the engine needs for client/server compute
            parity: when `enabled`, drive time is priced once at the estimate level and
            this worksheet's drive-time row is suppressed; `configured` false raises
            Configuration Required on the worksheet. Null = the estimate predates
            project-level travel or has it switched off (worksheet toggle governs).
          required: [enabled, configured]
          properties:
            enabled: { type: boolean }
            configured: { type: boolean }
        lastCalculatedAt: { type: ["string", "null"], format: date-time }
        warningCount:
          type: integer
          description: Config-severity warnings from the last calculation; 0 = pricing complete.
        revision:
          type: integer
          description: Monotonic save token; echo as baseRevision when saving inputs.
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    TravelWarning:
      type: object
      required: [code, message, severity]
      properties:
        code: { type: string }
        message: { type: string }
        severity: { type: string, enum: [blocking, config, info] }
        subject: { type: ["string", "null"] }
    TravelInputs:
      type: object
      description: >-
        Persisted per estimate (estimates.travel_json). Null numeric fields inherit
        the Settings defaults (crew size, hours/day, vehicle count). Formulas in
        docs/estimating-templates/travel-and-mobilization.md.
      required:
        [
          enabled,
          mode,
          oneWayMinutes,
          oneWayMiles,
          routeSource,
          routeCheckedAt,
          crewSize,
          productiveHoursPerDay,
          minimumRequiredVisits,
          additionalSiteVisits,
          finalSiteVisitsOverride,
          mobilizationCount,
          vehicleCount,
        ]
      properties:
        enabled: { type: boolean }
        mode: { type: string, enum: [local, out-of-town] }
        oneWayMinutes: { type: ["number", "null"] }
        oneWayMiles: { type: ["number", "null"] }
        routeSource:
          { type: ["string", "null"], enum: [manual, google, mock, null] }
        routeCheckedAt: { type: ["string", "null"], format: date-time }
        crewSize: { type: ["number", "null"] }
        productiveHoursPerDay: { type: ["number", "null"] }
        minimumRequiredVisits: { type: integer, minimum: 0 }
        additionalSiteVisits: { type: integer, minimum: 0 }
        finalSiteVisitsOverride:
          type: ["integer", "null"]
          minimum: 0
          description: Estimator's explicit visit count; replaces MAX(crew days, minimum visits) + additional when set.
        mobilizationCount: { type: ["number", "null"] }
        vehicleCount: { type: ["number", "null"] }
    EstimateTravelUpdate:
      type: object
      description: Partial update of TravelInputs. Any omitted field keeps its stored value; passing null clears a numeric field back to "inherit".
      properties:
        enabled: { type: boolean }
        mode: { type: string, enum: [local, out-of-town] }
        oneWayMinutes: { type: ["number", "null"], minimum: 0 }
        oneWayMiles: { type: ["number", "null"], minimum: 0 }
        crewSize: { type: ["integer", "null"], minimum: 1 }
        productiveHoursPerDay:
          { type: ["number", "null"], exclusiveMinimum: 0, maximum: 24 }
        minimumRequiredVisits: { type: integer, minimum: 0 }
        additionalSiteVisits: { type: integer, minimum: 0 }
        finalSiteVisitsOverride: { type: ["integer", "null"], minimum: 0 }
        mobilizationCount: { type: ["integer", "null"], minimum: 0 }
        vehicleCount: { type: ["integer", "null"], minimum: 0 }
    TravelResult:
      type: object
      required:
        [
          enabled,
          mode,
          configured,
          effective,
          roundTripDriveHours,
          calculatedCrewDays,
          effectiveMinimumVisits,
          finalSiteVisits,
          trips,
          travelLaborHours,
          travelLaborCost,
          travelLaborLoadedCost,
          travelLaborPrice,
          vehicleMiles,
          vehicleExpense,
          vehiclePrice,
          totalDirectCost,
          totalPrice,
          trace,
          warnings,
        ]
      properties:
        enabled: { type: boolean }
        mode: { type: string, enum: [local, out-of-town] }
        configured: { type: boolean }
        effective:
          type: object
          description: Inputs actually used after Settings defaults were applied.
          required:
            [
              oneWayMinutes,
              oneWayMiles,
              crewSize,
              productiveHoursPerDay,
              vehicleCount,
              minimumRequiredVisits,
              additionalSiteVisits,
              finalSiteVisitsOverride,
              mobilizationCount,
              worksheetRequiredMobilizations,
              totalOnsiteLaborHours,
              driveTimeLaborRate,
              vehicleMileageRate,
            ]
          properties:
            oneWayMinutes: { type: ["number", "null"] }
            oneWayMiles: { type: ["number", "null"] }
            crewSize: { type: ["number", "null"] }
            productiveHoursPerDay: { type: number }
            vehicleCount: { type: ["number", "null"] }
            minimumRequiredVisits: { type: integer }
            additionalSiteVisits: { type: integer }
            finalSiteVisitsOverride: { type: ["integer", "null"] }
            mobilizationCount: { type: ["number", "null"] }
            worksheetRequiredMobilizations: { type: integer }
            totalOnsiteLaborHours: { type: number }
            driveTimeLaborRate: { type: number }
            vehicleMileageRate: { type: number }
        roundTripDriveHours: { type: number }
        calculatedCrewDays: { type: integer }
        effectiveMinimumVisits: { type: integer }
        finalSiteVisits: { type: integer }
        trips: { type: integer }
        travelLaborHours: { type: number }
        travelLaborCost: { type: number }
        travelLaborLoadedCost: { type: number }
        travelLaborPrice: { type: number }
        vehicleMiles: { type: number }
        vehicleExpense: { type: number }
        vehiclePrice: { type: number }
        totalDirectCost: { type: number }
        totalPrice: { type: number }
        trace:
          type: array
          items: { type: string }
        warnings:
          type: array
          items: { $ref: "#/components/schemas/TravelWarning" }
    EstimateTravel:
      type: object
      required:
        [
          inputs,
          result,
          startAddress,
          projectAddress,
          routingLookupAvailable,
          routingProvider,
        ]
      properties:
        inputs:
          oneOf:
            - $ref: "#/components/schemas/TravelInputs"
            - type: "null"
          description: Null when the estimate predates project-level travel (travel disabled, worksheets price their own drive time).
        result: { $ref: "#/components/schemas/TravelResult" }
        startAddress:
          {
            type: ["string", "null"],
            description: Company travel start address (Settings) or the company address.,
          }
        projectAddress: { type: ["string", "null"] }
        routingLookupAvailable: { type: boolean }
        routingProvider: { type: string, enum: [google, mock, none] }
    TravelRouteLookupResult:
      type: object
      required: [status, provider, reason]
      properties:
        status: { type: string, enum: [ok, unavailable, failed] }
        provider: { type: string, enum: [google, mock, none] }
        reason:
          {
            type: ["string", "null"],
            description: "Why the lookup did not run (e.g. missing-credential, missing-start-address, missing-project-address) or the provider error.",
          }
        travel:
          allOf:
            - $ref: "#/components/schemas/EstimateTravel"
          description: Present on status ok — the stored inputs and recomputed result.
    EstimateFinancialSummary:
      type: object
      description: >-
        Estimate-level rollup derived live from the persisted worksheet totals
        (base + included options) and the estimate travel result — never stored.
        Gross Profit = Total Selling Price − Total Direct Job Cost; Gross Margin %
        = GP ÷ Total Selling Price; Allocated Overhead = Σ each worksheet's
        allocated overhead (its pinned overhead rate) + travel price × current
        overhead rate; Estimated Operating Profit = GP − Allocated Overhead;
        Estimated Operating Margin % = that ÷ Total Selling Price.
      required:
        [
          totalDirectJobCost,
          totalSellingPrice,
          grossProfit,
          grossMarginPercent,
          overheadRatePercent,
          allocatedOverhead,
          estimatedOperatingProfit,
          estimatedOperatingMarginPercent,
          worksheetDirectJobCost,
          worksheetSellingPrice,
          travelDirectCost,
          travelPrice,
          worksheetsIncluded,
          warnings,
          pricingStatus,
          blockingErrors,
        ]
      properties:
        pricingStatus:
          type: string
          enum: [complete, incomplete, invalid]
          description: >-
            `invalid` means at least one active worksheet has a BLOCKING pricing
            error (labor hours booked against a rate that is not configured), so
            every figure below that depends on it is null rather than a
            plausible-looking zero. `incomplete` is the older Pricing Incomplete
            state — config warnings, numbers still meaningful. `complete` is
            neither.
        blockingErrors:
          type: array
          description: >-
            One entry per blocking error, naming the worksheet, the labor
            category and the Settings field to fix. Empty unless pricingStatus
            is `invalid`.
          items:
            type: object
            required: [worksheetId, worksheetTitle, code, subject, message]
            properties:
              worksheetId:
                type: ["string", "null"]
                format: uuid
                description: >-
                  The worksheet the error came from, or null for an
                  estimate-level error such as project travel, which
                  belongs to no single worksheet.
              worksheetTitle: { type: string }
              code: { type: string }
              subject: { type: ["string", "null"] }
              message: { type: string }
        totalDirectJobCost:
          {
            type: ["number", "null"],
            description: "Null when pricingStatus is `invalid` — unavailable, not zero.",
          }
        totalSellingPrice:
          {
            type: ["number", "null"],
            description: Null when pricingStatus is `invalid`.,
          }
        grossProfit:
          {
            type: ["number", "null"],
            description: Null when pricingStatus is `invalid`.,
          }
        grossMarginPercent:
          {
            type: ["number", "null"],
            description: Null when pricingStatus is `invalid`.,
          }
        overheadRatePercent:
          {
            type: number,
            description: Current company overhead rate (percent of selling price) used for the travel allocation.,
          }
        allocatedOverhead:
          {
            type: ["number", "null"],
            description: Null when pricingStatus is `invalid`.,
          }
        estimatedOperatingProfit:
          {
            type: ["number", "null"],
            description: Null when pricingStatus is `invalid`.,
          }
        estimatedOperatingMarginPercent:
          {
            type: ["number", "null"],
            description: Null when pricingStatus is `invalid`.,
          }
        worksheetDirectJobCost:
          {
            type: ["number", "null"],
            description: Null when pricingStatus is `invalid`.,
          }
        worksheetSellingPrice:
          {
            type: ["number", "null"],
            description: Null when pricingStatus is `invalid`.,
          }
        travelDirectCost: { type: number }
        travelPrice: { type: number }
        worksheetsIncluded: { type: integer }
        warnings:
          type: array
          items: { $ref: "#/components/schemas/TravelWarning" }
    ProposalFromWorksheetInput:
      type: object
      properties:
        includeEstimateTravel:
          type: boolean
          description: >-
            Add the estimate's Travel & Mobilization price as a proposal section
            and to the proposal amount. Defaults to true only when the estimate
            has travel enabled AND this worksheet is the estimate's first base
            worksheet (lowest sort order), so several base-sheet proposals never
            each carry the full travel.
    InteractiveRowValues:
      type: object
      required: [id, values]
      properties:
        id: { type: string, minLength: 1, maxLength: 64 }
        values:
          type: object
          additionalProperties: true
    InteractiveInputValues:
      type: object
      required: [fields, rows]
      properties:
        fields:
          type: object
          additionalProperties: true
        rows:
          type: object
          additionalProperties:
            type: array
            items: { $ref: "#/components/schemas/InteractiveRowValues" }
    InteractiveInputValuesSave:
      type: object
      required: [fields, rows]
      properties:
        fields:
          type: object
          additionalProperties: true
        rows:
          type: object
          additionalProperties:
            type: array
            items: { $ref: "#/components/schemas/InteractiveRowValues" }
        baseRevision:
          type: integer
          description: The worksheet revision the client loaded; the save is rejected with 409 when the worksheet changed since.
    WorksheetConflict:
      type: object
      required: [error]
      properties:
        error: { type: string }
        revision:
          type: integer
          description: The worksheet's current revision on the server.
        worksheet:
          allOf:
            - $ref: "#/components/schemas/WorksheetDetail"
          description: Optional live worksheet snapshot returned by custom-cost-row conflicts for immediate reconciliation.
    InteractiveDiagnostic:
      type: object
      required: [code, message]
      properties:
        code: { type: string }
        nodeId: { type: ["string", "null"] }
        message: { type: string }
    InteractiveEvaluation:
      type: object
      required: [amount, values, sections, diagnostics]
      properties:
        amount: { type: number }
        values:
          type: object
          additionalProperties: true
        sections:
          type: array
          items:
            type: object
            additionalProperties: true
        diagnostics:
          type: array
          items: { $ref: "#/components/schemas/InteractiveDiagnostic" }
    InteractiveWorksheet:
      type: object
      required:
        [
          worksheet,
          templateVersion,
          templateActiveVersion,
          interactive,
          definition,
          inputValues,
          evaluation,
        ]
      properties:
        worksheet: { $ref: "#/components/schemas/WorksheetItem" }
        templateVersion: { type: integer }
        templateActiveVersion: { type: integer }
        interactive: { type: boolean }
        definition:
          type: ["object", "null"]
          additionalProperties: true
        inputValues: { $ref: "#/components/schemas/InteractiveInputValues" }
        evaluation:
          anyOf:
            - $ref: "#/components/schemas/InteractiveEvaluation"
            - type: "null"
    WorksheetRepricePreview:
      type: object
      required:
        [
          worksheetId,
          currentVersion,
          activeVersion,
          currentSellingPrice,
          newSellingPrice,
        ]
      properties:
        worksheetId: { type: string, format: uuid }
        currentVersion: { type: integer }
        activeVersion: { type: integer }
        currentSellingPrice: { type: number }
        newSellingPrice:
          type: number
          description: Selling price after re-evaluating the existing takeoff inputs against the active pricing version.
    WorksheetRepriceEvent:
      type: object
      required:
        [
          id,
          worksheetId,
          repricedByName,
          fromVersion,
          toVersion,
          oldSellingPrice,
          newSellingPrice,
          repricedAt,
        ]
      properties:
        id: { type: string, format: uuid }
        worksheetId: { type: string, format: uuid }
        repricedByName: { type: string }
        fromVersion: { type: integer }
        toVersion: { type: integer }
        oldSellingPrice: { type: number }
        newSellingPrice: { type: number }
        repricedAt: { type: string, format: date-time }
    WorksheetRepriceEventList:
      type: object
      required: [events]
      properties:
        events:
          type: array
          items: { $ref: "#/components/schemas/WorksheetRepriceEvent" }
    EstimateRepriceEvent:
      type: object
      required:
        [
          id,
          worksheetId,
          worksheetTitle,
          repricedByName,
          fromVersion,
          toVersion,
          oldSellingPrice,
          newSellingPrice,
          repricedAt,
        ]
      properties:
        id: { type: string, format: uuid }
        worksheetId:
          type: ["string", "null"]
          format: uuid
          description: Null once the worksheet has been deleted; the event is retained for the bid's pricing history.
        worksheetTitle:
          type: string
          description: The worksheet's current title, or its title as of deletion time for deleted worksheets.
        repricedByName: { type: string }
        fromVersion: { type: integer }
        toVersion: { type: integer }
        oldSellingPrice: { type: number }
        newSellingPrice: { type: number }
        repricedAt: { type: string, format: date-time }
    EstimateRepriceEventList:
      type: object
      required: [events]
      properties:
        events:
          type: array
          items: { $ref: "#/components/schemas/EstimateRepriceEvent" }
    RepriceWorksheetRequest:
      type: object
      required: [expectedActiveVersion]
      properties:
        expectedActiveVersion:
          type: integer
          description: The active template version the estimator confirmed against; a mismatch aborts with 409 so no worksheet re-prices onto a version nobody previewed.
    EstimateRepricePreviewItem:
      type: object
      required:
        [
          worksheetId,
          title,
          currentVersion,
          activeVersion,
          currentSellingPrice,
          newSellingPrice,
        ]
      properties:
        worksheetId: { type: string, format: uuid }
        title: { type: string }
        currentVersion: { type: integer }
        activeVersion: { type: integer }
        currentSellingPrice: { type: number }
        newSellingPrice: { type: number }
    EstimateRepricePreview:
      type: object
      required:
        [estimateId, outdatedCount, currentBidTotal, newBidTotal, worksheets]
      properties:
        estimateId: { type: string, format: uuid }
        outdatedCount: { type: integer }
        currentBidTotal:
          type: number
          description: Base bid + shown options as currently priced.
        newBidTotal:
          type: number
          description: Base bid + shown options if every outdated interactive worksheet moved to current pricing.
        worksheets:
          type: array
          items: { $ref: "#/components/schemas/EstimateRepricePreviewItem" }
    RepriceEstimateRequest:
      type: object
      required: [worksheets]
      properties:
        worksheets:
          type: array
          minItems: 1
          description: The exact worksheet/version pairs the estimator confirmed; any mismatch with the current outdated set aborts with 409 and nothing is re-priced.
          items:
            type: object
            required: [worksheetId, expectedActiveVersion]
            properties:
              worksheetId: { type: string, format: uuid }
              expectedActiveVersion: { type: integer }
    TemplatePricingParameter:
      type: object
      required: [key, label, value, defaultValue]
      properties:
        key: { type: string }
        label: { type: string }
        unit: { type: ["string", "null"] }
        help: { type: ["string", "null"] }
        value: { type: number }
        defaultValue: { type: number }
    TemplatePricingGroup:
      type: object
      required: [key, label, parameters]
      properties:
        key: { type: string }
        label: { type: string }
        parameters:
          type: array
          items: { $ref: "#/components/schemas/TemplatePricingParameter" }
    TemplatePricing:
      type: object
      required: [templateId, templateKey, name, activeVersion, groups]
      properties:
        templateId: { type: string, format: uuid }
        templateKey: { type: string }
        name: { type: string }
        activeVersion: { type: integer }
        groups:
          type: array
          items: { $ref: "#/components/schemas/TemplatePricingGroup" }
    TemplatePricingList:
      type: object
      required: [templates]
      properties:
        templates:
          type: array
          items: { $ref: "#/components/schemas/TemplatePricing" }
    TemplatePricingChange:
      type: object
      required: [key, label, previousValue, newValue]
      properties:
        key: { type: string }
        label: { type: string }
        unit: { type: ["string", "null"] }
        previousValue: { type: number }
        newValue: { type: number }
    TemplatePricingVersionValue:
      type: object
      required: [key, label, value]
      properties:
        key: { type: string }
        label: { type: string }
        unit: { type: ["string", "null"] }
        value: { type: number }
    TemplatePricingVersion:
      type: object
      required: [version, createdAt, publishedBy, isActive, changes, parameters]
      properties:
        version: { type: integer }
        createdAt: { type: string, format: date-time }
        publishedBy: { type: ["string", "null"] }
        isActive: { type: boolean }
        changes:
          type: array
          items: { $ref: "#/components/schemas/TemplatePricingChange" }
        parameters:
          type: array
          items: { $ref: "#/components/schemas/TemplatePricingVersionValue" }
    TemplatePricingHistory:
      type: object
      required: [templateId, templateKey, name, activeVersion, versions]
      properties:
        templateId: { type: string, format: uuid }
        templateKey: { type: string }
        name: { type: string }
        activeVersion: { type: integer }
        versions:
          type: array
          items: { $ref: "#/components/schemas/TemplatePricingVersion" }
    TemplatePricingPublish:
      type: object
      required: [baseVersion, values]
      properties:
        baseVersion: { type: integer }
        values:
          type: object
          additionalProperties: { type: number }
    EstimateWorkbook:
      type: object
      required:
        [
          estimate,
          project,
          worksheets,
          outdatedPricingCount,
          freightPricingOutdated,
          travel,
          financialSummary,
        ]
      properties:
        estimate: { $ref: "#/components/schemas/Estimate" }
        project: { $ref: "#/components/schemas/ProjectRef" }
        travel: { $ref: "#/components/schemas/EstimateTravel" }
        financialSummary:
          { $ref: "#/components/schemas/EstimateFinancialSummary" }
        outdatedPricingCount:
          type: integer
          description: Number of interactive worksheets pinned to a pricing version older than their template's active version.
        freightPricingOutdated:
          type: boolean
          description: True when at least one Tremco 250GC worksheet uses freight terms that differ from the company's current rule.
        worksheets:
          type: array
          items: { $ref: "#/components/schemas/WorksheetItem" }
    WorksheetCreate:
      type: object
      required: [templateId]
      properties:
        templateId: { type: string, format: uuid }
        title: { type: string, minLength: 1 }
    WorksheetUpdate:
      type: object
      properties:
        title: { type: string, minLength: 1 }
        worksheetType:
          type: string
          enum: [base, option]
        relatedBaseWorksheetId: { type: ["string", "null"], format: uuid }
        optionInclusion:
          type: string
          enum: [excluded, shown, included]
        notes: { type: string }
        baseUpdatedAt:
          type: string
          format: date-time
          description: The worksheet updatedAt value the client loaded; the update is rejected with 409 when the worksheet changed since. Omit for last-write-wins.
    WorksheetDuplicate:
      type: object
      properties:
        title: { type: string, minLength: 1 }
    WorksheetOrder:
      type: object
      required: [worksheetIds]
      properties:
        worksheetIds:
          type: array
          minItems: 1
          items: { type: string, format: uuid }
    WorksheetValueEntry:
      type: object
      required: [inputDefinitionId, value]
      properties:
        inputDefinitionId: { type: string, format: uuid }
        value: { type: ["string", "null"] }
    WorksheetValuesUpdate:
      type: object
      required: [values]
      properties:
        values:
          type: array
          items: { $ref: "#/components/schemas/WorksheetValueEntry" }
        baseRevision:
          type: integer
          description: The worksheet revision the client loaded; the save is rejected with 409 when the worksheet changed since.
    ProposalSectionInput:
      type: object
      required: [title, content, kind]
      properties:
        title: { type: string }
        content: { type: string }
        kind: { type: string }
    ProposalSectionSummary:
      type: object
      required: [id, title, content, kind]
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        content: { type: string }
        kind: { type: string }
        lastEditorName: { type: ["string", "null"] }
        lastEditedAt: { type: ["string", "null"], format: date-time }
    ProposalSection:
      type: object
      required: [id, proposalId, position, title, content, kind]
      properties:
        id: { type: string, format: uuid }
        proposalId: { type: string, format: uuid }
        position: { type: integer }
        title: { type: string }
        content: { type: string }
        kind: { type: string }
    ProposalSectionUpdate:
      type: object
      properties:
        title: { type: string, minLength: 1 }
        content: { type: string, minLength: 1 }
        kind: { type: string, minLength: 1 }
    Proposal:
      type: object
      required:
        [
          id,
          projectId,
          version,
          status,
          amount,
          snapshotJobNumber,
          snapshotPrevailingWage,
          snapshotPlanDate,
          snapshotBidSubmissionDate,
          sourceEstimateId,
          sourceEstimateName,
          sourceWorksheetId,
          sourceTemplateVersion,
          staleSourceWorksheet,
          sourceWorksheetUpdatedAt,
          sourceWorksheetCurrentPrice,
          sections,
          createdAt,
        ]
      properties:
        id: { type: string, format: uuid }
        projectId: { type: string, format: uuid }
        version: { type: integer }
        status: { type: string }
        amount: { type: number }
        sourceEstimateId: { type: ["string", "null"], format: uuid }
        sourceEstimateName: { type: ["string", "null"] }
        sourceWorksheetId: { type: ["string", "null"], format: uuid }
        sourceTemplateVersion: { type: ["integer", "null"] }
        # Staleness signals: present when the proposal was drafted from a
        # worksheet. staleSourceWorksheet is true when the source worksheet
        # was edited after this proposal was created (the frozen proposal may
        # no longer match the worksheet's current price).
        staleSourceWorksheet: { type: "boolean" }
        sourceWorksheetUpdatedAt:
          { type: ["string", "null"], format: date-time }
        sourceWorksheetCurrentPrice: { type: ["number", "null"] }
        snapshotJobNumber: { type: ["string", "null"] }
        snapshotPrevailingWage: { type: ["boolean", "null"] }
        snapshotPlanDate:
          { type: ["string", "null"], pattern: '^\d{4}-\d{2}-\d{2}$' }
        snapshotBidSubmissionDate:
          { type: ["string", "null"], pattern: '^\d{4}-\d{2}-\d{2}$' }
        sections:
          type: array
          items: { $ref: "#/components/schemas/ProposalSectionSummary" }
        createdAt: { type: string, format: date-time }
        engineWarningCount:
          type: integer
          description: Number of engine config warnings (unpriced/unconfigured items) present on the source worksheet at drafting time. Only set for engine-backed worksheets.
    ProposalInput:
      type: object
      required: [status, amount, sections]
      properties:
        status: { type: string }
        amount: { type: number }
        sourceEstimateId:
          type: ["string", "null"]
          format: uuid
          description: Optional estimate this manual proposal was created from. The server verifies the estimate belongs to this project and derives the name — the client cannot supply a name directly.
        sections:
          type: array
          items: { $ref: "#/components/schemas/ProposalSectionInput" }
    Rfi:
      type: object
      required: [id, projectId, number, subject, question, status, createdAt]
      properties:
        id: { type: string, format: uuid }
        projectId: { type: string, format: uuid }
        number: { type: string }
        subject: { type: string }
        question: { type: string }
        submittedTo: { type: ["string", "null"] }
        response: { type: ["string", "null"] }
        status: { type: string, enum: [Draft, Open, Answered, Closed] }
        createdAt: { type: string, format: date-time }
        lastEditorName: { type: ["string", "null"] }
        lastEditedAt: { type: ["string", "null"], format: date-time }
    RfiInput:
      type: object
      required: [subject, question, status]
      properties:
        subject: { type: string, minLength: 1 }
        question: { type: string, minLength: 1 }
        submittedTo: { type: string }
        status: { type: string, enum: [Draft, Open, Answered, Closed] }
    RfiUpdate:
      type: object
      properties:
        subject: { type: string, minLength: 1 }
        question: { type: string, minLength: 1 }
        submittedTo: { type: ["string", "null"] }
        status: { type: string, enum: [Draft, Open, Answered, Closed] }
    ChangeOrder:
      type: object
      required:
        [id, projectId, number, description, scope, price, status, createdAt]
      properties:
        id: { type: string, format: uuid }
        projectId: { type: string, format: uuid }
        number: { type: string }
        description: { type: string }
        scope: { type: string }
        price: { type: number }
        status: { type: string, enum: [Draft, Submitted, Approved, Rejected] }
        createdAt: { type: string, format: date-time }
        lastEditorName: { type: ["string", "null"] }
        lastEditedAt: { type: ["string", "null"], format: date-time }
    ChangeOrderInput:
      type: object
      required: [description, scope, price, status]
      properties:
        description: { type: string, minLength: 1 }
        scope: { type: string }
        price: { type: number }
        status: { type: string, enum: [Draft, Submitted, Approved, Rejected] }
    ChangeOrderUpdate:
      type: object
      properties:
        description: { type: string, minLength: 1 }
        scope: { type: string }
        price: { type: number }
        status: { type: string, enum: [Draft, Submitted, Approved, Rejected] }
    AiMessage:
      type: object
      required: [id, projectId, conversationId, role, content, createdAt]
      properties:
        id: { type: string, format: uuid }
        projectId: { type: string, format: uuid }
        conversationId: { type: string, format: uuid }
        role: { type: string, enum: [user, assistant, system] }
        content: { type: string }
        createdAt: { type: string, format: date-time }
    AiMessageInput:
      type: object
      required: [content]
      properties:
        content: { type: string, minLength: 1, maxLength: 8000 }
    AiActionType:
      type: string
      enum:
        [
          create_scope,
          create_rfi,
          create_change_order,
          create_proposal,
          update_scope,
          update_rfi,
          update_change_order,
          update_proposal_section,
          update_project_note,
        ]
    AiActionStatus:
      type: string
      enum: [proposed, confirmed, rejected]
    AiProposedAction:
      type: object
      required:
        [
          id,
          projectId,
          conversationId,
          actionType,
          title,
          summary,
          payload,
          status,
          createdAt,
          updatedAt,
        ]
      properties:
        id: { type: string, format: uuid }
        projectId: { type: string, format: uuid }
        conversationId: { type: string, format: uuid }
        actionType: { $ref: "#/components/schemas/AiActionType" }
        title: { type: string }
        summary: { type: string }
        payload:
          type: object
          additionalProperties: true
        status: { $ref: "#/components/schemas/AiActionStatus" }
        resultId: { type: ["string", "null"], format: uuid }
        error: { type: ["string", "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    AiActionConfirmInput:
      type: object
      properties:
        payload:
          type: object
          additionalProperties: true
    AiChatExchange:
      type: object
      required: [userMessage, assistantMessage, proposedActions]
      properties:
        userMessage: { $ref: "#/components/schemas/AiMessage" }
        assistantMessage: { $ref: "#/components/schemas/AiMessage" }
        proposedActions:
          type: array
          items: { $ref: "#/components/schemas/AiProposedAction" }
    ProjectWorkspace:
      type: object
      required:
        [
          project,
          metrics,
          notes,
          contacts,
          updates,
          availableContacts,
          scopes,
          estimates,
          proposals,
          rfis,
          changeOrders,
          aiMessages,
          aiActions,
        ]
      properties:
        project: { $ref: "#/components/schemas/Project" }
        metrics:
          type: object
          additionalProperties: { type: number }
        notes:
          type: array
          items: { $ref: "#/components/schemas/ProjectNote" }
        contacts:
          type: array
          items: { $ref: "#/components/schemas/OpportunityContact" }
        updates:
          type: array
          items: { $ref: "#/components/schemas/OpportunityUpdate" }
        availableContacts:
          type: array
          items: { $ref: "#/components/schemas/CustomerContact" }
        scopes:
          type: array
          items: { $ref: "#/components/schemas/Scope" }
        estimates:
          type: array
          items: { $ref: "#/components/schemas/Estimate" }
        proposals:
          type: array
          items: { $ref: "#/components/schemas/Proposal" }
        rfis:
          type: array
          items: { $ref: "#/components/schemas/Rfi" }
        changeOrders:
          type: array
          items: { $ref: "#/components/schemas/ChangeOrder" }
        aiMessages:
          type: array
          items: { $ref: "#/components/schemas/AiMessage" }
        aiActions:
          type: array
          items: { $ref: "#/components/schemas/AiProposedAction" }
    Customer:
      type: object
      required:
        [
          id,
          opportunityTitle,
          companyName,
          contactName,
          status,
          isDemo,
          updatedAt,
        ]
      properties:
        id: { type: string, format: uuid }
        opportunityTitle: { type: string }
        companyName: { type: string }
        contactName: { type: string }
        phone: { type: ["string", "null"] }
        email: { type: ["string", "null"] }
        streetAddress: { type: ["string", "null"] }
        followUpNotes: { type: ["string", "null"] }
        fieldNotes: { type: ["string", "null"] }
        status: { type: string }
        isDemo: { type: boolean }
        updatedAt: { type: string, format: date-time }
        contacts:
          type: array
          items: { $ref: "#/components/schemas/CustomerContact" }
    CustomerInput:
      type: object
      required: [opportunityTitle, companyName, contactName]
      properties:
        opportunityTitle: { type: string, minLength: 1 }
        companyName: { type: string }
        contactName: { type: string }
        phone: { type: string }
        email: { type: string }
        streetAddress: { type: string }
        followUpNotes: { type: string }
        fieldNotes: { type: string }
        status: { type: string }
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error: { type: string }
    CrmCompany:
      type: object
      required: [id, name, archived, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        address: { type: ["string", "null"] }
        phone: { type: ["string", "null"] }
        email: { type: ["string", "null"] }
        website: { type: ["string", "null"] }
        notes: { type: ["string", "null"] }
        archived: { type: boolean }
        archivedAt: { type: ["string", "null"], format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    CrmCompanyInput:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 1 }
        address: { type: string }
        phone: { type: string }
        email: { type: string }
        website: { type: string }
        notes: { type: string }
    CrmCompanyUpdate:
      type: object
      properties:
        name: { type: string, minLength: 1 }
        address: { type: string }
        phone: { type: string }
        email: { type: string }
        website: { type: string }
        notes: { type: string }
    CrmContact:
      type: object
      required: [id, name, crmCompanyId, archived, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        crmCompanyId: { type: ["string", "null"], format: uuid }
        crmCompanyName: { type: ["string", "null"] }
        name: { type: string }
        title: { type: ["string", "null"] }
        email: { type: ["string", "null"] }
        phone: { type: ["string", "null"] }
        address: { type: ["string", "null"] }
        notes: { type: ["string", "null"] }
        archived: { type: boolean }
        archivedAt: { type: ["string", "null"], format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    CrmContactInput:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 1 }
        crmCompanyId:
          type: ["string", "null"]
          format: uuid
          description: Associate with a CRM company, or null/omitted for an individual contact.
        title: { type: string }
        email: { type: string }
        phone: { type: string }
        address: { type: string }
        notes: { type: string }
    CrmContactUpdate:
      type: object
      properties:
        name: { type: string, minLength: 1 }
        crmCompanyId:
          type: ["string", "null"]
          format: uuid
          description: Set to a company id to associate, null to make an individual, or omit to leave unchanged.
        title: { type: string }
        email: { type: string }
        phone: { type: string }
        address: { type: string }
        notes: { type: string }
    CrmImportPreviewInput:
      type: object
      required: [mapping]
      properties:
        mapping:
          type: object
          additionalProperties: { type: string }
    CrmImportPreview:
      type: object
      required:
        [
          sourceName,
          totalRows,
          columns,
          mappedColumns,
          warnings,
          possibleDuplicates,
          persistenceEnabled,
        ]
      properties:
        sourceName: { type: string }
        totalRows: { type: integer }
        columns:
          type: array
          items: { type: string }
        mappedColumns:
          type: object
          additionalProperties: { type: string }
        warnings:
          type: array
          items: { type: string }
        possibleDuplicates: { type: integer }
        persistenceEnabled: { type: boolean }
    CostItem:
      type: object
      required:
        [
          id,
          category,
          name,
          unit,
          currentCost,
          effectiveDate,
          markupPercent,
          pricingStatus,
        ]
      properties:
        id: { type: string, format: uuid }
        category: { type: string }
        manufacturer: { type: ["string", "null"] }
        vendor: { type: ["string", "null"] }
        name: { type: string }
        description: { type: ["string", "null"] }
        unit: { type: string }
        currentCost: { type: number }
        effectiveDate: { type: string, format: date }
        markupPercent: { type: number }
        pricingStatus: { type: string, enum: [needs_quote, priced] }
        unitWeightLb:
          type: ["number", "null"]
          description: Shipping weight per purchase unit in pounds; used for freight-by-weight.
        sourceKey: { type: ["string", "null"] }
    CostItemInput:
      type: object
      required: [category, name, unit, currentCost, effectiveDate]
      properties:
        category: { type: string }
        manufacturer: { type: string }
        vendor: { type: string }
        name: { type: string, minLength: 1 }
        description: { type: string }
        unit: { type: string }
        currentCost: { type: number }
        effectiveDate: { type: string, format: date }
        markupPercent: { type: number }
        pricingStatus: { type: string, enum: [needs_quote, priced] }
        unitWeightLb:
          type: ["number", "null"]
          minimum: 0
    CostItemImportResult:
      type: object
      required: [sourceName, totalRows, created, skipped, pricingNeeded]
      properties:
        sourceName: { type: string }
        totalRows: { type: integer }
        created: { type: integer }
        skipped: { type: integer }
        pricingNeeded: { type: integer }
    ProposalLanguage:
      type: object
      required: [id, category, title, content, version, updatedAt]
      properties:
        id: { type: string, format: uuid }
        category: { type: string }
        system: { type: ["string", "null"] }
        application: { type: ["string", "null"] }
        title: { type: string }
        content: { type: string }
        version: { type: integer }
        updatedAt: { type: string, format: date-time }
    ProposalLanguageInput:
      type: object
      required: [category, title, content]
      properties:
        category: { type: string }
        system: { type: string }
        application: { type: string }
        title: { type: string, minLength: 1 }
        content: { type: string, minLength: 1 }
    FreightTier:
      type: object
      required: [overWeightLb]
      properties:
        overWeightLb:
          type: number
          minimum: 0
          description: Tier applies when total shipping weight strictly exceeds this many pounds.
        ratePerLb: { type: number, minimum: 0 }
        flatAmount: { type: number, minimum: 0 }
    FreightRule:
      type: object
      required: [kind, tiers]
      properties:
        kind: { type: string, enum: [weight-tiered] }
        tiers:
          type: array
          items: { $ref: "#/components/schemas/FreightTier" }
    Settings:
      type: object
      required:
        - userName
        - userEmail
        - companyName
        - revision
        - materialTaxRate
        - laborBurdenRate
        - overheadCalculationMode
        - overheadRate
        - targetAnnualRevenue
        - effectiveOverheadRate
        - annualOverheadTotal
        - baseProfitMargin
        - normalLaborRate
        - leadLaborRate
        - designLaborRate
        - driveTimeLaborRate
        - prevailingWageLaborRate
        - driveHourMinimum
        - travelStartAddress
        - defaultCrewSize
        - productiveHoursPerDay
        - defaultVehicleCount
        - vehicleMileageRate
        - routingLookupAvailable
        - routingProvider
        - minimumLaborAmount
        - freightRate
        - wasteMiscRate
        - rushPricingAdd
        - defaultSubcontractorMarkup
        - smallToolsRate
        - overheadCosts
        - onboardedAt
        - callerRole
        - callerUserId
      properties:
        callerRole:
          type: ["string", "null"]
          description: The caller's active company membership role.
        callerUserId:
          type: string
          format: uuid
          description: The caller's internal user UUID (for client-side self-identification).
        userName: { type: string }
        userEmail: { type: string }
        userPhone: { type: ["string", "null"] }
        companyName: { type: string }
        revision: { type: integer, minimum: 0 }
        companyAddress: { type: ["string", "null"] }
        companyPhone: { type: ["string", "null"] }
        companyEmail: { type: ["string", "null"] }
        companyWebsite: { type: ["string", "null"] }
        companyFax:
          type: ["string", "null"]
          description: Fax number printed on the proposal letterhead. Blank = the fax segment is omitted.
        companyDivisionLine:
          type: ["string", "null"]
          description: >-
            Line printed directly under the company name on the proposal
            letterhead (e.g. "A Division of Acme Holdings"). Blank = the line
            prints empty; nothing is substituted for it.
        companyLicenseNumbers:
          type: ["string", "null"]
          description: >-
            Free-text contractor licence numbers for the proposal letterhead.
            Each newline is its own line in the cover-page block (three lines;
            any beyond the second are joined onto the third) and the whole set
            is joined with a separator on the page-edge header. Blank = those
            lines print empty.
        companyLogoPath:
          type: ["string", "null"]
          description: >-
            Object path of the logo printed on the proposal cover and page
            header. Null = no logo uploaded and both slots print blank. Set
            through /company-logo, not through PATCH /settings.
        materialTaxRate: { type: number }
        laborBurdenRate: { type: number }
        overheadCalculationMode: { type: string, enum: [direct, calculated] }
        overheadRate:
          type: number
          description: >-
            Overhead Rate as a percent of selling price (revenue). Reporting
            allocation only (Allocated Overhead = Total Sale × rate → Estimated
            Operating Profit, i.e. profit after allocated overhead — labelled
            Operating, not Net); not a pricing driver. Leave 0 until the real
            company overhead is known.
        targetAnnualRevenue: { type: number }
        effectiveOverheadRate:
          type: number
          description: >-
            Overhead rate in effect (% of selling price): direct rate, or annual
            overhead ÷ target annual revenue in calculated mode.
        annualOverheadTotal: { type: number }
        baseProfitMargin:
          type: number
          description: >-
            Target Gross Margin (%). Legacy baseline 40%: ordinary selling price
            = direct job cost ÷ (1 − this). Margin, not markup.
            Subcontractors/out-of-town keep their own markup.
        normalLaborRate: { type: number }
        leadLaborRate: { type: number }
        designLaborRate: { type: number }
        driveTimeLaborRate:
          type: number
          description: >-
            $/HR applied to a worksheet's drive-time hours (engine worksheets:
            Include Drive Time = Yes). Drive time is figured on every stand-alone
            job and switched off per worksheet when the crew is already on site.
        prevailingWageLaborRate: { type: number }
        driveHourMinimum:
          type: number
          description: >-
            Minimum drive-time hours billed on any engine worksheet with Include
            Drive Time = Yes; also the DEFAULT hours when the worksheet leaves
            Drive-Time Hours blank. A worksheet-entered value is floored by this.
            (A future office-ZIP → job-site calculation will populate worksheet
            hours.) Independent of Move-Ons, which are dollars per move-on with
            no Settings default.
        minimumLaborAmount:
          type: number
          deprecated: true
          description: >-
            DEPRECATED — no longer used by any estimating engine and hidden from
            the Settings screen. Worksheet labor minimums are the legacy
            per-operation minimum hours carried in each template, switched on/off
            per worksheet with "Apply Minimum Labor Charge". Kept in the contract
            for compatibility; the API accepts and stores it but nothing prices
            from it.
        freightRate: { type: number }
        freightRule: { $ref: "#/components/schemas/FreightRule" }
        wasteMiscRate:
          type: number
          description: >-
            Company default MATERIAL WASTE percent (owner default 10). Applied by
            the explicit waste model to coverage-driven material rows that inherit
            (template/material overrides and worksheet overrides win; effective
            field rates that already include loss get none). See
            docs/estimating-templates/packaging-and-waste.md.
        rushPricingAdd: { type: number }
        defaultSubcontractorMarkup: { type: number }
        smallToolsRate: { type: number }
        travelStartAddress:
          type: ["string", "null"]
          description: Office / yard address trips start from for project travel; blank = the company address.
        defaultCrewSize:
          {
            type: ["integer", "null"],
            description: Default crew size for estimate travel (null = must be entered per estimate).,
          }
        productiveHoursPerDay:
          {
            type: number,
            description: Productive hours per crew day for crew-day calculation (owner default 8).,
          }
        defaultVehicleCount:
          {
            type: ["integer", "null"],
            description: Default vehicles per trip for mileage (null = must be entered per estimate).,
          }
        vehicleMileageRate:
          {
            type: number,
            description: Vehicle expense per mile ($/mi); 0 = not set (vehicle expense $0 with a warning).,
          }
        routingLookupAvailable:
          type: boolean
          description: True when a routing provider credential is configured (GOOGLE_MAPS_API_KEY) or the mock provider is selected; false → route lookup unavailable, manual minutes/miles still work.
        routingProvider: { type: string, enum: [google, mock, none] }
        onboardedAt:
          type: ["string", "null"]
          format: date-time
          description: >-
            When first-run onboarding was completed — the company name and the
            core labor rates were entered deliberately. Null = never onboarded;
            the app routes the tenant to /welcome rather than to a dashboard
            priced off the $0/hr column defaults. Set once, never cleared.
        overheadCosts:
          type: array
          items: { $ref: "#/components/schemas/OverheadCost" }
    SettingsInput:
      type: object
      required: [revision]
      properties:
        revision: { type: integer, minimum: 0 }
        markOnboarded:
          type: boolean
          description: >-
            Set by the /welcome form to stamp Settings.onboardedAt. Only true
            has an effect and only the first time — onboarding is never
            un-completed, so a later save cannot clear the marker.
        userName: { type: string }
        userPhone: { type: ["string", "null"] }
        companyName: { type: string }
        companyAddress: { type: ["string", "null"] }
        companyPhone: { type: ["string", "null"] }
        companyEmail: { type: ["string", "null"] }
        companyWebsite: { type: ["string", "null"] }
        companyFax: { type: ["string", "null"] }
        companyDivisionLine:
          type: ["string", "null"]
          description: Letterhead line under the company name (e.g. "A Division of Acme Holdings").
        companyLicenseNumbers:
          type: ["string", "null"]
          description: Free-text licence numbers for the letterhead; newlines separate cover-page lines.
        materialTaxRate: { type: number, minimum: 0, maximum: 100 }
        laborBurdenRate: { type: number, minimum: 0, maximum: 100 }
        overheadCalculationMode: { type: string, enum: [direct, calculated] }
        overheadRate:
          type: number
          minimum: 0
          maximum: 100
          description: >-
            Overhead Rate as a percent of selling price (revenue). Reporting
            allocation only (Allocated Overhead = Total Sale × rate → Estimated
            Operating Profit, i.e. profit after allocated overhead — labelled
            Operating, not Net); not a pricing driver. Leave 0 until the real
            company overhead is known.
        targetAnnualRevenue: { type: number, minimum: 0 }
        baseProfitMargin:
          type: number
          minimum: 0
          maximum: 100
          description: >-
            Target Gross Margin (%). Legacy baseline 40%: ordinary selling price
            = direct job cost ÷ (1 − this). Margin, not markup.
            Subcontractors/out-of-town keep their own markup.
        normalLaborRate: { type: number, minimum: 0 }
        leadLaborRate: { type: number, minimum: 0 }
        designLaborRate: { type: number, minimum: 0 }
        driveTimeLaborRate: { type: number, minimum: 0 }
        prevailingWageLaborRate: { type: number, minimum: 0 }
        driveHourMinimum:
          type: number
          minimum: 0
          description: Minimum / default drive-time hours per worksheet (see Settings.driveHourMinimum).
        minimumLaborAmount:
          type: number
          minimum: 0
          deprecated: true
          description: DEPRECATED — accepted for compatibility; no engine prices from it.
        freightRate: { type: number, minimum: 0, maximum: 100 }
        freightRule: { $ref: "#/components/schemas/FreightRule" }
        wasteMiscRate: { type: number, minimum: 0, maximum: 100 }
        rushPricingAdd: { type: number, minimum: 0 }
        defaultSubcontractorMarkup: { type: number }
        smallToolsRate: { type: number, minimum: 0, maximum: 100 }
        travelStartAddress: { type: ["string", "null"] }
        defaultCrewSize: { type: ["integer", "null"], minimum: 1 }
        productiveHoursPerDay:
          { type: number, exclusiveMinimum: 0, maximum: 24 }
        defaultVehicleCount: { type: ["integer", "null"], minimum: 0 }
        vehicleMileageRate: { type: number, minimum: 0 }
        overheadCosts:
          type: array
          items: { $ref: "#/components/schemas/OverheadCostInput" }
    ProposalTerm:
      type: object
      required: [tag, label, position, body, version, isCustomized]
      properties:
        tag:
          type: string
          description: Immutable slot identifier, matching the Word content control.
        label:
          type: string
          description: Section label shown in Settings; never printed on the document.
        position: { type: integer }
        body:
          type: string
          description: >-
            The company's own text. May contain the literal token
            "[Company Name]", replaced with the company name when the proposal
            is rendered.
        version: { type: integer, minimum: 1 }
        isCustomized:
          type: boolean
          description: True when the body differs from the shipped default.
    ProposalTermUpdate:
      type: object
      required: [body]
      properties:
        body:
          type: string
          maxLength: 20000
          description: >-
            Replacement text. Blank is allowed and prints an empty paragraph —
            that is how a company removes a clause it does not use.
    CompanyLogoUploadRequest:
      type: object
      required: [contentType, size]
      properties:
        contentType:
          type: string
          minLength: 1
          description: Must be one of the allowed logo image types (see shared upload-limits constants in @workspace/api-zod)
        size:
          type: integer
          minimum: 1
          maximum: 5242880
          description: File size in bytes; max 5 MB (MAX_LOGO_UPLOAD_BYTES)
    CompanyLogoUploadUrl:
      type: object
      required: [uploadURL, objectPath]
      properties:
        uploadURL: { type: string }
        objectPath: { type: string }
    CompanyLogoUpdate:
      type: object
      required: [objectPath]
      properties:
        objectPath:
          type: string
          minLength: 1
          description: The path returned by the upload-url call, or the raw upload URL.
    CompanyLogo:
      type: object
      required: [logoPath]
      properties:
        logoPath: { type: ["string", "null"] }
    OverheadCost:
      type: object
      required: [id, name, annualAmount, position, isActive]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        annualAmount: { type: number }
        position: { type: integer }
        isActive: { type: boolean }
    OverheadCostInput:
      type: object
      required: [name, annualAmount, position, isActive]
      properties:
        name: { type: string, minLength: 1 }
        annualAmount: { type: number, minimum: 0 }
        position: { type: integer, minimum: 0 }
        isActive: { type: boolean }
    TeamMember:
      type: object
      required: [userId, name, email, role, joinedAt]
      properties:
        userId: { type: string, format: uuid }
        name: { type: string }
        email: { type: string }
        phone: { type: ["string", "null"] }
        role: { type: string }
        joinedAt: { type: string, format: date-time }
    MemberRoleUpdate:
      type: object
      required: [role]
      properties:
        role: { type: string, enum: [owner, admin, estimator, viewer] }
    InvitationStatus:
      type: string
      enum: [pending, accepted, revoked, expired]
      description: >-
        Derived state. `expired` is never stored - it is what a `pending` row
        past its expiry reads as.
    Invitation:
      type: object
      required:
        [
          id,
          email,
          role,
          status,
          invitedByName,
          createdAt,
          expiresAt,
          acceptUrl,
        ]
      properties:
        id: { type: string, format: uuid }
        email: { type: string }
        role: { type: string }
        status: { $ref: "#/components/schemas/InvitationStatus" }
        invitedByName: { type: ["string", "null"] }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        acceptedAt: { type: ["string", "null"], format: date-time }
        revokedAt: { type: ["string", "null"], format: date-time }
        acceptUrl:
          type: ["string", "null"]
          description: >-
            Relative link the inviter sends to the invitee out of band (this
            deployment has no mail transport). Null once the invitation is no
            longer acceptable, and also null for a caller who cannot issue
            invitations - the link is a credential, not information.
    InvitationInput:
      type: object
      required: [email, role]
      properties:
        email: { type: string, format: email, minLength: 3, maxLength: 320 }
        role: { type: string, enum: [owner, admin, estimator, viewer] }
    InvitationAcceptInput:
      type: object
      required: [token]
      properties:
        token: { type: string, minLength: 1 }
    InvitationPreview:
      type: object
      required:
        [id, companyName, email, role, status, canAccept, expiresAt, token]
      properties:
        id: { type: string, format: uuid }
        companyName: { type: string }
        email:
          type: string
          description: The address the invitation was issued to.
        role: { type: string }
        status: { $ref: "#/components/schemas/InvitationStatus" }
        canAccept:
          type: boolean
          description: >-
            True only when the caller's e-mail matches and the invitation is
            pending and unexpired.
        blockedReason:
          type: ["string", "null"]
          description: Why `canAccept` is false, in words fit for the invitee.
        invitedByName: { type: ["string", "null"] }
        expiresAt: { type: string, format: date-time }
        token: { type: string }
    InvitationAcceptResult:
      type: object
      required: [companyId, companyName, role, hadOtherWorkspaces]
      properties:
        companyId: { type: string, format: uuid }
        companyName: { type: string }
        role: { type: string }
        hadOtherWorkspaces:
          type: boolean
          description: >-
            True when the invitee already belonged to at least one other
            company. Those memberships are kept; this company simply became the
            active one.
    Workspace:
      type: object
      required: [companyId, companyName, role, isActive, joinedAt]
      properties:
        companyId: { type: string, format: uuid }
        companyName: { type: string }
        role: { type: string }
        isActive:
          type: boolean
          description: >-
            Whether this is the workspace the caller's requests currently
            resolve to.
        joinedAt: { type: string, format: date-time }
