diff --git a/docs/DRIFT.md b/docs/DRIFT.md index 294552f..3492fe8 100644 --- a/docs/DRIFT.md +++ b/docs/DRIFT.md @@ -50,7 +50,8 @@ kubectl apply -k infra/k8s # 4. Verifiera kubectl -n guidad-felsokning get pods -curl https://app.exempel.se/halsa # → {"status":"ok"} (plattformen) +curl https://app.exempel.se/halsa # → {"status":"ok"} (plattformen) +curl https://app.exempel.se/api/openapi.yaml # API-first: hela API-specen ``` Byt domän och cert-issuer i `infra/k8s/ingress.yaml`. Att skapa nya organisationer är öppet i beta — stäng med `REGISTRERING_OPPEN=false` på plattformens Deployment; användare inom en organisation skapas alltid av dess systemadministratör. diff --git a/docs/MVP.md b/docs/MVP.md index 9fa1881..f4b337d 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -33,7 +33,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | Live Share | ✅ Skrivskyddad livevy per ärende (`/felsokning/dela/:id`): status ✔/🔄/⏳, bilder, mätvärdestabell, tidslinje, rekommenderat nästa steg. Uppdateras automatiskt, interna poster filtreras bort. Publik delningssida (`/felsokning/delad/:kod`) läser via `hamta_delat_arende` utan inloggning och pollar för liveuppdatering; "Kopiera delningslänk" finns i rapportfliken. Behörighetsnivåer (kund/intern/partner) i nästa fas. | | Dashboard | ✅ Enligt direktivet: räknare och filter för Alla/Pågående/Klara plus Starta nytt ärende. | | Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. | -| Öppet API | 🔶 Datamodellen är API-klar (händelser som JSON i Postgres). REST/OpenAPI-lager i nästa fas. | +| Öppet API | ✅ Plattforms-API:t är dokumenterat med OpenAPI 3.0 (`services/plattform/openapi.yaml`) — auth, användare, ärenden/händelser (append-only), översikt, publik delning och AI-orkestern, med scheman för alla händelsetyper. Specen valideras maskinellt, paritetstestas mot serverns rutter och serveras live på `GET /api/openapi.yaml`. | ## Arkitekturprinciper i koden @@ -47,4 +47,3 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st - Omfördelning av ärenden (arbetsledare) är inte implementerad — överlämningshändelsen täcker handover tills en ansvarig-modell införs. - QR/streckkod/OCR för objektidentifiering, bildanalys och tillverkarintegrationer ingår inte ännu. - Live Share-behörighetsnivåerna (kund/intern/partner) är specificerade men inte byggda — dagens delningslänk motsvarar kundnivån. -- OpenAPI-dokumentation av plattforms-API:t återstår (API-first-kravet). diff --git a/services/plattform/Dockerfile b/services/plattform/Dockerfile index 4e285e2..04ea18b 100644 --- a/services/plattform/Dockerfile +++ b/services/plattform/Dockerfile @@ -4,7 +4,7 @@ FROM node:22-alpine WORKDIR /app COPY package.json ./ RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force -COPY server.mjs ./ +COPY server.mjs openapi.yaml ./ ENV NODE_ENV=production PORT=8080 USER node diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh index 807bc0d..18d183d 100755 --- a/services/plattform/integrationstest.sh +++ b/services/plattform/integrationstest.sh @@ -112,4 +112,11 @@ kontroll "översikten härleder felbeskrivning" "$(echo "$OVERSIKT" | falt '.are kontroll "översikten härleder status" "$(echo "$OVERSIKT" | falt '.arenden[0].avslutat')" "false" kontroll "översikten räknar händelser" "$(echo "$OVERSIKT" | falt '.arenden[0].antal_handelser')" "2" +# 10. API-first: OpenAPI-specen serveras live, utan inloggning +SPEC=$(curl -s "$BAS/api/openapi.yaml") +case "$SPEC" in + "openapi: 3.0.3"*) echo "✓ OpenAPI-specen serveras på /api/openapi.yaml" ;; + *) echo "✗ OpenAPI-specen saknas"; exit 1 ;; +esac + echo "Integrationstest: allt grönt" diff --git a/services/plattform/openapi.yaml b/services/plattform/openapi.yaml new file mode 100644 index 0000000..0f811f0 --- /dev/null +++ b/services/plattform/openapi.yaml @@ -0,0 +1,465 @@ +openapi: 3.0.3 +info: + title: Guidad Felsökning – plattforms-API + version: 1.0.0 + description: | + API-first-ytan för Guidad Felsökning: autentisering, organisationens + användare, ärenden med append-only händelselogg, arbetsledaröversikt, + publik Live Share-delning samt AI-orkestern. + + **Händelseloggen är append-only.** Det finns inga update- eller + delete-operationer — historik kan aldrig ändras eller raderas, vilket + även garanteras av databastriggers. En händelse med redan känt `id` + ignoreras tyst (idempotent synk). + + **Multi-tenant.** All ärendedata är organisationsknuten. Ett ärende i + en annan organisation ger `404`, som om det inte fanns. + + Specen serveras live av tjänsten på `GET /api/openapi.yaml`. +servers: + - url: https://app.exempel.se + description: Klustrets ingress (ersätt med er domän) +tags: + - name: Auth + - name: Användare + - name: Ärenden + - name: Översikt + - name: Delning + - name: AI + - name: Drift + +paths: + /halsa: + get: + tags: [Drift] + summary: Hälsokontroll + security: [] + responses: + "200": + description: Tjänsten svarar. + content: + application/json: + schema: + type: object + properties: + status: { type: string, example: ok } + + /api/openapi.yaml: + get: + tags: [Drift] + summary: Denna specifikation + security: [] + responses: + "200": + description: OpenAPI-specen i YAML. + content: + application/yaml: + schema: { type: string } + + /api/auth/registrera: + post: + tags: [Auth] + summary: Skapa organisation + systemadministratör + description: > + Registrerar en ny organisation (tenant) och dess första användare, + som blir systemadministratör. Kan stängas av driften + (REGISTRERING_OPPEN=false → 403). + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [epost, losenord, namn, organisation] + properties: + epost: { type: string, format: email } + losenord: { type: string, minLength: 8 } + namn: { type: string } + organisation: { type: string } + responses: + "200": { $ref: "#/components/responses/Inloggad" } + "400": { $ref: "#/components/responses/Fel" } + "403": { $ref: "#/components/responses/Fel" } + "409": { $ref: "#/components/responses/Fel" } + + /api/auth/logga-in: + post: + tags: [Auth] + summary: Logga in + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [epost, losenord] + properties: + epost: { type: string, format: email } + losenord: { type: string } + responses: + "200": { $ref: "#/components/responses/Inloggad" } + "401": { $ref: "#/components/responses/Fel" } + + /api/anvandare: + get: + tags: [Användare] + summary: Lista organisationens användare + description: Kräver rollen `admin`. + responses: + "200": + description: Användare i den egna organisationen. + content: + application/json: + schema: + type: object + properties: + anvandare: + type: array + items: { $ref: "#/components/schemas/Anvandare" } + "401": { $ref: "#/components/responses/Fel" } + "403": { $ref: "#/components/responses/Fel" } + post: + tags: [Användare] + summary: Skapa användare i den egna organisationen + description: Kräver rollen `admin`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [epost, losenord, namn, roll] + properties: + epost: { type: string, format: email } + losenord: { type: string, minLength: 8 } + namn: { type: string } + roll: { $ref: "#/components/schemas/Roll" } + responses: + "200": + description: Den skapade användaren. + content: + application/json: + schema: { $ref: "#/components/schemas/Anvandare" } + "400": { $ref: "#/components/responses/Fel" } + "401": { $ref: "#/components/responses/Fel" } + "403": { $ref: "#/components/responses/Fel" } + "409": { $ref: "#/components/responses/Fel" } + + /api/arenden: + get: + tags: [Ärenden] + summary: Lista organisationens ärenden + responses: + "200": + description: Ärenden (senaste 200). + content: + application/json: + schema: + type: object + properties: + arenden: + type: array + items: { $ref: "#/components/schemas/Arende" } + "401": { $ref: "#/components/responses/Fel" } + post: + tags: [Ärenden] + summary: Registrera ett ärende (idempotent) + description: > + Skapar ärendet i användarens organisation. Ett redan känt `id` + ignoreras tyst — ärenderaden ändras aldrig. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, nummer, skapad] + properties: + id: { type: string } + nummer: { type: integer } + skapad: { type: string, format: date-time } + delningskod: { type: string, nullable: true } + metodikId: { type: string, nullable: true } + responses: + "200": + description: Mottaget. + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + "400": { $ref: "#/components/responses/Fel" } + "401": { $ref: "#/components/responses/Fel" } + + /api/arenden/{arendeId}/handelser: + parameters: + - name: arendeId + in: path + required: true + schema: { type: string } + get: + tags: [Ärenden] + summary: Hämta ärendets händelselogg + responses: + "200": + description: Händelser i tidsordning. + content: + application/json: + schema: + type: object + properties: + handelser: + type: array + items: { $ref: "#/components/schemas/LoggPost" } + "401": { $ref: "#/components/responses/Fel" } + "404": { $ref: "#/components/responses/Fel" } + post: + tags: [Ärenden] + summary: Lägg till händelser (append-only, idempotent) + description: > + Lägger till händelser i loggen. Max 500 per anrop. En händelse med + redan känt `id` ignoreras tyst — befintliga händelser skrivs aldrig + över, och databastriggern stoppar alla ändringsförsök. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [handelser] + properties: + handelser: + type: array + maxItems: 500 + items: { $ref: "#/components/schemas/LoggPost" } + responses: + "200": + description: Mottaget. + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + "400": { $ref: "#/components/responses/Fel" } + "401": { $ref: "#/components/responses/Fel" } + "404": { $ref: "#/components/responses/Fel" } + + /api/oversikt: + get: + tags: [Översikt] + summary: Organisationsöversikt med status och statistikunderlag + description: > + Kräver rollen `arbetsledare` eller `admin`. Status, deltagande + tekniker och sammanfattning härleds ur händelseloggen. + responses: + "200": + description: Alla ärenden i organisationen med härledd status. + content: + application/json: + schema: + type: object + properties: + arenden: + type: array + items: { $ref: "#/components/schemas/OversiktsRad" } + "401": { $ref: "#/components/responses/Fel" } + "403": { $ref: "#/components/responses/Fel" } + + /api/delad/{delningskod}: + get: + tags: [Delning] + summary: Publik Live Share-vy via delningskod + description: > + Kräver ingen inloggning — delningskoden är nyckeln. Interna poster + (kategoribyten, hypoteser, AI-dialog) är bortfiltrerade. + security: [] + parameters: + - name: delningskod + in: path + required: true + schema: { type: string, pattern: "^[a-z0-9-]+$" } + responses: + "200": + description: Ärendet med kunddelbar händelselogg. + content: + application/json: + schema: + type: object + properties: + arende: + type: object + properties: + id: { type: string } + nummer: { type: integer } + skapad: { type: string, format: date-time } + handelser: + type: array + items: { $ref: "#/components/schemas/LoggPost" } + "404": { $ref: "#/components/responses/Fel" } + + /api/ai: + post: + tags: [AI] + summary: AI-orkestern (separat tjänst bakom samma ingress) + description: > + Routas av ingressen till ai-orkester-tjänsten. Servern äger + modellval, effort, systemprompt och svarsschema per uppgift: + handledning (Claude Sonnet 5), granskning (Claude Opus 5), + sammanfattning (Claude Sonnet 5), metodikval (Claude Haiku 4.5). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [uppgift, prompt] + properties: + uppgift: + type: string + enum: [handledning, granskning, sammanfattning, metodikval] + prompt: { type: string, maxLength: 40000 } + responses: + "200": + description: Klassificerat AI-svar med modellen som svarade. + content: + application/json: + schema: + type: object + properties: + modell: { type: string, example: claude-sonnet-5 } + svar: + oneOf: + - $ref: "#/components/schemas/AiSvar" + - type: object + properties: + metodikId: + type: string + enum: [vibration, elsystem, generisk] + "400": { $ref: "#/components/responses/Fel" } + "401": { $ref: "#/components/responses/Fel" } + "502": { $ref: "#/components/responses/Fel" } + "503": { $ref: "#/components/responses/Fel" } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: > + HS256-JWT från /api/auth/logga-in eller /api/auth/registrera. + Anspråk: sub (användar-id), namn, org (organisations-id), + roll, iat, exp (12 timmar). + responses: + Inloggad: + description: Inloggad — token + kontouppgifter. + content: + application/json: + schema: + type: object + properties: + token: { type: string } + namn: { type: string } + roll: { $ref: "#/components/schemas/Roll" } + organisation: { type: string } + Fel: + description: Felsvar. + content: + application/json: + schema: + type: object + properties: + error: { type: string } + schemas: + Roll: + type: string + enum: [tekniker, arbetsledare, admin] + Anvandare: + type: object + properties: + id: { type: string, format: uuid } + epost: { type: string, format: email } + namn: { type: string } + roll: { $ref: "#/components/schemas/Roll" } + Arende: + type: object + properties: + id: { type: string } + nummer: { type: integer } + skapad: { type: string, format: date-time } + delningskod: { type: string, nullable: true } + metodik_id: { type: string, nullable: true } + LoggPost: + type: object + required: [id, tidpunkt, anvandare, handelse] + properties: + id: { type: string } + tidpunkt: { type: string, format: date-time } + anvandare: { type: string } + handelse: { $ref: "#/components/schemas/Handelse" } + Handelse: + type: object + description: > + Diskriminerad på `typ`; fullständiga fältdefinitioner i klientens + domänmodell (src/felsokning/domain.ts). + required: [typ] + additionalProperties: true + properties: + typ: + type: string + enum: + - objekt_identifierat + - felbeskrivning + - fraga_besvarad + - kontroll_utford + - observation + - matvarde + - hypotes + - foto + - kommentar + - kategori_byte + - inaktivitet_forklarad + - overlamning + - export_skapad + - ai_svar + - arende_avslutat + OversiktsRad: + type: object + properties: + id: { type: string } + nummer: { type: integer } + skapad: { type: string, format: date-time } + delningskod: { type: string, nullable: true } + metodik_id: { type: string, nullable: true } + antal_handelser: { type: integer } + forsta: { type: string, format: date-time, nullable: true } + senaste: { type: string, format: date-time, nullable: true } + avslutat: { type: boolean } + objekt: { type: string, nullable: true } + felbeskrivning: { type: string, nullable: true } + tekniker: + type: array + items: { type: string } + nullable: true + AiSvar: + type: object + required: [rader, nastaSteg] + properties: + rader: + type: array + items: + type: object + required: [typ, text] + properties: + typ: + type: string + enum: [observation, verifierat, hypotes, rekommendation] + text: { type: string } + nastaSteg: { type: string } + +security: + - bearerAuth: [] diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs index 1b95d30..49e3dd9 100644 --- a/services/plattform/server.mjs +++ b/services/plattform/server.mjs @@ -16,8 +16,14 @@ import { createServer } from "node:http"; import { createHmac, timingSafeEqual } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; import pg from "pg"; +// API-first: OpenAPI-specen är en versionerad artefakt och serveras live. +const OPENAPI = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "openapi.yaml"), "utf8"); + const PORT = Number(process.env.PORT ?? 8080); const MAX_KROPP = 4 * 1024 * 1024; const TOKEN_LIVSTID_S = 12 * 60 * 60; @@ -117,6 +123,13 @@ export function skapaServer() { if (req.method === "GET" && vag === "/halsa") { return svara(res, 200, { status: "ok" }); } + if (req.method === "GET" && vag === "/api/openapi.yaml") { + res.writeHead(200, { + "Content-Type": "application/yaml; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }); + return res.end(OPENAPI); + } const hemlighet = process.env.JWT_SECRET; if (!hemlighet) return svara(res, 503, { error: "Tjänsten är inte konfigurerad." }); diff --git a/src/felsokning/__tests__/ai.test.ts b/src/felsokning/__tests__/ai.test.ts index c5d439c..e982cfd 100644 --- a/src/felsokning/__tests__/ai.test.ts +++ b/src/felsokning/__tests__/ai.test.ts @@ -61,6 +61,33 @@ describe("AI-orkestern", () => { expect(schema).toContain("check (roll in ('tekniker', 'arbetsledare', 'admin'))"); }); + it("OpenAPI-specen och plattformsservern täcker samma endpoints", () => { + const spec = readFileSync("services/plattform/openapi.yaml", "utf8"); + const server = readFileSync("services/plattform/server.mjs", "utf8"); + // Varje dokumenterad väg finns i servern … + const vagar: [string, string][] = [ + ["/halsa", "/halsa"], + ["/api/openapi.yaml", "/api/openapi.yaml"], + ["/api/auth/registrera", "/api/auth/registrera"], + ["/api/auth/logga-in", "/api/auth/logga-in"], + ["/api/anvandare", "/api/anvandare"], + ["/api/arenden", "/api/arenden"], + ["/api/arenden/{arendeId}/handelser", "handelser"], + ["/api/oversikt", "/api/oversikt"], + ["/api/delad/{delningskod}", "delad"], + ]; + for (const [iSpec, iServer] of vagar) { + expect(spec).toContain(`${iSpec}:`); + expect(server).toContain(iServer); + } + // … och AI-endpointen + händelsetyperna är dokumenterade. + expect(spec).toContain("/api/ai:"); + expect(spec).toContain("append-only"); + for (const typ of ["objekt_identifierat", "kontroll_utford", "ai_svar", "arende_avslutat"]) { + expect(spec).toContain(typ); + } + }); + it("endpointen kodar AI-reglerna i grundprompten", () => { expect(ENDPOINT).toContain("Hitta aldrig på fakta"); expect(ENDPOINT).toContain("aldrig en hypotes som ett konstaterat fel");