openapi: 3.0.3
info:
  title: JobSeal API
  version: '1'
  description: |
    JobSeal's public-facing API for candidate profiles, employer search,
    and verification orders.

    **Base URL (production):** configured per environment via `NEXT_PUBLIC_API_URL`

    **Authentication:** All endpoints except `GET /candidates/by-refid/{refid}`
    require a Bearer token. Candidates and employers use separate tokens issued
    at registration or sign-in.

    **Data location:** All data is stored in Australia (AWS ap-southeast-2).

  contact:
    email: support@jobseal.co
  license:
    name: Proprietary

servers:
  - url: http://localhost:3001
    description: Local development (dev-runner)

tags:
  - name: Public
    description: Unauthenticated endpoints
  - name: Candidates
    description: Candidate-authenticated endpoints
  - name: Employers
    description: Employer-authenticated endpoints
  - name: Search
    description: Candidate search (employer-authenticated, subscription required)

paths:
  # ─── Public ──────────────────────────────────────────────────────────────────

  /candidates/by-refid/{refid}:
    get:
      tags: [Public]
      operationId: getPublicProfile
      summary: Get public candidate profile
      description: |
        Returns a candidate's verified employment history by their REFID.
        No authentication required. The REFID is the candidate's public identifier
        — enumeration is infeasible (10^19 space for a 16-char base-32 REFID).

        Only returns roles where `verified=true`, `disputed=false`, and a
        `roleScore` has been computed. Raw referee survey responses are never
        stored (ADR-005) — only computed dimension averages are returned.
      parameters:
        - name: refid
          in: path
          required: true
          schema:
            type: string
            example: 'A3K9PMQZXR7BN2L4'
      responses:
        '200':
          description: Public profile
          headers:
            Cache-Control:
              schema:
                type: string
                example: 'public, max-age=60, stale-while-revalidate=300'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicProfile'
        '404':
          $ref: '#/components/responses/NotFound'

  # ─── Candidates ──────────────────────────────────────────────────────────────

  /candidates:
    post:
      tags: [Candidates]
      operationId: registerCandidate
      summary: Register a new candidate
      description: |
        Creates a new candidate account. Email and phone must be unique across
        all candidates. Phone is required from R-0.14; must be a valid AU mobile
        or international E.164 number.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, phone]
              properties:
                email:
                  type: string
                  format: email
                  example: 'alice@example.com'
                phone:
                  type: string
                  description: AU mobile (04xx xxx xxx) or E.164 (+61...)
                  example: '0412 345 678'
                name:
                  type: string
                  example: 'Alice Smith'
      responses:
        '201':
          description: Registration successful
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthResult'
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '409':
          description: Email or phone already registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    enum: [already_registered_email, already_registered_phone]

  /candidates/sign-in:
    post:
      tags: [Candidates]
      operationId: signInCandidate
      summary: Sign in (dev/local only)
      description: |
        Email-only sign-in for local development. Not available in production —
        production sign-in goes through Cognito Hosted UI.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        '200':
          description: Token issued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthResult'
        '404':
          $ref: '#/components/responses/NotFound'

  /candidates/{id}:
    get:
      tags: [Candidates]
      operationId: getCandidate
      summary: Get candidate profile (owner view)
      description: |
        Full candidate profile including encrypted PII (decrypted server-side),
        all employment records, and stealth/exclusion settings.
        Only the authenticated candidate can access their own profile.
      security:
        - candidateBearer: []
      parameters:
        - $ref: '#/components/parameters/candidateId'
      responses:
        '200':
          description: Candidate profile
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CandidateProfile'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /candidates/{id}/stealth:
    patch:
      tags: [Candidates]
      operationId: updateStealth
      summary: Toggle stealth mode
      description: |
        When stealth is enabled, the candidate is hidden from all employer
        searches. The public REFID page remains accessible.
      security:
        - candidateBearer: []
      parameters:
        - $ref: '#/components/parameters/candidateId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [stealth]
              properties:
                stealth:
                  type: boolean
      responses:
        '200':
          description: Stealth updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  stealth:
                    type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /candidates/{id}/employer-exclusions:
    get:
      tags: [Candidates]
      operationId: getExclusions
      summary: List employer domain exclusions
      security:
        - candidateBearer: []
      parameters:
        - $ref: '#/components/parameters/candidateId'
      responses:
        '200':
          description: List of excluded domains
          content:
            application/json:
              schema:
                type: object
                properties:
                  domains:
                    type: array
                    items:
                      type: string
                    example: ['acme.com', 'bigcorp.com.au']
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [Candidates]
      operationId: addExclusion
      summary: Add an employer domain exclusion
      description: |
        Prevents employers whose email domain matches from finding this
        candidate in search results.
      security:
        - candidateBearer: []
      parameters:
        - $ref: '#/components/parameters/candidateId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain:
                  type: string
                  description: Employer email domain (e.g. acme.com)
                  example: 'acme.com'
      responses:
        '201':
          description: Exclusion added
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain:
                    type: string
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Domain already excluded

  /candidates/{id}/employer-exclusions/{domain}:
    delete:
      tags: [Candidates]
      operationId: removeExclusion
      summary: Remove an employer domain exclusion
      security:
        - candidateBearer: []
      parameters:
        - $ref: '#/components/parameters/candidateId'
        - name: domain
          in: path
          required: true
          schema:
            type: string
            example: 'acme.com'
      responses:
        '204':
          description: Exclusion removed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /candidates/{id}/export:
    get:
      tags: [Candidates]
      operationId: exportMyData
      summary: Export personal data (Privacy Act)
      description: |
        Returns all personal data held by JobSeal about the authenticated
        candidate, as a JSON file download. Satisfies Australian Privacy Act
        subject access requests.

        Raw referee survey responses are never included because they are never
        stored (ADR-005) — only computed dimension averages appear in the export.
      security:
        - candidateBearer: []
      parameters:
        - $ref: '#/components/parameters/candidateId'
      responses:
        '200':
          description: JSON data export
          headers:
            Content-Disposition:
              schema:
                type: string
                example: 'attachment; filename="jobseal-export-2024-01-15.json"'
          content:
            application/json:
              schema:
                type: object
                properties:
                  exportedAt:
                    type: string
                    format: date-time
                  exportNote:
                    type: string
                  candidate:
                    type: object
                  employmentRecords:
                    type: array
                    items:
                      type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /candidates/{id}/employment-records:
    post:
      tags: [Candidates]
      operationId: createEmploymentRecord
      summary: Add an employment record
      security:
        - candidateBearer: []
      parameters:
        - $ref: '#/components/parameters/candidateId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmploymentRecordInput'
      responses:
        '201':
          description: Record created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /employment-records/{rid}:
    patch:
      tags: [Candidates]
      operationId: updateEmploymentRecord
      summary: Update an employment record
      description: Only allowed when the role is unverified with no active nomination.
      security:
        - candidateBearer: []
      parameters:
        - $ref: '#/components/parameters/rid'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmploymentRecordInput'
      responses:
        '200':
          description: Updated
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Role is already verified or has active nominations

    delete:
      tags: [Candidates]
      operationId: deleteEmploymentRecord
      summary: Soft-delete an employment record
      description: Only allowed when the role is unverified with no active nomination.
      security:
        - candidateBearer: []
      parameters:
        - $ref: '#/components/parameters/rid'
      responses:
        '204':
          description: Deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Role is already verified or has active nominations

  /employment-records/{rid}/referees:
    post:
      tags: [Candidates]
      operationId: nominateReferee
      summary: Nominate referee + HR contact
      description: |
        Nominate a line-manager referee (who completes the BAES survey) and
        an HR cross-validator (who attests to the employment claim). Both are
        required unless HR has already confirmed the role, in which case use
        the referee-only variant.

        Triggers outbound emails to both contacts via the verification service.
      security:
        - candidateBearer: []
      parameters:
        - $ref: '#/components/parameters/rid'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NominateRefereeInput'
      responses:
        '201':
          description: Nominated
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  crossValidationId:
                    type: string
                    format: uuid
                    nullable: true
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Active nomination already exists

  /employment-records/{rid}/cancel-nominations:
    post:
      tags: [Candidates]
      operationId: cancelNominations
      summary: Cancel active nominations for a role
      description: |
        Cancels pending referee and/or HR nominations. Required before
        re-nominating different contacts.
      security:
        - candidateBearer: []
      parameters:
        - $ref: '#/components/parameters/rid'
      responses:
        '200':
          description: Cancelled
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /employment-records/{rid}/dispute-statement:
    post:
      tags: [Candidates]
      operationId: submitDisputeStatement
      summary: Submit a candidate dispute statement
      description: |
        Attaches the candidate's written statement to a disputed employment
        record. Available after an admin has marked the role disputed.
        Can be submitted or updated at any time while the role remains disputed.
      security:
        - candidateBearer: []
      parameters:
        - $ref: '#/components/parameters/rid'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [statement]
              properties:
                statement:
                  type: string
                  maxLength: 5000
      responses:
        '200':
          description: Statement saved
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Role is not disputed

  # ─── Employers ───────────────────────────────────────────────────────────────

  /employers:
    post:
      tags: [Employers]
      operationId: registerEmployer
      summary: Register a new employer account
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, legal_name]
              properties:
                email:
                  type: string
                  format: email
                legal_name:
                  type: string
                  example: 'Acme Recruiting Pty Ltd'
                abn:
                  type: string
                  pattern: "^\\d{11}$"
                  description: 11-digit Australian Business Number
                  example: '12345678901'
      responses:
        '201':
          description: Registration successful
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmployerAuthResult'
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '409':
          description: Email already registered

  /employers/sign-in:
    post:
      tags: [Employers]
      operationId: signInEmployer
      summary: Sign in (dev/local only)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        '200':
          description: Token issued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmployerAuthResult'

  /employers/me:
    get:
      tags: [Employers]
      operationId: getEmployerMe
      summary: Get authenticated employer's profile
      security:
        - employerBearer: []
      responses:
        '200':
          description: Employer profile + subscription state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmployerProfile'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /employers/checkout:
    post:
      tags: [Employers]
      operationId: createCheckout
      summary: Create a Stripe Checkout session
      description: |
        Initiates a Stripe-hosted Checkout session to subscribe to a tier.
        Returns a hosted URL; the caller 303-redirects the browser there.
        Subscription state arrives via Stripe webhooks after payment.
      security:
        - employerBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tier]
              properties:
                tier:
                  type: string
                  enum: [recruiter, agency]
      responses:
        '200':
          description: Checkout URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  url:
                    type: string
                    format: uri
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Already subscribed

  # ─── Search ──────────────────────────────────────────────────────────────────

  /search/candidates:
    post:
      tags: [Search]
      operationId: searchCandidates
      summary: Full-text candidate search
      description: |
        Full-text search over verified, non-disputed employment records using
        Postgres `websearch_to_tsquery`. Supports operators: quoted phrases,
        OR, -negation.

        Results are ranked by match quality, then by career score. Returns
        REFID-keyed candidate summaries — no PII is returned (same surface
        as the public REFID page, plus a role count and up to 3 matching roles
        per candidate).

        **Requires an active subscription** (`subscription_status=active` and
        `subscription_tier != none`). Returns 402 otherwise.

        **Stealth candidates and candidates who have excluded the employer's
        domain are automatically filtered from results.**
      security:
        - employerBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [q]
              properties:
                q:
                  type: string
                  minLength: 1
                  maxLength: 200
                  description: Full-text query string
                  example: 'senior software engineer'
                page:
                  type: integer
                  minimum: 0
                  maximum: 500
                  default: 0
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchResult'
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: Subscription required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    enum: [subscription_required]
                  currentTier:
                    type: string
                  currentStatus:
                    type: string

