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:
+1
-2
@@ -30,7 +30,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
|
|||||||
| Multi-tenant & roller | ✅ I självhostat läge: registrering skapar organisation + systemadministratör; admin hanterar användare (tekniker/arbetsledare/admin) via UI; all ärendedata organisationsisolerad i API:t; roll + organisation i JWT:n. **Arbetsledarvy** (`/felsokning/oversikt`): organisationens alla ärenden med status, deltagande tekniker och statistik (pågående/avslutade/ledtid) — härlett ur händelseloggen; ärenden kan hämtas till enheten med konfliktfri flätning. Integrationstestat mot riktig Postgres (isolering, rollstyrning, append-only, översiktens behörighet och härledningar). |
|
| Multi-tenant & roller | ✅ I självhostat läge: registrering skapar organisation + systemadministratör; admin hanterar användare (tekniker/arbetsledare/admin) via UI; all ärendedata organisationsisolerad i API:t; roll + organisation i JWT:n. **Arbetsledarvy** (`/felsokning/oversikt`): organisationens alla ärenden med status, deltagande tekniker och statistik (pågående/avslutade/ledtid) — härlett ur händelseloggen; ärenden kan hämtas till enheten med konfliktfri flätning. Integrationstestat mot riktig Postgres (isolering, rollstyrning, append-only, översiktens behörighet och härledningar). |
|
||||||
| Backend & synk | ✅ Databas-migration (`supabase/migrations/20260802230000_guidad_felsokning.sql`): ärenden + händelser med RLS, append-only även i databasen (inga update/delete-rättigheter). Synklager i klienten: konfliktfri ihopflätning av händelser per id (testad), push av lokala + pull av kollegors händelser var 15:e sekund. Utan inloggning arbetar appen i lokalt läge; status visas i ärendehuvudet. |
|
| Backend & synk | ✅ Databas-migration (`supabase/migrations/20260802230000_guidad_felsokning.sql`): ärenden + händelser med RLS, append-only även i databasen (inga update/delete-rättigheter). Synklager i klienten: konfliktfri ihopflätning av händelser per id (testad), push av lokala + pull av kollegors händelser var 15:e sekund. Utan inloggning arbetar appen i lokalt läge; status visas i ärendehuvudet. |
|
||||||
| Metodiker | ✅ Tre: vibration, elsystem/strömförsörjning (relä-exemplet ur visionen) och generisk — vald automatiskt utifrån felbeskrivningen. |
|
| Metodiker | ✅ Tre: vibration, elsystem/strömförsörjning (relä-exemplet ur visionen) och generisk — vald automatiskt utifrån felbeskrivningen. |
|
||||||
| 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. |
|
| 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**: återkallbara delningslänkar per nivå — kund (det kunddelbara), extern partner (även hypoteser, märkta ej verifierade), intern (full insyn) — med serverstyrd filtrering, hanterade från rapportfliken i självhostat läge. |
|
||||||
| Dashboard | ✅ Enligt direktivet: räknare och filter för Alla/Pågående/Klara plus Starta nytt ärende. |
|
| 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. |
|
| Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. |
|
||||||
| Ö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`. |
|
| Ö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`. |
|
||||||
@@ -46,4 +46,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.
|
- 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.
|
- 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.
|
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ Länkar kan skapas med olika åtkomstnivåer:
|
|||||||
|
|
||||||
- **Kund** – läsbehörighet till den information verkstaden valt att dela.
|
- **Kund** – läsbehörighet till den information verkstaden valt att dela.
|
||||||
- **Intern** – full insyn för kollegor och arbetsledare.
|
- **Intern** – full insyn för kollegor och arbetsledare.
|
||||||
- **Extern partner** – exempelvis försäkringsbolag eller tillverkare, med avgränsad information.
|
- **Extern partner** – exempelvis försäkringsbolag eller tillverkare, med avgränsad information (inklusive hypoteser, tydligt märkta som ej verifierade).
|
||||||
|
|
||||||
|
Implementerat i plattformen: varje länk skapas med en nivå, filtreringen sker på serversidan och länkar kan återkallas — en återkallad länk ger 404.
|
||||||
|
|
||||||
## Export
|
## Export
|
||||||
|
|
||||||
|
|||||||
@@ -62,3 +62,16 @@ drop trigger if exists arenden_append_only on felsokning_arenden;
|
|||||||
create trigger arenden_append_only
|
create trigger arenden_append_only
|
||||||
before update or delete on felsokning_arenden
|
before update or delete on felsokning_arenden
|
||||||
for each row execute function forbjud_andring();
|
for each row execute function forbjud_andring();
|
||||||
|
|
||||||
|
-- Live Share-delningar: återkallbara länkar med behörighetsnivå.
|
||||||
|
-- (Åtkomststyrning, inte journal — därför ingen append-only-trigger:
|
||||||
|
-- återkallelse sätter aterkallad-tidpunkten.)
|
||||||
|
create table if not exists delningar (
|
||||||
|
kod text primary key,
|
||||||
|
arende_id text not null references felsokning_arenden(id),
|
||||||
|
niva text not null check (niva in ('kund', 'partner', 'intern')),
|
||||||
|
skapad_av uuid references anvandare(id),
|
||||||
|
skapad timestamptz not null default now(),
|
||||||
|
aterkallad timestamptz
|
||||||
|
);
|
||||||
|
create index if not exists delningar_arende_idx on delningar (arende_id);
|
||||||
|
|||||||
@@ -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 härleder status" "$(echo "$OVERSIKT" | falt '.arenden[0].avslutat')" "false"
|
||||||
kontroll "översikten räknar händelser" "$(echo "$OVERSIKT" | falt '.arenden[0].antal_handelser')" "2"
|
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")
|
SPEC=$(curl -s "$BAS/api/openapi.yaml")
|
||||||
case "$SPEC" in
|
case "$SPEC" in
|
||||||
"openapi: 3.0.3"*) echo "✓ OpenAPI-specen serveras på /api/openapi.yaml" ;;
|
"openapi: 3.0.3"*) echo "✓ OpenAPI-specen serveras på /api/openapi.yaml" ;;
|
||||||
|
|||||||
@@ -269,22 +269,99 @@ paths:
|
|||||||
"401": { $ref: "#/components/responses/Fel" }
|
"401": { $ref: "#/components/responses/Fel" }
|
||||||
"403": { $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}:
|
/api/delad/{delningskod}:
|
||||||
get:
|
get:
|
||||||
tags: [Delning]
|
tags: [Delning]
|
||||||
summary: Publik Live Share-vy via delningskod
|
summary: Publik Live Share-vy via delningskod
|
||||||
description: >
|
description: >
|
||||||
Kräver ingen inloggning — delningskoden är nyckeln. Interna poster
|
Kräver ingen inloggning — delningskoden är nyckeln. Filtreringen
|
||||||
(kategoribyten, hypoteser, AI-dialog) är bortfiltrerade.
|
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: []
|
security: []
|
||||||
parameters:
|
parameters:
|
||||||
- name: delningskod
|
- name: delningskod
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema: { type: string, pattern: "^[a-z0-9-]+$" }
|
schema: { type: string, pattern: "^[A-Za-z0-9_-]+$" }
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Ärendet med kunddelbar händelselogg.
|
description: Ärendet med nivåfiltrerad händelselogg.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -299,6 +376,7 @@ paths:
|
|||||||
handelser:
|
handelser:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/LoggPost" }
|
items: { $ref: "#/components/schemas/LoggPost" }
|
||||||
|
niva: { $ref: "#/components/schemas/DelningsNiva" }
|
||||||
"404": { $ref: "#/components/responses/Fel" }
|
"404": { $ref: "#/components/responses/Fel" }
|
||||||
|
|
||||||
/api/ai:
|
/api/ai:
|
||||||
@@ -378,6 +456,16 @@ components:
|
|||||||
Roll:
|
Roll:
|
||||||
type: string
|
type: string
|
||||||
enum: [tekniker, arbetsledare, admin]
|
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:
|
Anvandare:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
// PORT default 8080
|
// PORT default 8080
|
||||||
|
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
import crypto, { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
@@ -80,6 +80,12 @@ async function lasKropp(req) {
|
|||||||
return JSON.parse(Buffer.concat(bitar).toString("utf8"));
|
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) {
|
function kravAuth(req, hemlighet) {
|
||||||
const auth = req.headers.authorization ?? "";
|
const auth = req.headers.authorization ?? "";
|
||||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
||||||
@@ -186,20 +192,44 @@ export function skapaServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -- Publik delning (Live Share via delningskod) --
|
// -- 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) {
|
if (req.method === "GET" && delad) {
|
||||||
const arende = await pool.query(
|
let arendeId = null;
|
||||||
`select id, nummer, skapad from felsokning_arenden where delningskod = $1`,
|
let niva = "kund";
|
||||||
|
const delning = await pool.query(
|
||||||
|
`select arende_id, niva from delningar where kod = $1 and aterkallad is null`,
|
||||||
[delad[1]],
|
[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(
|
const handelser = await pool.query(
|
||||||
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
|
`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`,
|
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) --
|
// -- Skyddade endpoints (organisationsknutna) --
|
||||||
@@ -284,6 +314,47 @@ export function skapaServer() {
|
|||||||
return svara(res, 200, { ok: true });
|
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$/);
|
const handelserVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/handelser$/);
|
||||||
if (handelserVag) {
|
if (handelserVag) {
|
||||||
// Organisationsgränsen: ärendet måste tillhöra användarens org.
|
// Organisationsgränsen: ärendet måste tillhöra användarens org.
|
||||||
|
|||||||
@@ -11,15 +11,28 @@ import { metodikForArende } from "./store";
|
|||||||
import { tidDatum, tidKlockslag } from "./format";
|
import { tidDatum, tidKlockslag } from "./format";
|
||||||
import { FelsokningSkal, Panel } from "./ui";
|
import { FelsokningSkal, Panel } from "./ui";
|
||||||
|
|
||||||
export function DelatArendeVy({ arende, nu, notis }: { arende: Arende; nu: string; notis: string }) {
|
export function DelatArendeVy({
|
||||||
|
arende,
|
||||||
|
nu,
|
||||||
|
notis,
|
||||||
|
redanFiltrerad = false,
|
||||||
|
}: {
|
||||||
|
arende: Arende;
|
||||||
|
nu: string;
|
||||||
|
notis: string;
|
||||||
|
// 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å.
|
||||||
|
redanFiltrerad?: boolean;
|
||||||
|
}) {
|
||||||
const metodik = metodikForArende(arende);
|
const metodik = metodikForArende(arende);
|
||||||
const b = brief(arende, metodik, nu);
|
const b = brief(arende, metodik, nu);
|
||||||
const avslutat = arAvslutat(arende);
|
const avslutat = arAvslutat(arende);
|
||||||
const bilder = foton(arende);
|
const bilder = foton(arende);
|
||||||
const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde");
|
const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde");
|
||||||
const kundposter = arende.handelser.filter(
|
const kundposter = redanFiltrerad
|
||||||
(p) => !["kategori_byte", "hypotes", "ai_svar"].includes(p.handelse.typ),
|
? arende.handelser
|
||||||
);
|
: arende.handelser.filter((p) => !["kategori_byte", "hypotes", "ai_svar"].includes(p.handelse.typ));
|
||||||
const pagaende = b.rekommenderatNastaSteg[0];
|
const pagaende = b.rekommenderatNastaSteg[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -98,6 +98,36 @@ export async function skapaAnvandare(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live Share-delningar: återkallbara länkar med behörighetsnivå.
|
||||||
|
export type DelningsNiva = "kund" | "partner" | "intern";
|
||||||
|
|
||||||
|
export interface Delning {
|
||||||
|
kod: string;
|
||||||
|
niva: DelningsNiva;
|
||||||
|
skapad: string;
|
||||||
|
aterkallad: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hamtaDelningar(arendeId: string): Promise<Delning[]> {
|
||||||
|
const res = await plattformFetch(`/api/arenden/${arendeId}/delningar`);
|
||||||
|
if (!res.ok) throw new Error(`Fel ${res.status}`);
|
||||||
|
return ((await res.json()) as { delningar: Delning[] }).delningar;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function skapaDelning(arendeId: string, niva: DelningsNiva): Promise<string> {
|
||||||
|
const res = await plattformFetch(`/api/arenden/${arendeId}/delningar`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ niva }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Fel ${res.status}`);
|
||||||
|
return ((await res.json()) as { kod: string }).kod;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aterkallaDelning(kod: string): Promise<void> {
|
||||||
|
const res = await plattformFetch(`/api/delningar/${kod}/aterkalla`, { method: "POST" });
|
||||||
|
if (!res.ok) throw new Error(`Fel ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Organisationsöversikt (arbetsledare/admin; servern verifierar).
|
// Organisationsöversikt (arbetsledare/admin; servern verifierar).
|
||||||
export interface OversiktsRad {
|
export interface OversiktsRad {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ import {
|
|||||||
} from "@/felsokning/projektioner";
|
} from "@/felsokning/projektioner";
|
||||||
import { metodikForArende, useFelsokning } from "@/felsokning/store";
|
import { metodikForArende, useFelsokning } from "@/felsokning/store";
|
||||||
import { synkroniseraArende, type SynkStatus } from "@/felsokning/synk";
|
import { synkroniseraArende, type SynkStatus } from "@/felsokning/synk";
|
||||||
|
import {
|
||||||
|
aterkallaDelning,
|
||||||
|
hamtaDelningar,
|
||||||
|
plattformAktiv,
|
||||||
|
skapaDelning,
|
||||||
|
type Delning,
|
||||||
|
type DelningsNiva,
|
||||||
|
} from "@/felsokning/plattform";
|
||||||
import { AI_RADTYP_LABEL, fragaAi, granskaUnderlag, sammanfattaOverlamning } from "@/felsokning/ai";
|
import { AI_RADTYP_LABEL, fragaAi, granskaUnderlag, sammanfattaOverlamning } from "@/felsokning/ai";
|
||||||
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
||||||
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
|
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
|
||||||
@@ -605,6 +613,104 @@ function OverlamningDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NIVA_LABEL: Record<DelningsNiva, string> = {
|
||||||
|
kund: "Kund",
|
||||||
|
partner: "Extern partner",
|
||||||
|
intern: "Intern",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Live Share-behörighetsnivåer: skapa och återkalla delningslänkar.
|
||||||
|
// Nivåfiltreringen sker alltid på serversidan; verkstaden kontrollerar
|
||||||
|
// delningen och varje skapad/återkallad länk loggas i ärendet.
|
||||||
|
function DelningsHanterare({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
|
||||||
|
const [oppen, setOppen] = useState(false);
|
||||||
|
const [delningar, setDelningar] = useState<Delning[]>([]);
|
||||||
|
const [fel, setFel] = useState("");
|
||||||
|
|
||||||
|
const uppdatera = async () => {
|
||||||
|
try {
|
||||||
|
setDelningar(await hamtaDelningar(arende.id));
|
||||||
|
setFel("");
|
||||||
|
} catch {
|
||||||
|
setFel("Kunde inte hämta delningar — kräver inloggning och att ärendet är synkat.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!oppen) {
|
||||||
|
return (
|
||||||
|
<StorKnapp
|
||||||
|
variant="sekundar"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={() => {
|
||||||
|
setOppen(true);
|
||||||
|
uppdatera();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔗 Delningslänkar (kund/partner/intern)
|
||||||
|
</StorKnapp>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-lg border-2 border-zinc-700 bg-zinc-950 p-3">
|
||||||
|
{fel && <p className="mb-2 font-bold text-red-400">{fel}</p>}
|
||||||
|
{delningar.map((delning) => (
|
||||||
|
<div key={delning.kod} className="flex items-center justify-between gap-2 border-b border-zinc-800 py-2 last:border-0">
|
||||||
|
<span className="text-lg">
|
||||||
|
{NIVA_LABEL[delning.niva]}{" "}
|
||||||
|
{delning.aterkallad && <span className="text-sm font-bold text-red-400">(återkallad)</span>}
|
||||||
|
</span>
|
||||||
|
{!delning.aterkallad && (
|
||||||
|
<span className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded border-2 border-zinc-600 px-3 py-1 font-bold text-zinc-200 hover:border-amber-400"
|
||||||
|
onClick={() =>
|
||||||
|
navigator.clipboard.writeText(`${window.location.origin}/felsokning/delad/${delning.kod}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Kopiera
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded border-2 border-zinc-600 px-3 py-1 font-bold text-red-400 hover:border-red-400"
|
||||||
|
onClick={async () => {
|
||||||
|
await aterkallaDelning(delning.kod);
|
||||||
|
skicka({ typ: "kommentar", text: `Delningslänk (${NIVA_LABEL[delning.niva]}) återkallad.` });
|
||||||
|
uppdatera();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Återkalla
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||||
|
{(Object.keys(NIVA_LABEL) as DelningsNiva[]).map((niva) => (
|
||||||
|
<button
|
||||||
|
key={niva}
|
||||||
|
className="min-h-12 rounded-lg border-2 border-zinc-600 text-sm font-extrabold text-zinc-200 hover:border-amber-400"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await skapaDelning(arende.id, niva);
|
||||||
|
skicka({ typ: "kommentar", text: `Delningslänk skapad (${NIVA_LABEL[niva]}).` });
|
||||||
|
uppdatera();
|
||||||
|
} catch {
|
||||||
|
setFel("Kunde inte skapa delning — kräver inloggning och att ärendet är synkat.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ {NIVA_LABEL[niva]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-zinc-500">
|
||||||
|
Kund: det kunddelbara · Extern partner: även hypoteser (märkta ej verifierade) · Intern: full insyn.
|
||||||
|
Filtreringen sker på servern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LoggFlik({ arende }: { arende: Arende }) {
|
function LoggFlik({ arende }: { arende: Arende }) {
|
||||||
return (
|
return (
|
||||||
<Panel rubrik="Arbetslogg — append-only, ingenting skrivs över">
|
<Panel rubrik="Arbetslogg — append-only, ingenting skrivs över">
|
||||||
@@ -808,7 +914,10 @@ function RapportFlik({
|
|||||||
<Link to={`/felsokning/dela/${arende.id}`} className="mt-2 block">
|
<Link to={`/felsokning/dela/${arende.id}`} className="mt-2 block">
|
||||||
<StorKnapp variant="sekundar">🟢 Öppna Live Share-vy</StorKnapp>
|
<StorKnapp variant="sekundar">🟢 Öppna Live Share-vy</StorKnapp>
|
||||||
</Link>
|
</Link>
|
||||||
{arende.delningskod && (
|
{plattformAktiv() ? (
|
||||||
|
<DelningsHanterare arende={arende} skicka={skicka} />
|
||||||
|
) : (
|
||||||
|
arende.delningskod && (
|
||||||
<StorKnapp
|
<StorKnapp
|
||||||
variant="sekundar"
|
variant="sekundar"
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
@@ -819,6 +928,7 @@ function RapportFlik({
|
|||||||
>
|
>
|
||||||
🔗 Kopiera delningslänk
|
🔗 Kopiera delningslänk
|
||||||
</StorKnapp>
|
</StorKnapp>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<p className="mt-2 text-sm text-zinc-500">
|
<p className="mt-2 text-sm text-zinc-500">
|
||||||
Delningslänken kräver att ärendet är synkat mot molnet (inloggad användare).
|
Delningslänken kräver att ärendet är synkat mot molnet (inloggad användare).
|
||||||
|
|||||||
@@ -12,9 +12,17 @@ import { FelsokningSkal, Panel } from "@/felsokning/ui";
|
|||||||
interface DelatSvar {
|
interface DelatSvar {
|
||||||
arende: { id: string; nummer: number; skapad: string };
|
arende: { id: string; nummer: number; skapad: string };
|
||||||
handelser: { id: string; tidpunkt: string; anvandare: string; handelse: Handelse }[];
|
handelser: { id: string; tidpunkt: string; anvandare: string; handelse: Handelse }[];
|
||||||
|
niva?: "kund" | "partner" | "intern";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hamtaDelat(kod: string): Promise<Arende | undefined> {
|
const NIVA_NOTIS: Record<string, string> = {
|
||||||
|
kund: "Skrivskyddad livevy från verkstaden — sidan uppdateras automatiskt när ny information registreras.",
|
||||||
|
partner:
|
||||||
|
"Skrivskyddad livevy (partnernivå) — inkluderar tekniska hypoteser, tydligt märkta som ej verifierade. Uppdateras automatiskt.",
|
||||||
|
intern: "Skrivskyddad livevy (intern nivå) — full insyn i ärendet. Uppdateras automatiskt.",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function hamtaDelat(kod: string): Promise<{ arende: Arende; niva: string } | undefined> {
|
||||||
const { plattformAktiv, PLATTFORM_URL } = await import("@/felsokning/plattform");
|
const { plattformAktiv, PLATTFORM_URL } = await import("@/felsokning/plattform");
|
||||||
let data: unknown;
|
let data: unknown;
|
||||||
if (plattformAktiv()) {
|
if (plattformAktiv()) {
|
||||||
@@ -32,6 +40,8 @@ async function hamtaDelat(kod: string): Promise<Arende | undefined> {
|
|||||||
}
|
}
|
||||||
const svar = data as DelatSvar;
|
const svar = data as DelatSvar;
|
||||||
return {
|
return {
|
||||||
|
niva: svar.niva ?? "kund",
|
||||||
|
arende: {
|
||||||
id: svar.arende.id,
|
id: svar.arende.id,
|
||||||
nummer: svar.arende.nummer,
|
nummer: svar.arende.nummer,
|
||||||
skapad: new Date(svar.arende.skapad).toISOString(),
|
skapad: new Date(svar.arende.skapad).toISOString(),
|
||||||
@@ -41,12 +51,14 @@ async function hamtaDelat(kod: string): Promise<Arende | undefined> {
|
|||||||
anvandare: rad.anvandare,
|
anvandare: rad.anvandare,
|
||||||
handelse: rad.handelse,
|
handelse: rad.handelse,
|
||||||
})),
|
})),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PublikDelning() {
|
export default function PublikDelning() {
|
||||||
const { kod } = useParams<{ kod: string }>();
|
const { kod } = useParams<{ kod: string }>();
|
||||||
const [arende, setArende] = useState<Arende | undefined>();
|
const [arende, setArende] = useState<Arende | undefined>();
|
||||||
|
const [niva, setNiva] = useState<string>("kund");
|
||||||
const [status, setStatus] = useState<"laddar" | "klar" | "saknas">("laddar");
|
const [status, setStatus] = useState<"laddar" | "klar" | "saknas">("laddar");
|
||||||
const [nu, setNu] = useState(() => new Date().toISOString());
|
const [nu, setNu] = useState(() => new Date().toISOString());
|
||||||
|
|
||||||
@@ -57,7 +69,8 @@ export default function PublikDelning() {
|
|||||||
try {
|
try {
|
||||||
const resultat = await hamtaDelat(kod);
|
const resultat = await hamtaDelat(kod);
|
||||||
if (!aktiv) return;
|
if (!aktiv) return;
|
||||||
setArende(resultat);
|
setArende(resultat?.arende);
|
||||||
|
if (resultat) setNiva(resultat.niva);
|
||||||
setStatus(resultat ? "klar" : "saknas");
|
setStatus(resultat ? "klar" : "saknas");
|
||||||
setNu(new Date().toISOString());
|
setNu(new Date().toISOString());
|
||||||
} catch {
|
} catch {
|
||||||
@@ -90,7 +103,8 @@ export default function PublikDelning() {
|
|||||||
<DelatArendeVy
|
<DelatArendeVy
|
||||||
arende={arende}
|
arende={arende}
|
||||||
nu={nu}
|
nu={nu}
|
||||||
notis="Skrivskyddad livevy från verkstaden — sidan uppdateras automatiskt när ny information registreras."
|
notis={NIVA_NOTIS[niva] ?? NIVA_NOTIS.kund}
|
||||||
|
redanFiltrerad
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user