openapi: 3.1.0

info:
  title: 2hr API
  version: "1.0"
  description: |
    REST API for publishing and managing job listings on the 2hr platform.

    **Authentication:** All endpoints require a Bearer token in the `Authorization` header.
    Tokens are issued after manual account verification (1-2 business days).

    **Rate limit:** 10 requests per token per 60-second window.

servers:
  - url: https://api.2hr.pl
    description: Production

security:
  - BearerToken: []

tags:
  - name: Companies
    description: Manage hiring organisations linked to job listings
  - name: Jobs
    description: Create and manage job listings

paths:

  # ─── COMPANIES ────────────────────────────────────────────────────────────

  /api/v1/companies:

    get:
      tags: [Companies]
      summary: List companies
      description: Returns all companies owned by your account, ordered by creation date descending.
      operationId: listCompanies
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Company"
                  meta:
                    $ref: "#/components/schemas/Meta"
              example:
                data:
                  - uuid: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11
                    name: Example Company Sp. z o.o.
                    description: Tech company based in Warsaw.
                    website: https://example-company.com
                    logo_url: https://example-company.com/logo.png
                    created_at: "2026-02-28T10:00:00+00:00"
                    updated_at: "2026-02-28T10:00:00+00:00"
                meta:
                  total: 1
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

    post:
      tags: [Companies]
      summary: Create company
      description: Creates a new company linked to your account. Returns 201 with the created company.
      operationId: createCompany
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CompanyInput"
            example:
              name: Example Company Sp. z o.o.
              description: Tech company based in Warsaw.
              website: https://example-company.com
              logo_url: https://example-company.com/logo.png
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Company"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /api/v1/companies/{uuid}:

    put:
      tags: [Companies]
      summary: Update company
      description: |
        Updates a company owned by your account.
        All fields are replaced - omitted optional fields are set to `null`.
        Attempting to update another account's company returns 404.
      operationId: updateCompany
      parameters:
        - $ref: "#/components/parameters/UuidPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CompanyInput"
            example:
              name: Example Company S.A.
              website: https://example-company.com
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Company"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  # ─── JOBS ─────────────────────────────────────────────────────────────────

  /api/v1/jobs:

    get:
      tags: [Jobs]
      summary: List jobs
      description: |
        Returns all published jobs owned by your account, ordered by `updated_at` descending.
        Jobs in status `pending`, `requires_review`, `rejected`, or `failed` are not included.
        Maximum 20 results.
      operationId: listJobs
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Job"
                  meta:
                    $ref: "#/components/schemas/Meta"
              example:
                data:
                  - uuid: f47ac10b-58cc-4372-a567-0e02b2c3d479
                    company:
                      uuid: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11
                      name: Example Company Sp. z o.o.
                      description: Tech company based in Warsaw.
                      website: https://example-company.com
                      logo_url: https://example-company.com/logo.png
                      created_at: "2026-02-28T10:00:00+00:00"
                      updated_at: "2026-02-28T10:00:00+00:00"
                    title: Senior PHP Developer
                    location: Warszawa
                    snippet: We are looking for an experienced PHP developer...
                    salary:
                      type: monthly
                      min: 12000
                      max: 18000
                      currency: PLN
                    link: https://example-company.com/jobs/php
                    source: null
                    type: full-time
                    status: published
                    created_at: "2026-02-28T10:00:00+00:00"
                    updated_at: "2026-02-28T10:05:00+00:00"
                meta:
                  total: 1
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

    post:
      tags: [Jobs]
      summary: Create job (async)
      description: |
        Accepts the job and queues it for async processing. Returns 202 immediately.

        Once processed the job transitions to `published` (verified accounts) or
        `requires_review` (unverified accounts). Only verified accounts can create jobs -
        unverified accounts receive 403.
      operationId: createJob
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/JobInput"
            example:
              title: Senior PHP Developer
              location: Warszawa
              snippet: We are looking for an experienced PHP developer...
              link: https://example-company.com/jobs/php
              company_uuid: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11
              salary:
                type: monthly
                min: 12000
                max: 18000
                currency: PLN
              type: full-time
      responses:
        "202":
          description: Accepted - job queued for processing
          content:
            application/json:
              schema:
                type: object
                properties:
                  uuid:
                    type: string
                    format: uuid
                  status:
                    type: string
                    example: pending
              example:
                uuid: f47ac10b-58cc-4372-a567-0e02b2c3d479
                status: pending
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /api/v1/jobs/{uuid}:

    put:
      tags: [Jobs]
      summary: Update job
      description: |
        Updates the content of an existing job. Accepts the same fields as POST.
        Only jobs owned by your account can be updated.
        Jobs with status `ended` cannot be updated (returns 409).
      operationId: updateJob
      parameters:
        - $ref: "#/components/parameters/UuidPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/JobInput"
            example:
              title: Lead PHP Developer
              location: Warszawa / Remote
              snippet: Updated description...
              link: https://example-company.com/jobs/php
              salary:
                type: monthly
                min: 15000
                max: 22000
                currency: PLN
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Job"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /api/v1/jobs/{uuid}/status:

    put:
      tags: [Jobs]
      summary: Change job status
      description: |
        Transitions a job to a new status. Only the following transitions are allowed
        via the API:
        - `published` → `ended`

        Invalid transitions are rejected with 422.
      operationId: changeJobStatus
      parameters:
        - $ref: "#/components/parameters/UuidPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [published, ended]
            example:
              status: ended
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      uuid:
                        type: string
                        format: uuid
                      status:
                        $ref: "#/components/schemas/JobStatus"
                      updated_at:
                        type: string
                        format: date-time
              example:
                data:
                  uuid: f47ac10b-58cc-4372-a567-0e02b2c3d479
                  status: ended
                  updated_at: "2026-02-28T12:00:00+00:00"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

