diff --git a/docs/MVP.md b/docs/MVP.md index eb53e02..3e71d5b 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -38,7 +38,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | Pre-diagnostik | ✅ Ingen felsökning förrän grundkontrollerna är gjorda eller motiverade: fordonshistorik — tidigare ärenden på samma fordon hämtas automatiskt med sina felorsaker (server i inloggat läge, lokala storen annars) och orsakskedjan kopplas med ett tryck; Ja/Nej med obligatorisk orsak → kvalitetsvarning, **ingående mätarställning** (foto av instrumentpanelen, bildtolkningen föreslår värdet), kundens felbeskrivning verifierad och tidiga observationer hanterade. Metodiken låses upp först därefter. **Utgående mätarställning** fotograferas inför avslut och blir obligatorisk i grinden när ärendet stängs. | | Symptomverifiering (SVP) | ✅ Kundens beskrivning ≠ konstaterat fel: beskrivningen dokumenteras ordagrant, förtydligas via metodikens symptomfrågor (generiska metodiken har SVP-setet när/var/hur) och reproduceras — Ja (hur/förhållanden), Delvis (vad kunde/kunde inte) eller Nej (obligatorisk motivering). Rapportens beviskedja skiljer kundens beskrivning, verifierad observation, felorsaksanalys och rekommenderad åtgärd; formuleringen "kunde inte reproduceras under de förhållanden som rådde" används i stället för "felet konstaterat" (kodat även i orkesterns grundprompt). | | Felorsaksanalys | ✅ Obligatorisk före avslut: konstaterad avvikelse (kvalitetsregeln avvisar "trasig/defekt/sliten" utan förklaring), orsakskategorier (inkl. Okänd orsak med krav på motivering), minst en evidenskälla som valideras mot loggen, säkerhetsnivå (medel/låg kräver stärkande kontroller) och rekommenderad åtgärd. Avslutsknappen spärrad tills SVP + felorsak finns; kvalitetsgrinden gör båda obligatoriska vid stängning. Eget avsnitt i slutrapporten. | -| Kundgodkännande | ✅ Åtgärdsförslag lämnas till kund innan arbetet påbörjas (förifyllt ur felorsaksanalysen, med uppskattad kostnad) och **visas i Live Share** — kunden ser vad som föreslås. Kundens besked registreras med utfall, kanal (telefon/på plats/e-post/SMS/delningslänk) och motivering vid avböjt. Åtgärdsknappen är låst tills beskedet finns och förblir låst vid avböjt; kvalitetsgrinden flaggar hårt om arbete utförts trots avböjt förslag. Publikt godkännande direkt i delningslänken är en medveten avgränsning. | +| Kundgodkännande | ✅ Åtgärdsförslag lämnas till kund innan arbetet påbörjas (förifyllt ur felorsaksanalysen, med uppskattad kostnad) och **visas i Live Share** — kunden ser vad som föreslås. Kundens besked registreras med utfall, kanal (telefon/på plats/e-post/SMS/delningslänk) och motivering vid avböjt. Åtgärdsknappen är låst tills beskedet finns och förblir låst vid avböjt; kvalitetsgrinden flaggar hårt om arbete utförts trots avböjt förslag. **Kunden kan även svara direkt i sin delningslänk** — den enda skrivande publika vägen, med sex spärrar (endast kundnivå, ej återkallad, förslag måste finnas, ett besked per ärende, begränsat innehåll, takt-begränsning), samtliga verifierade i integrationstestet. | | Åtgärd och kvalitetskontroll | ✅ Arbetsflödets sista led: åtgärden dokumenteras (vad som gjordes + delar) eller motiveras varför den uteblev (kunden avböjde, väntar på reservdel …). Har en åtgärd utförts krävs **kvalitetskontroll** — symptomet borta / kvarstår / delvis / kunde inte verifieras, med beskrivning av hur det verifierades under samma förhållanden som symptomet reproducerades. Kvarstående symptom flaggas i grinden i stället för att döljas. Avslutsknappen är spärrad tills hela kedjan symptomverifiering → felorsak → åtgärd → kvalitetskontroll är komplett; rapporten har ett eget avsnitt ”Utförd åtgärd och verifiering”. | | Ärendeidentitet | ✅ Fordonsobjektet som röd tråd: identiteten (AO-nummer, claim-/garantinummer, skadenummer, regnr, VIN, miltal, kund) registreras en gång — normalt via arbetsorderskanningen — och återanvänds i identitetsraden i arbetsytan (med ärendetypsval), låst panel överst i Live Share, slutrapportens första sida (Ärendeinformation + Fordonsinformation) och exporten. | | Instrumentavläsning (visual-first) | ✅ Kameran som universellt gränssnitt: `📷 Instrument` i Dokumentera-panelen fotograferar multimetrar, diagnosskärmar, batteritestare m.m. — bildtolkningen identifierar instrumenttyp och extraherar värden/enheter/felkoder med konfidens per värde; teknikern bekräftar innan något loggas. Originalbilden loggas alltid tillsammans med de strukturerade mätvärdena — strukturerad data ersätter aldrig originalevidensen. Ingen integration mot diagnossystem krävs. | @@ -58,4 +58,3 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st ## Medvetna avgränsningar - VIN-avkodning mot fordonsdatabaser (utrustningsnivå, återkallelser, TSB:er) och tillverkarintegrationer ingår inte ännu — arbetsorderskanningen och QR-/VIN-avläsningen ger strukturen de kopplas in i. -- Publikt kundgodkännande direkt i delningslänken (kunden klickar själv) kräver en skrivande publik endpoint med egen säkerhetsmodell; i dag registrerar verkstaden kundens besked med kanal och tidpunkt. diff --git a/docs/moduler/evidensmotor.md b/docs/moduler/evidensmotor.md index a4f4a3a..1c1de6a 100644 --- a/docs/moduler/evidensmotor.md +++ b/docs/moduler/evidensmotor.md @@ -181,9 +181,24 @@ registrerat och spårbart: flaggar konflikten *"Utfört arbete trots avböjt åtgärdsförslag"* som ett hårt fel. -Avgränsning: kunden godkänner i dagsläget genom kontakt med verkstaden — -plattformen registrerar beskedet. Publikt godkännande direkt i -delningslänken kräver en skrivande publik endpoint och hanteras separat. +**Kunden kan svara direkt i sin delningslänk** (`POST /api/delad/{kod}/beslut`) +— den enda skrivande publika vägen i hela API:t, med sex spärrar som var +och en verifieras i integrationstestet: + +1. Endast delningar på **kundnivå** (partner-/internlänkar får aldrig + svara åt kunden) och aldrig återkallade. +2. Ärendets ursprungliga delningskod saknar registrerad nivå och kan inte + heller svara. +3. Det måste finnas ett åtgärdsförslag att svara på. +4. **Ett besked per ärende** — svaret kan inte ändras i efterhand + (kontakta verkstaden i stället). +5. Endast `godkant`/`avbojt`/`delvis` plus en kommentar på högst 500 + tecken; inget annat kan skrivas till loggen den vägen. +6. Takt-begränsning per delningskod. + +Beskedet loggas som `kundbeslut` med kanal `Delningslänk` och avsändaren +"Kund via delningslänk" — verkstadens egna registreringar (telefon, på +plats …) fungerar precis som förut. ## Åtgärdsfasen (Repair & Verification) diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh index 20272d6..5b46d83 100755 --- a/services/plattform/integrationstest.sh +++ b/services/plattform/integrationstest.sh @@ -194,6 +194,65 @@ REGLER=$(curl -s "$BAS/api/ecm/regler" -H "Authorization: Bearer $TOKEN_J") kontroll "regelpaketet serveras" "$(echo "$REGLER" | falt .version)" "2.0" kontroll "regelpaketet innehåller garantiregler" "$(echo "$REGLER" | falt '.arendetypRegler.Garanti.length')" "3" +# 10c. Publikt kundgodkännande — den enda skrivande publika vägen +# Ärendets ursprungliga delningskod saknar registrerad nivå och får +# därför aldrig svara (bara riktiga kundlänkar duger). +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/delkod123/beslut" \ + -H 'Content-Type: application/json' -d '{"beslut":"godkant"}') +kontroll "legacy-delningskod kan inte svara" "$KOD" "404" + +# Kundlänk utan åtgärdsförslag: inget att svara på +KUNDKOD=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' -d '{"niva":"kund"}' | falt .kod) +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/$KUNDKOD/beslut" \ + -H 'Content-Type: application/json' -d '{"beslut":"godkant"}') +kontroll "beslut utan åtgärdsförslag avvisas" "$KOD" "409" + +# Lägg in ett åtgärdsförslag +curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"handelser":[{"id":"h-forslag","tidpunkt":"2026-08-03T08:06:00Z","anvandare":"Anna","handelse":{"typ":"atgardsforslag","beskrivning":"Byt reläet i kupémodulen.","uppskattadKostnad":"1 450 kr"}}]}' >/dev/null + +# Partnerlänk får INTE svara åt kunden +PKOD=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' -d '{"niva":"partner"}' | falt .kod) +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/$PKOD/beslut" \ + -H 'Content-Type: application/json' -d '{"beslut":"godkant"}') +kontroll "partnerlänk kan inte svara åt kunden" "$KOD" "404" + +# Ogiltigt beslutsvärde avvisas +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/$KUNDKOD/beslut" \ + -H 'Content-Type: application/json' -d '{"beslut":"kanske"}') +kontroll "ogiltigt beslutsvärde avvisas" "$KOD" "400" + +# Kunden godkänner via sin länk +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/$KUNDKOD/beslut" \ + -H 'Content-Type: application/json' -d '{"beslut":"godkant","kommentar":"Kör på."}') +kontroll "kunden kan godkänna via sin länk" "$KOD" "200" +BESLUT=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" \ + | falt '.handelser.filter(h=>h.handelse.typ==="kundbeslut").map(h=>h.handelse.kanal+"/"+h.anvandare).join(",")') +kontroll "beskedet loggas med kanal och avsändare" "$BESLUT" "Delningslänk/Kund via delningslänk" + +# Ett beslut per ärende — svaret kan inte ändras i efterhand +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/$KUNDKOD/beslut" \ + -H 'Content-Type: application/json' -d '{"beslut":"avbojt"}') +kontroll "beskedet kan inte ändras i efterhand" "$KOD" "409" + +# Återkallad delning kan inte svara +curl -s -X POST "$BAS/api/delningar/$KUNDKOD/aterkalla" -H "Authorization: Bearer $TOKEN_A" >/dev/null +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/$KUNDKOD/beslut" \ + -H 'Content-Type: application/json' -d '{"beslut":"godkant"}') +kontroll "återkallad länk kan inte svara" "$KOD" "404" + +# Takt-begränsning slår till efter upprepade försök +NYKOD=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' -d '{"niva":"kund"}' | falt .kod) +SISTA="" +for i in 1 2 3 4 5 6 7; do + SISTA=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/$NYKOD/beslut" \ + -H 'Content-Type: application/json' -d '{"beslut":"godkant"}') +done +kontroll "takt-begränsning stoppar upprepade försök" "$SISTA" "429" + # 11. API-first: OpenAPI-specen serveras live, utan inloggning SPEC=$(curl -s "$BAS/api/openapi.yaml") case "$SPEC" in diff --git a/services/plattform/openapi.yaml b/services/plattform/openapi.yaml index 75b3fa2..d42fd1b 100644 --- a/services/plattform/openapi.yaml +++ b/services/plattform/openapi.yaml @@ -229,6 +229,46 @@ paths: "401": { $ref: "#/components/responses/Fel" } "403": { $ref: "#/components/responses/Fel" } + /api/delad/{delningskod}/beslut: + post: + tags: [Delning] + summary: Kundens besked på ett åtgärdsförslag + description: > + Den enda skrivande publika vägen. Endast delningar på **kundnivå** + som inte återkallats får svara, det måste finnas ett + åtgärdsförslag, och **ett besked per ärende** — svaret kan inte + ändras i efterhand. Takt-begränsad per delningskod. Beskedet + loggas som `kundbeslut` med kanal `Delningslänk`. + security: [] + parameters: + - name: delningskod + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [beslut] + properties: + beslut: { type: string, enum: [godkant, avbojt, delvis] } + kommentar: { type: string, maxLength: 500 } + responses: + "200": + description: Beskedet registrerat. + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + "400": { $ref: "#/components/responses/Fel" } + "404": { $ref: "#/components/responses/Fel" } + "409": { $ref: "#/components/responses/Fel" } + "429": { $ref: "#/components/responses/Fel" } + /api/organisation: get: tags: [Organisation] diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs index e9be1a3..6163352 100644 --- a/services/plattform/server.mjs +++ b/services/plattform/server.mjs @@ -40,6 +40,23 @@ const ROLLER = ["tekniker", "arbetsledare", "admin"]; const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 }); +// Enkel takt-begränsning för den publika beslutsendpointen (per +// delningskod, i minnet). Räcker för en enda pod; bakom flera repliker +// kompletteras den av databasspärren "ett beslut per förslag". +const BESLUT_TAK = 5; +const BESLUT_FONSTER_MS = 60_000; +const beslutsForsok = new Map(); + +function forTataForsok(kod) { + const nu = Date.now(); + const forsok = (beslutsForsok.get(kod) ?? []).filter((t) => nu - t < BESLUT_FONSTER_MS); + forsok.push(nu); + beslutsForsok.set(kod, forsok); + // Enkel städning så kartan inte växer obegränsat. + if (beslutsForsok.size > 5000) beslutsForsok.clear(); + return forsok.length > BESLUT_TAK; +} + // ---- JWT (HS256, utan beroenden) -------------------------------------- const b64url = (data) => Buffer.from(data).toString("base64url"); @@ -241,6 +258,69 @@ export function skapaServer() { return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows, niva }); } + // -- Publikt kundgodkännande (den enda skrivande publika vägen) -- + // + // Kunden svarar på ett åtgärdsförslag via sin delningslänk. Spärrar: + // 1. endast delningar på kundnivå (partner/intern får inte svara + // åt kunden), och aldrig återkallade + // 2. det måste finnas ett åtgärdsförslag att svara på + // 3. ett beslut per ärende — svaret kan inte ändras i efterhand + // 4. takt-begränsning per kod + // 5. beslutet får bara vara godkant/avbojt/delvis + kort kommentar; + // inget annat kan skrivas till loggen den här vägen + const beslutVag = vag.match(/^\/api\/delad\/([A-Za-z0-9_-]+)\/beslut$/); + if (req.method === "POST" && beslutVag) { + const kod = beslutVag[1]; + if (forTataForsok(kod)) return svara(res, 429, { error: "För många försök — vänta en stund." }); + + const delning = await pool.query( + `select arende_id, niva from delningar where kod = $1 and aterkallad is null`, + [kod], + ); + if (delning.rowCount === 0 || delning.rows[0].niva !== "kund") { + return svara(res, 404, { error: "Delningen är inte tillgänglig." }); + } + const arendeId = delning.rows[0].arende_id; + + const { beslut, kommentar } = await lasKropp(req); + if (!["godkant", "avbojt", "delvis"].includes(beslut)) { + return svara(res, 400, { error: "Ogiltigt beslut." }); + } + if (kommentar !== undefined && (typeof kommentar !== "string" || kommentar.length > 500)) { + return svara(res, 400, { error: "Kommentaren är för lång." }); + } + + const forslag = await pool.query( + `select 1 from felsokning_handelser + where arende_id = $1 and handelse->>'typ' = 'atgardsforslag' limit 1`, + [arendeId], + ); + if (forslag.rowCount === 0) { + return svara(res, 409, { error: "Det finns inget åtgärdsförslag att svara på." }); + } + const tidigare = await pool.query( + `select 1 from felsokning_handelser + where arende_id = $1 and handelse->>'typ' = 'kundbeslut' limit 1`, + [arendeId], + ); + if (tidigare.rowCount > 0) { + return svara(res, 409, { error: "Ett besked är redan registrerat — kontakta verkstaden." }); + } + + const handelse = { + typ: "kundbeslut", + beslut, + kanal: "Delningslänk", + ...(kommentar?.trim() ? { kommentar: kommentar.trim() } : {}), + }; + await pool.query( + `insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse) + values ($1, $2, now(), $3, $4)`, + [`kb-${nyKod()}`, arendeId, "Kund via delningslänk", handelse], + ); + return svara(res, 200, { ok: true }); + } + // -- Skyddade endpoints (organisationsknutna) -- const anspr = kravAuth(req, hemlighet); if (!anspr?.org) return svara(res, 401, { error: "Inloggning krävs." }); diff --git a/src/felsokning/DelatArendeVy.tsx b/src/felsokning/DelatArendeVy.tsx index 4bcbf47..4977c52 100644 --- a/src/felsokning/DelatArendeVy.tsx +++ b/src/felsokning/DelatArendeVy.tsx @@ -4,14 +4,75 @@ // hypoteser) ingår aldrig i underlaget som når hit externt, och filtreras // bort även lokalt. +import { useState } from "react"; import type { Arende } from "./domain"; import { KUNDBESLUT_LABEL, handelseRubrik } from "./domain"; import { arAvslutat, arendeidentitet, brief, foton, tidsfordelningsRader, videor } from "./projektioner"; import { metodikForArende } from "./store"; import { tidDatum, tidKlockslag } from "./format"; -import { FelsokningSkal, Panel } from "./ui"; +import { FelsokningSkal, Panel, StorKnapp } from "./ui"; import { IkonCheck, IkonKlocka, IkonUppdatera } from "./ikoner"; +// Kundens svar på åtgärdsförslaget — den enda skrivande åtgärden i hela +// delningsvyn. Beskedet kan lämnas en gång och kan inte ändras här. +function BeslutsKnappar({ + vidBeslut, +}: { + vidBeslut: (beslut: "godkant" | "avbojt", kommentar: string) => Promise; +}) { + const [val, setVal] = useState<"" | "godkant" | "avbojt">(""); + const [kommentar, setKommentar] = useState(""); + const [skickar, setSkickar] = useState(false); + const [fel, setFel] = useState(""); + + return ( +
+

