Live Share-behörighetsnivåer: kund/partner/intern med återkallelse
Modulspecifikationens sista del implementerad:
- Ny tabell delningar: återkallbara länkar med behörighetsnivå
(kund/partner/intern), organisationsknutna via ärendet.
- Nivåstyrd filtrering på serversidan i GET /api/delad/{kod}:
kund utesluter kategoribyten, hypoteser och AI-dialog; partner
utesluter kategoribyten och AI-dialog (hypoteser visas, märkta ej
verifierade); intern visar allt. Ärendets ursprungliga delningskod
fungerar bakåtkompatibelt som kundnivå. Svaret bär nivån.
- Nya endpoints: skapa/lista delningar per ärende och återkalla per
kod — alltid organisationskontrollerat; en återkallad länk ger 404.
- Klienten: delningshanterare i rapportfliken (självhostat läge) med
skapa per nivå, kopiera och återkalla — varje åtgärd loggas i
ärendet; publika delningssidan visar nivåanpassad notis och renderar
serverfiltrerade händelser utan att dölja partner-/interninnehåll.
- OpenAPI-specen utökad (Delning, DelningsNiva, tre nya operationer)
och maskinvaliderad.
Verifierat: integrationstestets 25 kontroller gröna mot Postgres 16 —
inkl. att kundkoden filtrerar hypoteser, partnernivån visar dem men
döljer kategoribyten, internnivån visar allt, org B inte kan skapa
delning av org A:s ärende och att återkallad länk ger 404. 29
vitest-tester gröna, produktionsbygge ok, Playwright-röktest grönt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -112,7 +112,31 @@ 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
|
||||
# 10. Live Share-behörighetsnivåer: kund/partner/intern + återkallelse
|
||||
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"handelser":[{"id":"h3","tidpunkt":"2026-08-03T08:03:00Z","anvandare":"Anna","handelse":{"typ":"hypotes","text":"Trasigt relä","niva":"lag"}}]}' >/dev/null
|
||||
kontroll "kundkoden filtrerar hypoteser" "$(curl -s "$BAS/api/delad/delkod123" | falt .handelser.length)" "1"
|
||||
|
||||
PARTNERKOD=$(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)
|
||||
PARTNER=$(curl -s "$BAS/api/delad/$PARTNERKOD")
|
||||
kontroll "partnernivån visar hypoteser" "$(echo "$PARTNER" | falt .handelser.length)" "2"
|
||||
kontroll "partnernivån döljer kategoribyten" "$(echo "$PARTNER" | falt '.handelser.some(h=>h.handelse.typ==="kategori_byte")')" "false"
|
||||
kontroll "nivån följer med svaret" "$(echo "$PARTNER" | falt .niva)" "partner"
|
||||
|
||||
INTERNKOD=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' -d '{"niva":"intern"}' | falt .kod)
|
||||
kontroll "internnivån visar allt" "$(curl -s "$BAS/api/delad/$INTERNKOD" | falt .handelser.length)" "3"
|
||||
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-test1/delningar" \
|
||||
-H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' -d '{"niva":"intern"}')
|
||||
kontroll "org B kan inte skapa delning av org A:s ärende" "$KOD" "404"
|
||||
|
||||
curl -s -X POST "$BAS/api/delningar/$PARTNERKOD/aterkalla" -H "Authorization: Bearer $TOKEN_A" >/dev/null
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$PARTNERKOD")
|
||||
kontroll "återkallad delning ger 404" "$KOD" "404"
|
||||
|
||||
# 11. 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" ;;
|
||||
|
||||
@@ -269,22 +269,99 @@ paths:
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/arenden/{arendeId}/delningar:
|
||||
parameters:
|
||||
- name: arendeId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
get:
|
||||
tags: [Delning]
|
||||
summary: Lista ärendets delningslänkar
|
||||
responses:
|
||||
"200":
|
||||
description: Delningar (inklusive återkallade).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
delningar:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Delning" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"404": { $ref: "#/components/responses/Fel" }
|
||||
post:
|
||||
tags: [Delning]
|
||||
summary: Skapa delningslänk med behörighetsnivå
|
||||
description: >
|
||||
Verkstaden kontrollerar alltid delningen: kund (det kunddelbara),
|
||||
partner (även hypoteser, tydligt märkta ej verifierade) eller
|
||||
intern (full insyn). Nivåfiltreringen sker på serversidan.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [niva]
|
||||
properties:
|
||||
niva: { $ref: "#/components/schemas/DelningsNiva" }
|
||||
responses:
|
||||
"200":
|
||||
description: Den skapade länkens kod.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
kod: { type: string }
|
||||
niva: { $ref: "#/components/schemas/DelningsNiva" }
|
||||
"400": { $ref: "#/components/responses/Fel" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"404": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/delningar/{kod}/aterkalla:
|
||||
post:
|
||||
tags: [Delning]
|
||||
summary: Återkalla en delningslänk
|
||||
description: Efter återkallelse ger länken 404. Kan inte ångras.
|
||||
parameters:
|
||||
- name: kod
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: Återkallad.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok: { type: boolean }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"404": { $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.
|
||||
Kräver ingen inloggning — delningskoden är nyckeln. Filtreringen
|
||||
styrs av länkens behörighetsnivå: kund utesluter kategoribyten,
|
||||
hypoteser och AI-dialog; partner utesluter kategoribyten och
|
||||
AI-dialog; intern visar allt. Ärendets ursprungliga delningskod
|
||||
fungerar bakåtkompatibelt som kundnivå.
|
||||
security: []
|
||||
parameters:
|
||||
- name: delningskod
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, pattern: "^[a-z0-9-]+$" }
|
||||
schema: { type: string, pattern: "^[A-Za-z0-9_-]+$" }
|
||||
responses:
|
||||
"200":
|
||||
description: Ärendet med kunddelbar händelselogg.
|
||||
description: Ärendet med nivåfiltrerad händelselogg.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -299,6 +376,7 @@ paths:
|
||||
handelser:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/LoggPost" }
|
||||
niva: { $ref: "#/components/schemas/DelningsNiva" }
|
||||
"404": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/ai:
|
||||
@@ -378,6 +456,16 @@ components:
|
||||
Roll:
|
||||
type: string
|
||||
enum: [tekniker, arbetsledare, admin]
|
||||
DelningsNiva:
|
||||
type: string
|
||||
enum: [kund, partner, intern]
|
||||
Delning:
|
||||
type: object
|
||||
properties:
|
||||
kod: { type: string }
|
||||
niva: { $ref: "#/components/schemas/DelningsNiva" }
|
||||
skapad: { type: string, format: date-time }
|
||||
aterkallad: { type: string, format: date-time, nullable: true }
|
||||
Anvandare:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// PORT default 8080
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import crypto, { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -80,6 +80,12 @@ async function lasKropp(req) {
|
||||
return JSON.parse(Buffer.concat(bitar).toString("utf8"));
|
||||
}
|
||||
|
||||
function nyKod() {
|
||||
const tecken = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const { randomBytes } = crypto;
|
||||
return Array.from(randomBytes(16), (b) => tecken[b % 36]).join("");
|
||||
}
|
||||
|
||||
function kravAuth(req, hemlighet) {
|
||||
const auth = req.headers.authorization ?? "";
|
||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
||||
@@ -186,20 +192,44 @@ export function skapaServer() {
|
||||
}
|
||||
|
||||
// -- Publik delning (Live Share via delningskod) --
|
||||
const delad = vag.match(/^\/api\/delad\/([a-z0-9-]+)$/);
|
||||
// Behörighetsnivån styr filtreringen på serversidan:
|
||||
// kund – det kunddelbara (inga kategoribyten, hypoteser, AI-dialog)
|
||||
// partner – försäkringsbolag/tillverkare: även hypoteser (tydligt märkta)
|
||||
// intern – full insyn
|
||||
const delad = vag.match(/^\/api\/delad\/([A-Za-z0-9_-]+)$/);
|
||||
if (req.method === "GET" && delad) {
|
||||
const arende = await pool.query(
|
||||
`select id, nummer, skapad from felsokning_arenden where delningskod = $1`,
|
||||
let arendeId = null;
|
||||
let niva = "kund";
|
||||
const delning = await pool.query(
|
||||
`select arende_id, niva from delningar where kod = $1 and aterkallad is null`,
|
||||
[delad[1]],
|
||||
);
|
||||
if (arende.rowCount === 0) return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
|
||||
if (delning.rowCount > 0) {
|
||||
arendeId = delning.rows[0].arende_id;
|
||||
niva = delning.rows[0].niva;
|
||||
} else {
|
||||
// Bakåtkompatibelt: ärendets ursprungliga delningskod = kundnivå.
|
||||
const viaArende = await pool.query(
|
||||
`select id from felsokning_arenden where delningskod = $1`,
|
||||
[delad[1]],
|
||||
);
|
||||
if (viaArende.rowCount > 0) arendeId = viaArende.rows[0].id;
|
||||
}
|
||||
if (!arendeId) return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
|
||||
|
||||
const arende = await pool.query(
|
||||
`select id, nummer, skapad from felsokning_arenden where id = $1`,
|
||||
[arendeId],
|
||||
);
|
||||
const bortfiltrerat =
|
||||
niva === "intern" ? [] : niva === "partner" ? ["kategori_byte", "ai_svar"] : ["kategori_byte", "hypotes", "ai_svar"];
|
||||
const handelser = await pool.query(
|
||||
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
|
||||
where arende_id = $1 and handelse->>'typ' not in ('kategori_byte','hypotes','ai_svar')
|
||||
where arende_id = $1 and not (handelse->>'typ' = any($2))
|
||||
order by tidpunkt, id`,
|
||||
[arende.rows[0].id],
|
||||
[arendeId, bortfiltrerat],
|
||||
);
|
||||
return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows });
|
||||
return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows, niva });
|
||||
}
|
||||
|
||||
// -- Skyddade endpoints (organisationsknutna) --
|
||||
@@ -284,6 +314,47 @@ export function skapaServer() {
|
||||
return svara(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// Delningslänkar: skapa/lista per ärende, återkalla per kod.
|
||||
// Verkstaden kontrollerar alltid delningen (organisationskravet).
|
||||
const delningarVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/delningar$/);
|
||||
if (delningarVag) {
|
||||
if (!(await arendeIOrg(delningarVag[1], anspr.org))) {
|
||||
return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
|
||||
}
|
||||
if (req.method === "GET") {
|
||||
const rader = await pool.query(
|
||||
`select kod, niva, skapad, aterkallad from delningar where arende_id = $1 order by skapad desc`,
|
||||
[delningarVag[1]],
|
||||
);
|
||||
return svara(res, 200, { delningar: rader.rows });
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
const { niva } = await lasKropp(req);
|
||||
if (!["kund", "partner", "intern"].includes(niva)) {
|
||||
return svara(res, 400, { error: "Ogiltig nivå." });
|
||||
}
|
||||
const kod = nyKod();
|
||||
await pool.query(
|
||||
`insert into delningar (kod, arende_id, niva, skapad_av) values ($1, $2, $3, $4)`,
|
||||
[kod, delningarVag[1], niva, anspr.sub],
|
||||
);
|
||||
return svara(res, 200, { kod, niva });
|
||||
}
|
||||
}
|
||||
|
||||
const aterkalla = vag.match(/^\/api\/delningar\/([A-Za-z0-9_-]+)\/aterkalla$/);
|
||||
if (req.method === "POST" && aterkalla) {
|
||||
const rad = await pool.query(
|
||||
`update delningar d set aterkallad = now()
|
||||
from felsokning_arenden a
|
||||
where d.kod = $1 and d.arende_id = a.id and a.organisation_id = $2 and d.aterkallad is null
|
||||
returning d.kod`,
|
||||
[aterkalla[1], anspr.org],
|
||||
);
|
||||
if (rad.rowCount === 0) return svara(res, 404, { error: "Delningen är inte tillgänglig." });
|
||||
return svara(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
const handelserVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/handelser$/);
|
||||
if (handelserVag) {
|
||||
// Organisationsgränsen: ärendet måste tillhöra användarens org.
|
||||
|
||||
Reference in New Issue
Block a user