# ─── Components ──────────────────────────────────────────────────────────────

components:
  securitySchemes:
    candidateBearer:
      type: http
      scheme: bearer
      description: JWT issued by Cognito (or stub token in local dev)
    employerBearer:
      type: http
      scheme: bearer
      description: JWT issued by Cognito (or stub token in local dev)

  parameters:
    candidateId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    rid:
      name: rid
      in: path
      required: true
      description: Employment record ID
      schema:
        type: string
        format: uuid

  responses:
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                enum: [not_found]
    Unauthorized:
      description: Missing or invalid Bearer token
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                enum: [unauthorized]
    Forbidden:
      description: Authenticated but not authorised for this resource
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                enum: [forbidden]
    ValidationFailed:
      description: Request body failed validation
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                enum: [validation_failed]
              detail:
                type: object

  schemas:
    AuthResult:
      type: object
      properties:
        id:
          type: string
          format: uuid
        refid:
          type: string
          description: Candidate's public REFID
        token:
          type: string
          description: Bearer token for subsequent requests

    EmployerAuthResult:
      type: object
      properties:
        id:
          type: string
          format: uuid
        token:
          type: string

    PublicProfile:
      type: object
      properties:
        refid:
          type: string
        name:
          type: string
          nullable: true
        careerScore:
          type: number
          nullable: true
          minimum: 0
          maximum: 10
        careerScoreUpdatedAt:
          type: string
          format: date-time
          nullable: true
        roles:
          type: array
          items:
            $ref: '#/components/schemas/PublicRole'

    PublicRole:
      type: object
      properties:
        employerName:
          type: string
        roleTitle:
          type: string
        startDate:
          type: string
          format: date
        endDate:
          type: string
          format: date
          nullable: true
        roleScore:
          type: number
          nullable: true
          minimum: 0
          maximum: 10
        crossVerified:
          type: boolean
          description: HR cross-validator explicitly confirmed the employment claim
        dimensions:
          $ref: '#/components/schemas/Dimensions'

    CandidateProfile:
      type: object
      properties:
        id:
          type: string
          format: uuid
        refid:
          type: string
        email:
          type: string
          format: email
        name:
          type: string
          nullable: true
        careerScore:
          type: number
          nullable: true
        careerScoreUpdatedAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time
        stealth:
          type: boolean
        employerExclusions:
          type: array
          items:
            type: string
          description: Email domains blocked from search results
        roles:
          type: array
          items:
            $ref: '#/components/schemas/OwnerRole'

    OwnerRole:
      type: object
      properties:
        id:
          type: string
          format: uuid
        employerName:
          type: string
        roleTitle:
          type: string
        startDate:
          type: string
          format: date
        endDate:
          type: string
          format: date
          nullable: true
        verified:
          type: boolean
        disputed:
          type: boolean
        roleScore:
          type: number
          nullable: true
        dimensions:
          $ref: '#/components/schemas/Dimensions'

    Dimensions:
      type: object
      description: |
        BAES dimension averages. Each is the mean across all referee responses
        for that dimension — null until at least one referee has completed a
        survey. Raw responses are never stored (ADR-005).
      properties:
        delivery:
          type: number
          nullable: true
          minimum: 0
          maximum: 10
        expertise:
          type: number
          nullable: true
          minimum: 0
          maximum: 10
        conduct:
          type: number
          nullable: true
          minimum: 0
          maximum: 10
        reengagement:
          type: number
          nullable: true
          minimum: 0
          maximum: 10

    EmployerProfile:
      type: object
      properties:
        id:
          type: string
          format: uuid
        legalName:
          type: string
        abn:
          type: string
          nullable: true
        email:
          type: string
          format: email
        subscriptionTier:
          type: string
          enum: [none, recruiter, agency]
        subscriptionStatus:
          type: string
          enum: [inactive, active, past_due, cancelled]
        checksUsedThisPeriod:
          type: integer
        checksLimitThisPeriod:
          type: integer
          description: Monthly check limit for the current tier
        createdAt:
          type: string
          format: date-time

    EmploymentRecordInput:
      type: object
      required: [employer_name, role_title, start_date]
      properties:
        employer_name:
          type: string
          maxLength: 255
        employer_abn:
          type: string
          pattern: "^\\d{11}$"
        role_title:
          type: string
          maxLength: 255
        role_type:
          type: string
          maxLength: 64
          default: default
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
          nullable: true

    NominateRefereeInput:
      type: object
      required: [email, name, relationship, hr]
      properties:
        email:
          type: string
          format: email
          description: Referee (line manager) email
        name:
          type: string
          description: Referee's full name
        role_title:
          type: string
          description: Referee's role title at the time of employment
        relationship:
          type: string
          enum: [manager, peer, report, client, other]
        hr:
          type: object
          required: [email, name]
          properties:
            email:
              type: string
              format: email
              description: HR contact email (must differ from referee email)
            name:
              type: string
            role_title:
              type: string

    SearchResult:
      type: object
      properties:
        results:
          type: array
          items:
            $ref: '#/components/schemas/SearchCandidate'
        total:
          type: integer
          description: Total matching candidates (for pagination)
        page:
          type: integer
        pageSize:
          type: integer
          example: 20

    SearchCandidate:
      type: object
      description: |
        Candidate search result. Contains only non-PII fields — the same
        data the public REFID page exposes, plus aggregates useful for an
        employer's click-through decision.
      properties:
        refid:
          type: string
          description: |
            Public identifier. Pass to GET /candidates/by-refid/{refid} to
            fetch the full public profile, or to POST /verification-orders to
            commission a background check.
        careerScore:
          type: number
          nullable: true
          minimum: 0
          maximum: 10
        verifiedRoleCount:
          type: integer
          description: Count of all verified, non-disputed roles
        matchingRoles:
          type: array
          maxItems: 3
          description: Up to 3 most-recent roles that matched the query
          items:
            type: object
            properties:
              employerName:
                type: string
              roleTitle:
                type: string
              roleType:
                type: string
              startDate:
                type: string
                format: date
              endDate:
                type: string
                format: date
                nullable: true
              crossVerified:
                type: boolean
