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
+ Ditt besked till verkstaden
+ {fel}
+ Beskedet registreras i ärendet och kan inte ändras här — kontakta verkstaden om du ändrar dig.
+
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 (