openapi: 3.1.0
info:
  title: Kungjort Handlingar API (lager 2)
  version: "1.1.0"
  description: |
    Typade, fulltext-sökbara svenska kommundokument (protokoll ner till paragraf, roster, beslut).
    Källa: offentliga kommunhandlingar. INTERNT — alla endpoints kräver Authorization: Bearer <API-nyckel>
    eller admin-session. Skapa nyckel under /keys.
servers:
  - url: https://handlingar.kungjort.se
security:
  - bearer: []
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
paths:
  /api/health:
    get:
      summary: Hälsokoll
      security: []
      responses: { "200": { description: ok } }
  /api/stats:
    get:
      summary: Översikt (antal dokument/§/personer, datumspann, doc_type- och status-fördelning)
      responses: { "200": { description: ok }, "401": { description: unauthorized } }
  /api/search:
    get:
      summary: Faceterad fulltext-sök över typade §-avsnitt
      parameters:
        - { name: q, in: query, schema: { type: string }, description: "fritext (svensk textsökning); utan q listas senaste besluten" }
        - { name: kommun, in: query, schema: { type: string }, description: "kommun-slug (facettens slug-fält)" }
        - { name: organ, in: query, schema: { type: string }, description: "organ-namn, skiftläges-okänslig matchning" }
        - { name: doc_type, in: query, schema: { type: string, enum: [protokoll, protokollsutdrag, kallelse, tjänsteskrivelse, kungörelse, yttrande, revisionsrapport, ekonomirapport, postlista, skannad, e-post, okänd] } }
        - { name: since, in: query, schema: { type: string, format: date } }
        - { name: until, in: query, schema: { type: string, format: date } }
        - { name: dnr, in: query, schema: { type: string } }
        - { name: person, in: query, schema: { type: string }, description: "roster-namn (vem satt med)" }
        - { name: sakarende, in: query, schema: { type: string, enum: ["1"] }, description: "bara sakärenden — OBJEKTIV kategori (§ med Dnr + substantiell rubrik, ej mötesformalia). newsworthy accepteras som legacy-alias" }
        - { name: limit, in: query, schema: { type: integer, maximum: 100, default: 30 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 }, description: "offset-paginering — UI:t hämtar 100 åt gången" }
      responses:
        "200": { description: "träffar (dokument-id, kommun, organ, datum, paragraf, rubrik, dnr, beslut_typ, sakarende, snippet) + facetter (doc_type; kommun som {slug, namn, n} — filtrera med slug)" }
        "401": { description: unauthorized }
  /api/documents/{id}:
    get:
      summary: Ett helt typat dokument (möteshuvud + roster + paragrafer + fil_url/arkiv_url)
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer }, description: "= arkivets document-id" }
        - { name: full, in: query, schema: { type: string, enum: ["1"] }, description: "även parserns rå-header (JSON), råtext och raw_md" }
      responses:
        "200": { description: "{ dokument, fil_url (same-origin-proxy), arkiv_url (arkivets direkta fil-URL), roster{beslutande,tjanstgorande_ersattare,ersattare,ovriga}, paragrafer[] }. Dokumentvyn har §-ankare: /documents/{id}#p45" }
        "404": { description: "okänt dokument" }
        "401": { description: unauthorized }
  /api/documents/{id}/original:
    get:
      summary: Originalfilen (PDF), proxad same-origin under denna apps auth
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200": { description: "filen (Content-Type från arkivet, normalt application/pdf)" }
        "401": { description: unauthorized }
        "502": { description: "originalet kunde inte hämtas från arkivet" }
  /api/documents/{id}/pages:
    get:
      summary: Originalets sidor renderade som PNG (granskningens vänsterpanel)
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: info, in: query, schema: { type: string, enum: ["1"] }, description: "returnera {pages: N} i stället för en bild" }
        - { name: n, in: query, schema: { type: integer, default: 1 }, description: "sidnummer (1-baserat)" }
      responses:
        "200": { description: "PNG av sidan, eller {pages} med ?info=1" }
        "401": { description: unauthorized }
        "502": { description: "rendering/hämtning misslyckades" }
  /api/beslut:
    get:
      summary: "Beslutsflödet: alla beslut nyast först, med objektiva filter (neutralt — ingen nyhetsvärdering)"
      parameters:
        - { name: since, in: query, schema: { type: string, format: date } }
        - { name: kommun, in: query, schema: { type: string } }
        - { name: organ, in: query, schema: { type: string }, description: "skiftläges-okänslig matchning" }
        - { name: votering, in: query, schema: { type: string, enum: ["1"] } }
        - { name: reservation, in: query, schema: { type: string, enum: ["1"] } }
        - { name: jav, in: query, schema: { type: string, enum: ["1"] } }
        - { name: beslut_typ, in: query, schema: { type: string }, description: "bifall | avslag | återremiss | bordläggning | noteras | …" }
        - { name: formalia, in: query, schema: { type: string, enum: ["1"] }, description: "inkludera även mötesformalia (utelämnas som default)" }
        - { name: limit, in: query, schema: { type: integer, maximum: 200, default: 50 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
      responses:
        "200": { description: "{ antal, limit, offset, poster[] } — varje post: typade §-fält + block_text (facit), original_url, dokument_api, arende_api" }
        "401": { description: unauthorized }
  /api/arenden:
    get:
      summary: "Ärendetråden: alla §-avsnitt som bär diarienumret, kronologiskt över möten/organ"
      parameters:
        - { name: dnr, in: query, required: true, schema: { type: string }, description: "exakt diarienummer, t.ex. 'KS 2025/123' (query-param eftersom Dnr innehåller '/')" }
        - { name: kommun, in: query, schema: { type: string } }
      responses:
        "200": { description: "{ dnr, kommun, antal, steg[] } — kronologiskt, med dokumentkontext + 600-teckens utdrag" }
        "400": { description: "dnr krävs" }
        "401": { description: unauthorized }
  /api/personer:
    get:
      summary: "Person-grävning: framträdanden i rosterdata (roll, parti, tjänstgöringar, jäv med §§)"
      parameters:
        - { name: namn, in: query, required: true, schema: { type: string, minLength: 3 }, description: "del av namn (ILIKE)" }
        - { name: kommun, in: query, schema: { type: string } }
      responses:
        "200": { description: "{ sok, personer[] } — grupperat per NAMN med partier[] aggregerade (parti = första belagda, bakåtkompatibilitet) + framtradanden[] och jav_ganger" }
        "400": { description: "namn (≥3 tecken) krävs" }
        "401": { description: unauthorized }
  /api/browse:
    get:
      summary: "Bläddringsdata: kommunöversikt (utan parametrar) eller en kommuns organ + mötestidslinje"
      parameters:
        - { name: kommun, in: query, schema: { type: string }, description: "kommun-slug; utelämnad → alla kommuner med täckning" }
      responses:
        "200": { description: "utan kommun: { kommuner[] } (dokument, organ, protokoll, senaste, sakarenden). Med kommun: { kommun, kommun_namn, organ[] (skiftläges-normaliserade namn), dokument[] (≤400, nyast först) }" }
        "401": { description: unauthorized }
  /api/ingest:
    get:
      summary: Ingest-status (antal dokument per parse_status/doc_type)
      responses: { "200": { description: ok }, "401": { description: unauthorized } }
    post:
      summary: "Upsert parsad(e) dokument (parse-worker). Body: {meta,parsed,text} eller {docs:[...]}"
      responses: { "200": { description: "{ ingested, failed[] }" }, "401": { description: unauthorized } }
  /api/admin:
    get:
      summary: "Lista dokument för granskning (admin-session). ?status=needs_review|parsed|approved|flagged"
      responses: { "200": { description: "{ total, counts[], documents[] }" }, "403": { description: "kräver admin-inloggning" } }
    post:
      summary: "Sätt parse_status + review_note (admin-session). Body: { id, status, note? }"
      responses: { "200": { description: "{ ok, updated }" }, "403": { description: "kräver admin-inloggning" } }
