Bilagor ut ur händelseloggen — referens och hash i stället för inbäddat
Foton, video och instrumentbilder låg som data-URL:er inne i händelserna. Det drabbade allt som läser loggen: synken drog med hela bildmassan var femtonde sekund, kundvyn likaså, och en säkerhetskopia av loggen var i praktiken en kopia av alla foton. Nu ligger innehållet utanför händelsen och loggen bär en referens med innehållets SHA-256. Det försvagar inte bevisvärdet utan stärker det: hashen står i den append-only-skyddade loggen, så en bild som bytts ut går att upptäcka. Tidigare låg bilden i loggen och måste helt enkelt tros på. Innehållet kontrolleras mot hashen varje gång det lämnas ut — stämmer det inte svarar tjänsten 409 i stället för att visa bilden. Innehållsadresserat, så samma foto som dokumenteras två gånger lagras en gång. Två lägen: databas (bytea, fungerar överallt utan konfiguration) och s3 (AWS, MinIO, Ceph). Signeringen är egen — SigV4 för PUT och GET — i stället för molnleverantörens SDK, eftersom två operationer inte motiverar tiotals megabyte beroenden i en bild som annars bara har pg-drivrutinen. Den korsverifieras bit för bit mot botocore i testerna. Det avslöjade en riktig bugg direkt: host-huvudet saknade portnummer, vilket hade fungerat mot AWS men avvisats av all självhostad S3. Kodningen av objektnycklar kanoniseras medvetet inte. S3 följer andra URL-regler än övriga AWS-tjänster och en felgissad regel ger signaturer som ser rimliga ut men avvisas. I stället begränsas hink och prefix till tecken som aldrig behöver kodas — då finns ingen regel att gissa fel på. Delningsgränsen gäller även bilagor: en bilaga lämnas bara ut via en delningslänk om händelsen den hör till är synlig på den nivån, så den skannade arbetsordern nås aldrig via kundlänken. Uppladdningen sker på ett enda ställe — sidans egna skicka() flyttar innehållet innan händelsen skrivs, så ingen panel behövde ändras. Misslyckas det, eller saknas server som i lokalt läge, bäddas det in precis som förut. Dokumentationen får aldrig gå förlorad för att nätet ligger nere, och äldre händelser med inbäddad data-URL fortsätter fungera för alltid eftersom loggen är append-only. Verifierat: 96 vitest-tester (varav 9 nya för signering och innehållsadressering), typkontroll, eslint, OpenAPI-validering, terraform fmt och referenskontroll, samt integrationstest mot riktig Postgres med 13 nya kontroller — bland annat att ett manipulerat innehåll upptäcks och inte lämnas ut, och att kundlänken når fotot men inte arbetsordern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
// Visar en bilaga oavsett om innehållet ligger inbäddat i händelsen
|
||||
// (äldre ärenden, lokalt läge) eller hämtas som referens.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Bilaga } from "./domain";
|
||||
import { hamtaBilaga } from "./bilagor";
|
||||
|
||||
function useKalla(bilaga: Bilaga, delningskod?: string): string | null | "laddar" {
|
||||
const [kalla, setKalla] = useState<string | null | "laddar">("laddar");
|
||||
const id = bilaga.bilagaId ?? bilaga.dataUrl?.slice(0, 64) ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
let aktuell = true;
|
||||
setKalla("laddar");
|
||||
hamtaBilaga(bilaga, delningskod)
|
||||
.then((url) => aktuell && setKalla(url))
|
||||
.catch(() => aktuell && setKalla(null));
|
||||
return () => {
|
||||
aktuell = false;
|
||||
};
|
||||
// Bilagan identifieras av sitt id — objektet självt byts vid varje
|
||||
// omrendering utan att innehållet ändrats.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, delningskod]);
|
||||
|
||||
return kalla;
|
||||
}
|
||||
|
||||
function Platshallare({ text }: { text: string }) {
|
||||
return (
|
||||
<p className="rounded border border-dashed border-[#C6C6C6] bg-[#F7F7F7] p-3 text-[12px] text-[#707070]">
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function Bild({
|
||||
bilaga,
|
||||
alt,
|
||||
className,
|
||||
delningskod,
|
||||
}: {
|
||||
bilaga: Bilaga;
|
||||
alt: string;
|
||||
className?: string;
|
||||
delningskod?: string;
|
||||
}) {
|
||||
const kalla = useKalla(bilaga, delningskod);
|
||||
if (kalla === "laddar") return <Platshallare text="Hämtar bild …" />;
|
||||
// Ärligt om att bilden inte gick att hämta, i stället för en trasig
|
||||
// bildikon som lämnar teknikern i tvivel om vad som dokumenterats.
|
||||
if (!kalla) return <Platshallare text="Bilden kunde inte hämtas." />;
|
||||
return <img src={kalla} alt={alt} className={className} />;
|
||||
}
|
||||
|
||||
export function Klipp({
|
||||
bilaga,
|
||||
className,
|
||||
delningskod,
|
||||
}: {
|
||||
bilaga: Bilaga;
|
||||
className?: string;
|
||||
delningskod?: string;
|
||||
}) {
|
||||
const kalla = useKalla(bilaga, delningskod);
|
||||
if (kalla === "laddar") return <Platshallare text="Hämtar videoklipp …" />;
|
||||
if (!kalla) return <Platshallare text="Videoklippet kunde inte hämtas." />;
|
||||
return <video src={kalla} controls className={className} />;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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 { Bild, Klipp } from "./Bilagevisning";
|
||||
import { tidDatum, tidKlockslag } from "./format";
|
||||
import { FelsokningSkal, Panel, StorKnapp } from "./ui";
|
||||
import { IkonCheck, IkonKlocka, IkonUppdatera } from "./ikoner";
|
||||
@@ -107,10 +108,14 @@ export function DelatArendeVy({
|
||||
notis,
|
||||
redanFiltrerad = false,
|
||||
vidBeslut,
|
||||
delningskod,
|
||||
}: {
|
||||
arende: Arende;
|
||||
nu: string;
|
||||
notis: string;
|
||||
// Satt i den publika vyn: bilagor hämtas då via delningens egen väg,
|
||||
// som filtrerar på samma behörighetsnivå som händelserna.
|
||||
delningskod?: 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<string | null>;
|
||||
@@ -205,7 +210,7 @@ export function DelatArendeVy({
|
||||
<Panel rubrik="Video">
|
||||
{klipp.map((v, i) => (
|
||||
<figure key={i} className="mb-2">
|
||||
<video src={v.dataUrl} controls className="w-full rounded border border-[#C6C6C6]" />
|
||||
<Klipp bilaga={v.bilaga} delningskod={delningskod} className="w-full rounded border border-[#C6C6C6]" />
|
||||
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{v.beskrivning}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
@@ -216,7 +221,7 @@ export function DelatArendeVy({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{bilder.map((bild, i) => (
|
||||
<figure key={i}>
|
||||
<img src={bild.dataUrl} alt={bild.beskrivning} className="rounded border border-[#C6C6C6]" />
|
||||
<Bild bilaga={bild.bilaga} alt={bild.beskrivning} delningskod={delningskod} className="rounded border border-[#C6C6C6]" />
|
||||
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{bild.beskrivning}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
|
||||
@@ -62,6 +62,8 @@ describe("AI-orkestern", () => {
|
||||
|
||||
const schema = readFileSync("infra/postgres-init.sql", "utf8");
|
||||
expect(schema).toContain("create table if not exists inloggningsforsok");
|
||||
expect(schema).toContain("create table if not exists bilagor");
|
||||
expect(schema).toContain("create table if not exists bilage_innehall");
|
||||
expect(schema).toContain("token_version integer not null default 0");
|
||||
expect(schema).toContain("before update or delete on felsokning_handelser");
|
||||
expect(schema).toContain("before update or delete on felsokning_arenden");
|
||||
@@ -96,6 +98,9 @@ describe("AI-orkestern", () => {
|
||||
["/api/anvandare/{anvandarId}/avaktivera", "kontoVag"],
|
||||
["/api/anvandare/{anvandarId}/aktivera", "aktivera"],
|
||||
["/api/auth/logga-ut-alla", "/api/auth/logga-ut-alla"],
|
||||
["/api/arenden/{arendeId}/bilagor", "laddaUppVag"],
|
||||
["/api/bilagor/{bilagaId}", "bilagaVag"],
|
||||
["/api/delad/{delningskod}/bilagor/{bilagaId}", "delatBilaga"],
|
||||
];
|
||||
for (const [iSpec, iServer] of vagar) {
|
||||
expect(spec).toContain(`${iSpec}:`);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment node
|
||||
// Bilagornas två egenskaper som måste hålla: signeringen mot
|
||||
// objektlagringen är korrekt (annars fungerar inget alls), och
|
||||
// innehållsadresseringen är verkligen innehållsadresserad — hashen är
|
||||
// det som binder bilden till händelseloggen.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
innehallsHash,
|
||||
mediatypGiltig,
|
||||
signera,
|
||||
signeringsnyckel,
|
||||
skapaS3Lager,
|
||||
valjLager,
|
||||
} from "../../../services/plattform/bilagor.mjs";
|
||||
|
||||
// Referenssignaturer framtagna med botocore (AWS egen implementation) —
|
||||
// se scratchpad/sigv4-referens.py. Att vår signering ger exakt samma
|
||||
// resultat är den enda meningsfulla kontrollen: en signatur är antingen
|
||||
// bitidentisk eller värdelös.
|
||||
const REFERENS = JSON.parse(readFileSync("src/felsokning/__tests__/sigv4-referens.json", "utf8")) as {
|
||||
namn: string;
|
||||
metod: string;
|
||||
url: string;
|
||||
kropp_base64: string;
|
||||
region: string;
|
||||
tid: string;
|
||||
authorization: string;
|
||||
}[];
|
||||
|
||||
const UPPGIFTER = {
|
||||
nyckelId: "AKIDEXAMPLE",
|
||||
hemlighet: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
|
||||
};
|
||||
|
||||
describe("signering mot objektlagringen", () => {
|
||||
it("ger bitidentiska signaturer med botocore", () => {
|
||||
expect(REFERENS.length).toBeGreaterThanOrEqual(3);
|
||||
for (const fall of REFERENS) {
|
||||
const huvuden = signera(
|
||||
fall.metod,
|
||||
fall.url,
|
||||
Buffer.from(fall.kropp_base64, "base64"),
|
||||
{ ...UPPGIFTER, region: fall.region },
|
||||
fall.tid,
|
||||
);
|
||||
expect(huvuden.Authorization, fall.namn).toBe(fall.authorization);
|
||||
}
|
||||
});
|
||||
|
||||
it("härleder signeringsnyckeln enligt AWS testvektor", () => {
|
||||
// Publicerad vektor ur AWS dokumentation för nyckelhärledning.
|
||||
const nyckel = signeringsnyckel("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", "20150830", "us-east-1", "iam");
|
||||
expect(nyckel.toString("hex")).toBe(
|
||||
"c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9",
|
||||
);
|
||||
});
|
||||
|
||||
it("en ändrad byte i kroppen ger en annan signatur", () => {
|
||||
const bas = { ...UPPGIFTER, region: "eu-north-1" };
|
||||
const a = signera("PUT", "https://s3.exempel.se/hink/x", Buffer.from("hej"), bas, "20260803T120000Z");
|
||||
const b = signera("PUT", "https://s3.exempel.se/hink/x", Buffer.from("hej!"), bas, "20260803T120000Z");
|
||||
expect(a.Authorization).not.toBe(b.Authorization);
|
||||
expect(a["x-amz-content-sha256"]).not.toBe(b["x-amz-content-sha256"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("innehållsadressering", () => {
|
||||
it("samma innehåll ger samma nyckel, olika innehåll olika", () => {
|
||||
expect(innehallsHash(Buffer.from("samma"))).toBe(innehallsHash(Buffer.from("samma")));
|
||||
expect(innehallsHash(Buffer.from("a"))).not.toBe(innehallsHash(Buffer.from("b")));
|
||||
expect(innehallsHash(Buffer.from(""))).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("objektet läggs under sin egen hash — identiska filer lagras en gång", async () => {
|
||||
const skrivna: string[] = [];
|
||||
const lager = skapaS3Lager(
|
||||
{ endpoint: "https://s3.exempel.se", hink: "hink", region: "eu-north-1", prefix: "bilagor", ...UPPGIFTER },
|
||||
async (url: string, init: { method: string }) => {
|
||||
if (init.method === "PUT") skrivna.push(url);
|
||||
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
||||
},
|
||||
);
|
||||
|
||||
const data = Buffer.from("ett foto");
|
||||
const hash = innehallsHash(data);
|
||||
await lager.spara(hash, data);
|
||||
await lager.spara(hash, data);
|
||||
|
||||
// Båda skrivningarna pekar på samma nyckel: lagringen skriver över
|
||||
// sig själv i stället för att lägga till en dubblett.
|
||||
expect(new Set(skrivna).size).toBe(1);
|
||||
expect(skrivna[0]).toBe(`https://s3.exempel.se/hink/bilagor/${hash.slice(0, 2)}/${hash}`);
|
||||
});
|
||||
|
||||
it("saknat objekt ger null, fel från lagringen kastas vidare", async () => {
|
||||
const svar = { status: 404, ok: false };
|
||||
const lager = skapaS3Lager(
|
||||
{ endpoint: "https://s3.exempel.se", hink: "h", region: "eu-north-1", ...UPPGIFTER },
|
||||
async () => svar,
|
||||
);
|
||||
expect(await lager.hamta("a".repeat(64))).toBeNull();
|
||||
|
||||
svar.status = 500;
|
||||
await expect(lager.hamta("a".repeat(64))).rejects.toThrow("500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("val av lager", () => {
|
||||
it("standard är databasen — fungerar utan konfiguration", () => {
|
||||
expect(valjLager({}, null).namn).toBe("databas");
|
||||
expect(valjLager({ BILAGE_LAGE: "databas" }, null).namn).toBe("databas");
|
||||
});
|
||||
|
||||
it("s3 utan fullständig konfiguration failar direkt, inte vid första uppladdningen", () => {
|
||||
expect(() => valjLager({ BILAGE_LAGE: "s3" }, null)).toThrow(/S3_ENDPOINT/);
|
||||
expect(() =>
|
||||
valjLager({ BILAGE_LAGE: "s3", S3_ENDPOINT: "https://s3.exempel.se", S3_HINK: "h" }, null),
|
||||
).toThrow(/S3_REGION/);
|
||||
expect(
|
||||
valjLager(
|
||||
{
|
||||
BILAGE_LAGE: "s3",
|
||||
S3_ENDPOINT: "https://s3.exempel.se",
|
||||
S3_HINK: "h",
|
||||
S3_REGION: "eu-north-1",
|
||||
S3_NYCKEL_ID: "a",
|
||||
S3_NYCKEL: "b",
|
||||
},
|
||||
null,
|
||||
).namn,
|
||||
).toBe("s3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("vad som får laddas upp", () => {
|
||||
it("bilder och video, inget annat", () => {
|
||||
for (const typ of ["image/jpeg", "image/png", "image/webp", "video/mp4", "video/webm", "IMAGE/JPEG"]) {
|
||||
expect(mediatypGiltig(typ), typ).toBe(true);
|
||||
}
|
||||
expect(mediatypGiltig("image/jpeg; charset=binary")).toBe(true);
|
||||
for (const typ of ["application/pdf", "text/html", "application/octet-stream", "", undefined]) {
|
||||
expect(mediatypGiltig(typ as string), String(typ)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
[
|
||||
{
|
||||
"namn": "put-liten",
|
||||
"metod": "PUT",
|
||||
"url": "https://s3.eu-north-1.amazonaws.com/verkstad-bilagor/bilagor/ab/abcdef",
|
||||
"kropp_base64": "aGVq",
|
||||
"region": "eu-north-1",
|
||||
"tid": "20260804T111952Z",
|
||||
"authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20260804/eu-north-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=20ee842bb592f8af0cfe2dbf1e697693b12de1aa677812a1a12628687f4ab102"
|
||||
},
|
||||
{
|
||||
"namn": "get-tom",
|
||||
"metod": "GET",
|
||||
"url": "https://s3.eu-north-1.amazonaws.com/verkstad-bilagor/bilagor/ff/ffff",
|
||||
"kropp_base64": "",
|
||||
"region": "eu-north-1",
|
||||
"tid": "20260804T111952Z",
|
||||
"authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20260804/eu-north-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=7002e3616b3ab2a3a2a55927827afbe9e197eb47e4ebfe6def73b56de5c2f266"
|
||||
},
|
||||
{
|
||||
"namn": "annan-endpoint",
|
||||
"metod": "PUT",
|
||||
"url": "https://minio.exempel.se:9000/hink/verkstad-1/bilagor/0f/0f9a2b",
|
||||
"kropp_base64": "AAECYmluYXJ0",
|
||||
"region": "us-east-1",
|
||||
"tid": "20260804T111952Z",
|
||||
"authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20260804/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=09e038fa4e0ba6b967bfd8e88dde00285f3090f7609c935eabe04c4eae68758e"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
// Bilagor på klientsidan.
|
||||
//
|
||||
// Innehållet ligger inte längre i händelsen utan hämtas separat. Det gör
|
||||
// synken lätt igen: en ärendesynk var femtonde sekund drar inte med sig
|
||||
// hela bildmassan, och kundvyn laddar bara de bilder som faktiskt visas.
|
||||
//
|
||||
// I lokalt läge (ingen inloggning) finns ingen server att ladda upp till.
|
||||
// Då används data-URL:en precis som förut — appen ska fungera utan
|
||||
// backend, och en tekniker som dokumenterar offline får inte tappa något.
|
||||
|
||||
import type { Bilaga } from "./domain";
|
||||
|
||||
// Objekt-URL:er per bilage-id. Innehållsadresserat på servern, så en
|
||||
// hämtad bilaga kan cachas obegränsat under sidans livstid.
|
||||
const cache = new Map<string, Promise<string | null>>();
|
||||
|
||||
export function harBilaga(b: Bilaga | undefined): boolean {
|
||||
return !!b && (!!b.bilagaId || !!b.dataUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Laddar upp innehållet och returnerar referensen som ska sparas i
|
||||
* händelsen. Returnerar null i lokalt läge — då sparas data-URL:en i
|
||||
* stället.
|
||||
*/
|
||||
export async function laddaUppBilaga(
|
||||
arendeId: string,
|
||||
dataUrl: string,
|
||||
): Promise<{ bilagaId: string; bilagaHash: string } | null> {
|
||||
const { plattformAktiv, plattformToken, PLATTFORM_URL } = await import("./plattform");
|
||||
if (!plattformAktiv() || !plattformToken()) return null;
|
||||
|
||||
const svar = await fetch(dataUrl);
|
||||
const blob = await svar.blob();
|
||||
|
||||
const res = await fetch(`${PLATTFORM_URL}/api/arenden/${arendeId}/bilagor`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": blob.type || "application/octet-stream",
|
||||
Authorization: `Bearer ${plattformToken()}`,
|
||||
},
|
||||
body: blob,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Bilagan kunde inte sparas (${res.status}).`);
|
||||
const { id, hash } = (await res.json()) as { id: string; hash: string };
|
||||
return { bilagaId: id, bilagaHash: hash };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bygger det som ska in i händelsen. Lyckas uppladdningen bär händelsen
|
||||
* en referens; annars data-URL:en. Det senare är inte ett fel utan
|
||||
* lokalt läge — dokumentationen får aldrig gå förlorad för att nätet
|
||||
* ligger nere.
|
||||
*/
|
||||
export async function bilageFalt(arendeId: string, dataUrl: string): Promise<Bilaga> {
|
||||
try {
|
||||
const referens = await laddaUppBilaga(arendeId, dataUrl);
|
||||
if (referens) return referens;
|
||||
} catch {
|
||||
// Faller igenom till inbäddning nedan.
|
||||
}
|
||||
return { dataUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Var innehållet finns att hämta. `delningskod` sätts i den publika
|
||||
* vyn — där går hämtningen via delningens egen väg, som filtrerar på
|
||||
* samma nivå som händelserna.
|
||||
*/
|
||||
export async function hamtaBilaga(b: Bilaga, delningskod?: string): Promise<string | null> {
|
||||
if (b.dataUrl) return b.dataUrl;
|
||||
if (!b.bilagaId) return null;
|
||||
|
||||
const nyckel = `${delningskod ?? ""}:${b.bilagaId}`;
|
||||
const befintlig = cache.get(nyckel);
|
||||
if (befintlig) return befintlig;
|
||||
|
||||
const hamtning = (async () => {
|
||||
const { PLATTFORM_URL, plattformToken } = await import("./plattform");
|
||||
if (!PLATTFORM_URL) return null;
|
||||
|
||||
const url = delningskod
|
||||
? `${PLATTFORM_URL}/api/delad/${delningskod}/bilagor/${b.bilagaId}`
|
||||
: `${PLATTFORM_URL}/api/bilagor/${b.bilagaId}`;
|
||||
const token = plattformToken();
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: !delningskod && token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return URL.createObjectURL(await res.blob());
|
||||
})();
|
||||
|
||||
cache.set(nyckel, hamtning);
|
||||
return hamtning;
|
||||
}
|
||||
@@ -73,9 +73,23 @@ export interface ArbetsorderFalt {
|
||||
}
|
||||
|
||||
// Varje händelse är en av dessa typer. Ingen händelse ändras eller tas bort.
|
||||
// Bilagans innehåll ligger utanför händelsen: loggen bär referensen och
|
||||
// innehållets SHA-256. Hashen står därmed i den append-only-skyddade
|
||||
// loggen och en utbytt bild går att upptäcka.
|
||||
//
|
||||
// dataUrl finns kvar och kommer alltid att finnas kvar. Loggen är
|
||||
// append-only, så händelser som skrevs när bilden låg inbäddad måste
|
||||
// gå att läsa för alltid. Lokalt läge (utan inloggning) använder den
|
||||
// också — där finns ingen server att ladda upp till.
|
||||
export interface Bilaga {
|
||||
bilagaId?: string;
|
||||
bilagaHash?: string;
|
||||
dataUrl?: string;
|
||||
}
|
||||
|
||||
export type Handelse =
|
||||
| { typ: "objekt_identifierat"; objekt: Objekt }
|
||||
| { typ: "arbetsorder_skannad"; falt: ArbetsorderFalt[]; dataUrl?: string }
|
||||
| ({ typ: "arbetsorder_skannad"; falt: ArbetsorderFalt[] } & Bilaga)
|
||||
| { typ: "felbeskrivning"; text: string }
|
||||
| { typ: "fraga_besvarad"; stegId: string; frageId: string; fraga: string; svar: string }
|
||||
// undantag: underlaget kunde inte tas fram — obligatorisk orsak i stället
|
||||
@@ -84,11 +98,11 @@ export type Handelse =
|
||||
| { typ: "observation"; text: string }
|
||||
| { typ: "matvarde"; beskrivning: string; varde: string; enhet?: string }
|
||||
| { typ: "hypotes"; text: string; niva: Exclude<Tillforlitlighet, "hog"> }
|
||||
| { typ: "foto"; beskrivning: string; dataUrl: string }
|
||||
| ({ typ: "foto"; beskrivning: string } & Bilaga)
|
||||
// Video med ljud (E3-evidens): för det som låter eller rör sig —
|
||||
// motorljud, hjullager, fjädringsrörelse. Kort och komprimerad;
|
||||
// originalfilen bevaras som evidens precis som foton.
|
||||
| { typ: "video"; beskrivning: string; dataUrl: string }
|
||||
| ({ typ: "video"; beskrivning: string } & Bilaga)
|
||||
| { typ: "kommentar"; text: string }
|
||||
| { typ: "kategori_byte"; kategori: TidKategori }
|
||||
| { typ: "inaktivitet_forklarad"; text: string; minuter: number }
|
||||
@@ -101,7 +115,7 @@ export type Handelse =
|
||||
// varför inte (kvalitetsvarning).
|
||||
| { typ: "historik_kontrollerad"; kontrollerad: boolean; kommentar?: string }
|
||||
// Officiell mätarställning in/ut, normalt med foto av instrumentpanelen.
|
||||
| { typ: "matarstallning"; lage: "ingaende" | "utgaende"; varde: string; dataUrl?: string; undantag?: string }
|
||||
| ({ typ: "matarstallning"; lage: "ingaende" | "utgaende"; varde: string; undantag?: string } & Bilaga)
|
||||
// Symptomverifiering (SVP): kundens beskrivning är inte ett konstaterat
|
||||
// fel förrän den reproducerats — eller dokumenterats som ej
|
||||
// reproducerbar med motivering.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// funktioner av händelseloggen. De kan alltid regenereras — loggen vinner
|
||||
// om en vy och loggen skulle säga olika saker.
|
||||
|
||||
import type { Arende, LoggPost, Objekt, TidKategori, Tillforlitlighet } from "./domain";
|
||||
import type { Arende, Bilaga, LoggPost, Objekt, TidKategori, Tillforlitlighet } from "./domain";
|
||||
import { TIDKATEGORI_LABEL } from "./domain";
|
||||
import type { Metodik } from "./metodik";
|
||||
import { nastaSteg } from "./metodik";
|
||||
@@ -172,21 +172,21 @@ export function hypoteser(arende: Arende): { text: string; niva: Tillforlitlighe
|
||||
return resultat;
|
||||
}
|
||||
|
||||
export function foton(arende: Arende): { beskrivning: string; dataUrl: string; tidpunkt: string }[] {
|
||||
const resultat: { beskrivning: string; dataUrl: string; tidpunkt: string }[] = [];
|
||||
export function foton(arende: Arende): { beskrivning: string; bilaga: Bilaga; tidpunkt: string }[] {
|
||||
const resultat: { beskrivning: string; bilaga: Bilaga; tidpunkt: string }[] = [];
|
||||
for (const post of arende.handelser) {
|
||||
if (post.handelse.typ === "foto") {
|
||||
resultat.push({ beskrivning: post.handelse.beskrivning, dataUrl: post.handelse.dataUrl, tidpunkt: post.tidpunkt });
|
||||
resultat.push({ beskrivning: post.handelse.beskrivning, bilaga: post.handelse, tidpunkt: post.tidpunkt });
|
||||
}
|
||||
}
|
||||
return resultat;
|
||||
}
|
||||
|
||||
export function videor(arende: Arende): { beskrivning: string; dataUrl: string; tidpunkt: string }[] {
|
||||
const resultat: { beskrivning: string; dataUrl: string; tidpunkt: string }[] = [];
|
||||
export function videor(arende: Arende): { beskrivning: string; bilaga: Bilaga; tidpunkt: string }[] {
|
||||
const resultat: { beskrivning: string; bilaga: Bilaga; tidpunkt: string }[] = [];
|
||||
for (const post of arende.handelser) {
|
||||
if (post.handelse.typ === "video") {
|
||||
resultat.push({ beskrivning: post.handelse.beskrivning, dataUrl: post.handelse.dataUrl, tidpunkt: post.tidpunkt });
|
||||
resultat.push({ beskrivning: post.handelse.beskrivning, bilaga: post.handelse, tidpunkt: post.tidpunkt });
|
||||
}
|
||||
}
|
||||
return resultat;
|
||||
|
||||
@@ -68,6 +68,8 @@ import {
|
||||
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
||||
import { lasVideo, skalaNerFoto, tidDatum, tidKlockslag } from "@/felsokning/format";
|
||||
import { IkonCheck, IkonKamera, IkonKryss, IkonLank, IkonPunkt, IkonSok, IkonVarning } from "@/felsokning/ikoner";
|
||||
import { Bild, Klipp } from "@/felsokning/Bilagevisning";
|
||||
import { bilageFalt } from "@/felsokning/bilagor";
|
||||
|
||||
const FLIKAR = [
|
||||
{ id: "guide", label: "Guide" },
|
||||
@@ -144,7 +146,20 @@ export default function ArendeSida() {
|
||||
const o = objekt(arende);
|
||||
const avslutat = arAvslutat(arende);
|
||||
const total = formateraTid(tidsfordelning(arende, nu).totalMs);
|
||||
const skicka = (h: Handelse) => laggTill(id, h);
|
||||
// Ett ställe flyttar bilagornas innehåll ut ur händelsen. Alla
|
||||
// panelerna nedan skickar som förut med en inbäddad data-URL; här
|
||||
// laddas den upp och händelsen får i stället en referens med
|
||||
// innehållets hash. Misslyckas uppladdningen — eller finns ingen
|
||||
// server, som i lokalt läge — sparas den inbäddat precis som förut.
|
||||
// Dokumentationen får aldrig gå förlorad för att nätet ligger nere.
|
||||
const skicka = (h: Handelse) => {
|
||||
if (!("dataUrl" in h) || !h.dataUrl) {
|
||||
laggTill(id, h);
|
||||
return;
|
||||
}
|
||||
const { dataUrl, ...utan } = h;
|
||||
void bilageFalt(id, dataUrl).then((bilaga) => laggTill(id, { ...utan, ...bilaga } as Handelse));
|
||||
};
|
||||
|
||||
return (
|
||||
<FelsokningSkal
|
||||
@@ -1991,10 +2006,10 @@ function LoggFlik({ arende }: { arende: Arende }) {
|
||||
<p className="text-[13px] text-[#1A1A1A]">{handelseRubrik(post)}</p>
|
||||
<p className="text-[11px] text-[#707070]">{post.anvandare}</p>
|
||||
{post.handelse.typ === "foto" && (
|
||||
<img src={post.handelse.dataUrl} alt={post.handelse.beskrivning} className="mt-1 max-h-40 rounded border border-[#C6C6C6]" />
|
||||
<Bild bilaga={post.handelse} alt={post.handelse.beskrivning} className="mt-1 max-h-40 rounded border border-[#C6C6C6]" />
|
||||
)}
|
||||
{post.handelse.typ === "video" && (
|
||||
<video src={post.handelse.dataUrl} controls className="mt-1 max-h-40 rounded border border-[#C6C6C6]" />
|
||||
<Klipp bilaga={post.handelse} className="mt-1 max-h-40 rounded border border-[#C6C6C6]" />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
@@ -2385,7 +2400,7 @@ function RapportFlik({
|
||||
<Panel rubrik="Dokumenterad video">
|
||||
{klipp.map((v, i) => (
|
||||
<figure key={i} className="mb-2">
|
||||
<video src={v.dataUrl} controls className="w-full rounded border border-[#C6C6C6]" />
|
||||
<Klipp bilaga={v.bilaga} className="w-full rounded border border-[#C6C6C6]" />
|
||||
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{v.beskrivning}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
@@ -2396,7 +2411,7 @@ function RapportFlik({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{bilder.map((bild, i) => (
|
||||
<figure key={i}>
|
||||
<img src={bild.dataUrl} alt={bild.beskrivning} className="rounded border border-[#C6C6C6]" />
|
||||
<Bild bilaga={bild.bilaga} alt={bild.beskrivning} className="rounded border border-[#C6C6C6]" />
|
||||
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{bild.beskrivning}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
|
||||
@@ -133,6 +133,7 @@ export default function PublikDelning() {
|
||||
notis={NIVA_NOTIS[niva] ?? NIVA_NOTIS.kund}
|
||||
redanFiltrerad
|
||||
vidBeslut={skickaBeslut}
|
||||
delningskod={kod}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user