+ Ditt besked till verkstaden +

+
+ setVal("godkant")}> + Godkänn åtgärden + + setVal("avbojt")}> + Avböj + +
+ {val && ( + <> + + {fel &&

{fel}

} + { + setSkickar(true); + setFel(""); + const felmeddelande = await vidBeslut(val, kommentar); + if (felmeddelande) setFel(felmeddelande); + setSkickar(false); + }} + > + {skickar ? "Skickar …" : "Skicka besked"} + +

+ Beskedet registreras i ärendet och kan inte ändras här — kontakta verkstaden om du ändrar dig. +

+ + )} +
+ ); +} + function IdentitetsPanel({ arende, avslutat }: { arende: Arende; avslutat: boolean }) { const idn = arendeidentitet(arende); const falt: [string, string | undefined][] = [ @@ -45,10 +106,14 @@ export function DelatArendeVy({ nu, notis, redanFiltrerad = false, + vidBeslut, }: { arende: Arende; nu: string; notis: string; + // Satt endast på den publika kundlänken: kunden kan svara på ett + // åtgärdsförslag. Utan den är vyn helt skrivskyddad. + vidBeslut?: (beslut: "godkant" | "avbojt", kommentar: string) => Promise; // Sant när servern redan filtrerat per behörighetsnivå (publik delning) — // då renderas händelserna som de kom, inklusive t.ex. hypoteser på // partner-/internnivå. @@ -116,6 +181,8 @@ export function DelatArendeVy({ Ditt besked: {KUNDBESLUT_LABEL[beslut.handelse.beslut]}{" "} (registrerat via {beslut.handelse.kanal})

+ ) : vidBeslut ? ( + ) : (

Inväntar ditt besked — kontakta verkstaden för att godkänna eller avböja. diff --git a/src/felsokning/__tests__/ai.test.ts b/src/felsokning/__tests__/ai.test.ts index 569ffb9..7699b02 100644 --- a/src/felsokning/__tests__/ai.test.ts +++ b/src/felsokning/__tests__/ai.test.ts @@ -81,6 +81,7 @@ describe("AI-orkestern", () => { ["/api/statistik/felorsaker", "/api/statistik/felorsaker"], ["/api/oversikt", "/api/oversikt"], ["/api/delad/{delningskod}", "delad"], + ["/api/delad/{delningskod}/beslut", "beslut"], ]; for (const [iSpec, iServer] of vagar) { expect(spec).toContain(`${iSpec}:`); diff --git a/src/pages/felsokning/PublikDelning.tsx b/src/pages/felsokning/PublikDelning.tsx index 5891fcd..0e5ed6b 100644 --- a/src/pages/felsokning/PublikDelning.tsx +++ b/src/pages/felsokning/PublikDelning.tsx @@ -99,12 +99,40 @@ export default function PublikDelning() { ); } + // Kunden kan svara på ett åtgärdsförslag direkt i sin länk. Endast + // kundnivån får svara — servern gör samma kontroll igen. + const skickaBeslut = + niva === "kund" && kod + ? async (beslut: "godkant" | "avbojt", kommentar: string) => { + const { plattformAktiv, PLATTFORM_URL } = await import("@/felsokning/plattform"); + if (!plattformAktiv()) return "Beskedet kan inte lämnas här — kontakta verkstaden."; + try { + const res = await fetch(`${PLATTFORM_URL}/api/delad/${kod}/beslut`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ beslut, kommentar }), + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + return data.error ?? "Beskedet kunde inte registreras — försök igen."; + } + // Hämta om direkt så kvittensen syns. + const uppdaterat = await hamtaDelat(kod); + if (uppdaterat) setArende(uppdaterat.arende); + return null; + } catch { + return "Beskedet kunde inte skickas — kontrollera anslutningen."; + } + } + : undefined; + return ( ); }