# ─── COMPONENTS ───────────────────────────────────────────────────────────────

components:

  securitySchemes:
    BearerToken:
      type: http
      scheme: bearer
      description: |
        64-character hex token (256-bit entropy) issued after account verification.
        The raw token is never stored on the server - keep it secret.

  parameters:
    UuidPath:
      name: uuid
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: UUID v4 of the resource
      example: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11

  schemas:

    Company:
      type: object
      properties:
        uuid:
          type: string
          format: uuid
          description: Public unique identifier.
        name:
          type: string
          maxLength: 255
        description:
          type: ["string", "null"]
        website:
          type: ["string", "null"]
          format: uri
          maxLength: 500
        logo_url:
          type: ["string", "null"]
          format: uri
          maxLength: 500
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    CompanyInput:
      type: object
      required: [name]
      properties:
        name:
          type: string
          maxLength: 255
          description: Company name.
        description:
          type: string
          description: Short company description.
        website:
          type: string
          format: uri
          maxLength: 500
          description: Company website URL.
        logo_url:
          type: string
          format: uri
          maxLength: 500
          description: Logo image URL.

    Job:
      type: object
      properties:
        uuid:
          type: string
          format: uuid
        company:
          oneOf:
            - $ref: "#/components/schemas/Company"
            - type: "null"
          description: Embedded company data, or null if no company is linked.
        title:
          type: string
        location:
          type: string
        snippet:
          type: string
        salary:
          $ref: "#/components/schemas/Salary"
        link:
          type: string
          format: uri
        source:
          type: ["string", "null"]
          description: Traffic source label (e.g. "linkedin").
        type:
          type: ["string", "null"]
          description: Employment type (e.g. "full-time", "contract").
        status:
          $ref: "#/components/schemas/JobStatus"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    JobInput:
      type: object
      required: [title, location, snippet, link, salary]
      properties:
        title:
          type: string
          maxLength: 255
          description: Job title.
        location:
          type: string
          maxLength: 255
          description: Job location (city or "Remote").
        snippet:
          type: string
          description: Short job description / teaser text.
        link:
          type: string
          format: uri
          maxLength: 500
          description: URL of the recruitment / application form.
        salary:
          $ref: "#/components/schemas/Salary"
        company_uuid:
          type: string
          format: uuid
          description: UUID of the company to associate with this job.
        source:
          type: string
          description: Traffic source label (e.g. "linkedin").
        type:
          type: string
          description: Employment type (e.g. "full-time", "contract").

    Salary:
      type: object
      required: [type, min, max, currency]
      properties:
        type:
          type: string
          enum: [monthly, hourly]
          description: Pay period.
        min:
          type: integer
          minimum: 0
          description: Minimum salary. Must be <= max.
        max:
          type: integer
          minimum: 0
          description: Maximum salary. Must be >= min.
        currency:
          type: string
          pattern: "^[A-Z]{3}$"
          description: ISO 4217 currency code - 3 uppercase letters (e.g. PLN, EUR, USD).

    JobStatus:
      type: string
      enum: [pending, published, ended, failed, requires_review, rejected]
      description: |
        Current job status.
        - `pending` - queued, processing not yet complete
        - `published` - live and visible
        - `ended` - manually closed (terminal)
        - `failed` - processing error (terminal)
        - `requires_review` - awaiting moderator approval
        - `rejected` - rejected by moderator (terminal)

    Meta:
      type: object
      properties:
        total:
          type: integer
          description: Total number of items.

    Error:
      type: object
      properties:
        error:
          type: string
          description: Human-readable error description.
        message:
          type: string
          description: Additional detail (present on auth errors).

  responses:

    BadRequest:
      description: Malformed request - body is not valid JSON or Content-Type header is missing.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "Bad Request" }

    Unauthorized:
      description: Missing, malformed, or invalid Bearer token.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            missing:
              value: { error: "Unauthorized", message: "Bearer token required." }
            invalid:
              value: { error: "Unauthorized", message: "Invalid token." }

    Forbidden:
      description: Access denied - resource belongs to a different account, or account is not verified.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "Forbidden" }

    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "Not Found" }

    Conflict:
      description: Conflict - e.g. attempting to update a job with status "ended".
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "Conflict" }

    UnprocessableEntity:
      description: Validation failed - missing field, invalid URL, salary min > max, bad currency, or illegal status transition.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "Field \"salary.min\" cannot be greater than \"salary.max\"." }

    TooManyRequests:
      description: Rate limit exceeded - more than 10 requests in the current 60-second window.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds until the rate limit window resets.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "Too Many Requests", retry_after: 42 }
