Merge main: Guidad Felsökning flyttar ur roten till felsokning/
Main är sedan den här grenen skapades en helt annan produkt — Semantika,
en mobilapp med egen CDK-infrastruktur. Den äger nu repots rot: en
npm-workspaces-monorepo med apps/mobile, services/api och infra.
För att båda ska rymmas i samma repo flyttar Guidad Felsökning in i en
egen katalog i stället för att göra anspråk på roten:
felsokning/app webbklienten (Vite, egen package.json och
eslint-/vitest-konfiguration)
felsokning/services plattformstjänsten och AI-orkestern
felsokning/infra Terraform och databasschemat
felsokning/docs vision, moduler, drift
felsokning/supabase edge-funktion och migrationer
Merge:n hade tagit bort 128 filer som Guidad Felsökning bygger på —
värdapplikationens komponenter, Supabase-klienten, tillgångar — eftersom
main raderat dem och den här grenen inte råkat ändra just dem. De är
återställda på sin nya plats. Utan dem gick varken bygget eller
testerna: ai.ts och synk.ts importerar Supabase-klienten.
Semantikas rotfiler är orörda: package.json, eslint.config.js och
.github/workflows/ är deras. Guidad Felsökning har egna motsvarigheter i
sin katalog.
CI flyttar samtidigt från GitHub Actions till .gitea/workflows — samma
syntax, egna runners. .github/workflows/ tillhör Semantika härefter.
Verifierat på den nya platsen: 96 vitest-tester, typkontroll, eslint på
både klient och tjänster, bygge, och integrationstest mot riktig Postgres.
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,13 @@
|
||||
# Plattformstjänsten — självhostad backend (auth, händelse-API, delning).
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
|
||||
COPY server.mjs bilagor.mjs openapi.yaml ecm-regler.json integrationer.json ./
|
||||
|
||||
ENV NODE_ENV=production PORT=8080
|
||||
USER node
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK CMD wget -qO- http://127.0.0.1:8080/halsa || exit 1
|
||||
CMD ["node", "server.mjs"]
|
||||
@@ -0,0 +1,220 @@
|
||||
// Bilagor — foton, videoklipp och instrumentbilder.
|
||||
//
|
||||
// Tidigare låg de som data-URL:er inne i händelserna. Det gjorde
|
||||
// händelseloggen tung på ett sätt som drabbade allt som läser den:
|
||||
// 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
|
||||
// SHA-256 av innehållet. Det är inte en försvagning av bevisvärdet utan
|
||||
// en förstärkning: 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ållsadressering ger dedup på köpet — samma foto som dokumenteras
|
||||
// två gånger lagras en gång.
|
||||
//
|
||||
// Två lager, valda med BILAGE_LAGE:
|
||||
// databas bytea i en egen tabell (standard, fungerar överallt)
|
||||
// s3 S3-kompatibel objektlagring — loggen och bilderna växer
|
||||
// då oberoende av varandra
|
||||
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
|
||||
export function innehallsHash(buffert) {
|
||||
return createHash("sha256").update(buffert).digest("hex");
|
||||
}
|
||||
|
||||
// Vad som får laddas upp. Listan är avsiktligt kort: det här är
|
||||
// dokumentation av ett fordon, inte en filserver.
|
||||
export const TILLATNA_MEDIATYPER = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
];
|
||||
|
||||
export function mediatypGiltig(typ) {
|
||||
return TILLATNA_MEDIATYPER.includes((typ ?? "").split(";")[0].trim().toLowerCase());
|
||||
}
|
||||
|
||||
// ---- AWS Signature Version 4 -------------------------------------------
|
||||
//
|
||||
// Egen implementation i stället för molnleverantörens SDK: tjänsten
|
||||
// behöver två operationer (PUT och GET av ett objekt) och SDK:t hade
|
||||
// dragit in tiotals megabyte beroenden i en bild som annars bara har
|
||||
// pg-drivrutinen. Signeringen korsverifieras mot botocore i testerna.
|
||||
|
||||
const hmac = (nyckel, data) => createHmac("sha256", nyckel).update(data, "utf8").digest();
|
||||
|
||||
export function signeringsnyckel(hemlighet, datum, region, tjanst) {
|
||||
const d = hmac(`AWS4${hemlighet}`, datum);
|
||||
const r = hmac(d, region);
|
||||
const t = hmac(r, tjanst);
|
||||
return hmac(t, "aws4_request");
|
||||
}
|
||||
|
||||
// Sökvägen kanoniseras INTE här, och det är ett medvetet val.
|
||||
//
|
||||
// S3 följer andra URL-kodningsregler än övriga AWS-tjänster, och en
|
||||
// felgissad regel ger signaturer som ser rimliga ut men avvisas. I
|
||||
// stället begränsas det som kan hamna i en nyckel (se namnGiltigt) till
|
||||
// tecken som aldrig behöver kodas: hink och prefix är [a-z0-9.-/] och
|
||||
// resten av nyckeln är hexadecimal ur innehållshashen. Då finns ingen
|
||||
// kodningsfråga att gissa fel på.
|
||||
export const NAMN_MONSTER = /^[a-z0-9][a-z0-9./-]*$/;
|
||||
|
||||
export function namnGiltigt(namn) {
|
||||
return typeof namn === "string" && namn.length <= 128 && NAMN_MONSTER.test(namn) && !namn.includes("..");
|
||||
}
|
||||
|
||||
/**
|
||||
* Bygger Authorization-huvudet för en S3-förfrågan.
|
||||
*
|
||||
* @param metod HTTP-metod, t.ex. "PUT"
|
||||
* @param url fullständig URL till objektet
|
||||
* @param nyttolast kroppen (Buffer) — tom Buffer för GET
|
||||
* @param uppgifter { nyckelId, hemlighet, region }
|
||||
* @param tidsstampel ISO-basic, t.ex. "20260803T120000Z"
|
||||
*/
|
||||
export function signera(metod, url, nyttolast, uppgifter, tidsstampel) {
|
||||
// host, inte hostname: vid en icke-standardport måste porten med i
|
||||
// huvudet, annars avvisar självhostad S3 (MinIO, Ceph) signaturen.
|
||||
const { host, pathname, search } = new URL(url);
|
||||
const datum = tidsstampel.slice(0, 8);
|
||||
const nyttolastHash = innehallsHash(nyttolast);
|
||||
|
||||
const huvuden = {
|
||||
host,
|
||||
"x-amz-content-sha256": nyttolastHash,
|
||||
"x-amz-date": tidsstampel,
|
||||
};
|
||||
const signerade = Object.keys(huvuden).sort();
|
||||
const kanoniskaHuvuden = signerade.map((n) => `${n}:${huvuden[n].trim()}\n`).join("");
|
||||
const signeradeNamn = signerade.join(";");
|
||||
|
||||
// Frågesträngen ska vara sorterad och kodad. Vi använder inga
|
||||
// parametrar i dag, men kanoniseringen måste ändå vara rätt.
|
||||
const parametrar = [...new URLSearchParams(search).entries()]
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([n, v]) => `${encodeURIComponent(n)}=${encodeURIComponent(v)}`)
|
||||
.join("&");
|
||||
|
||||
const kanoniskForfragan = [
|
||||
metod,
|
||||
pathname,
|
||||
parametrar,
|
||||
kanoniskaHuvuden,
|
||||
signeradeNamn,
|
||||
nyttolastHash,
|
||||
].join("\n");
|
||||
|
||||
const omfang = `${datum}/${uppgifter.region}/s3/aws4_request`;
|
||||
const attSignera = [
|
||||
"AWS4-HMAC-SHA256",
|
||||
tidsstampel,
|
||||
omfang,
|
||||
innehallsHash(Buffer.from(kanoniskForfragan, "utf8")),
|
||||
].join("\n");
|
||||
|
||||
const signatur = createHmac("sha256", signeringsnyckel(uppgifter.hemlighet, datum, uppgifter.region, "s3"))
|
||||
.update(attSignera, "utf8")
|
||||
.digest("hex");
|
||||
|
||||
return {
|
||||
...huvuden,
|
||||
Authorization:
|
||||
`AWS4-HMAC-SHA256 Credential=${uppgifter.nyckelId}/${omfang}, ` +
|
||||
`SignedHeaders=${signeradeNamn}, Signature=${signatur}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Lager --------------------------------------------------------------
|
||||
|
||||
// Innehållsadresserat: nyckeln ÄR hashen. Två identiska filer blir en.
|
||||
function objektnyckel(prefix, hash) {
|
||||
const rent = prefix.replace(/^\/+|\/+$/g, "");
|
||||
return `${rent ? `${rent}/` : ""}${hash.slice(0, 2)}/${hash}`;
|
||||
}
|
||||
|
||||
export function skapaS3Lager(konfig, hamtare = fetch, nu = () => new Date()) {
|
||||
const prefix = (konfig.prefix ?? "").replace(/^\/+|\/+$/g, "");
|
||||
if (!namnGiltigt(konfig.hink) || (prefix !== "" && !namnGiltigt(prefix))) {
|
||||
throw new Error(
|
||||
"S3_HINK och S3_PREFIX får bara innehålla a-z, 0-9, punkt, bindestreck och snedstreck.",
|
||||
);
|
||||
}
|
||||
const bas = konfig.endpoint.replace(/\/$/, "");
|
||||
|
||||
const url = (hash) => `${bas}/${konfig.hink}/${objektnyckel(prefix, hash)}`;
|
||||
const stampel = () => nu().toISOString().replace(/[-:]|\.\d{3}/g, "");
|
||||
|
||||
return {
|
||||
namn: "s3",
|
||||
|
||||
async spara(hash, data) {
|
||||
const mal = url(hash);
|
||||
const svar = await hamtare(mal, {
|
||||
method: "PUT",
|
||||
headers: signera("PUT", mal, data, konfig, stampel()),
|
||||
body: data,
|
||||
});
|
||||
if (!svar.ok) throw new Error(`Objektlagringen svarade ${svar.status} vid skrivning.`);
|
||||
},
|
||||
|
||||
async hamta(hash) {
|
||||
const mal = url(hash);
|
||||
const svar = await hamtare(mal, {
|
||||
method: "GET",
|
||||
headers: signera("GET", mal, Buffer.alloc(0), konfig, stampel()),
|
||||
});
|
||||
if (svar.status === 404) return null;
|
||||
if (!svar.ok) throw new Error(`Objektlagringen svarade ${svar.status} vid läsning.`);
|
||||
return Buffer.from(await svar.arrayBuffer());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function skapaDatabasLager(pool) {
|
||||
return {
|
||||
namn: "databas",
|
||||
|
||||
async spara(hash, data) {
|
||||
// Innehållsadresserat: finns hashen redan är filen redan sparad.
|
||||
await pool.query(
|
||||
`insert into bilage_innehall (hash, data) values ($1, $2) on conflict (hash) do nothing`,
|
||||
[hash, data],
|
||||
);
|
||||
},
|
||||
|
||||
async hamta(hash) {
|
||||
const rad = await pool.query(`select data from bilage_innehall where hash = $1`, [hash]);
|
||||
return rad.rowCount === 0 ? null : rad.rows[0].data;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Väljer lager ur miljön. Saknas något som s3-läget kräver failar vi
|
||||
// hellre vid start än vid första uppladdningen.
|
||||
export function valjLager(env, pool, hamtare = fetch) {
|
||||
if ((env.BILAGE_LAGE ?? "databas") !== "s3") return skapaDatabasLager(pool);
|
||||
|
||||
const saknas = ["S3_ENDPOINT", "S3_HINK", "S3_REGION", "S3_NYCKEL_ID", "S3_NYCKEL"].filter((n) => !env[n]);
|
||||
if (saknas.length > 0) {
|
||||
throw new Error(`BILAGE_LAGE=s3 kräver ${saknas.join(", ")}.`);
|
||||
}
|
||||
|
||||
return skapaS3Lager(
|
||||
{
|
||||
endpoint: env.S3_ENDPOINT,
|
||||
hink: env.S3_HINK,
|
||||
region: env.S3_REGION,
|
||||
prefix: env.S3_PREFIX ?? "bilagor",
|
||||
nyckelId: env.S3_NYCKEL_ID,
|
||||
hemlighet: env.S3_NYCKEL,
|
||||
},
|
||||
hamtare,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"beskrivning": "ECM Knowledge Library — serverdistribuerat regelpaket. Uppdateras utan appändring: redigera filen (eller montera en ConfigMap över den i klustret) och starta om tjänsten. Klienten hämtar paketet vid inloggning och faller tillbaka till sitt inbyggda standardpaket offline.",
|
||||
"arendetypRegler": {
|
||||
"Garanti": [
|
||||
{ "id": "garanti_miltal", "rubrik": "Miltal dokumenterat", "krav": "miltal", "detaljVidBrist": "Garantiärenden kräver dokumenterad mätarställning." },
|
||||
{ "id": "garanti_historik", "rubrik": "Servicehistorik kontrollerad", "krav": "historik", "detaljVidBrist": "Garantiärenden kräver kontrollerad servicehistorik." },
|
||||
{ "id": "garanti_claim", "rubrik": "Claim-/garantinummer registrerat", "krav": "claim", "detaljVidBrist": "Ange claim-/garantinummer (läses ur arbetsordern)." }
|
||||
],
|
||||
"Goodwill": [
|
||||
{ "id": "goodwill_miltal", "rubrik": "Miltal dokumenterat", "krav": "miltal", "detaljVidBrist": "Goodwillärenden kräver dokumenterad mätarställning." },
|
||||
{ "id": "goodwill_historik", "rubrik": "Servicehistorik kontrollerad", "krav": "historik", "detaljVidBrist": "Goodwillärenden kräver kontrollerad servicehistorik." }
|
||||
],
|
||||
"Försäkring": [
|
||||
{ "id": "forsakring_skadenummer", "rubrik": "Skadenummer registrerat", "krav": "skadenummer", "detaljVidBrist": "Försäkringsärenden kräver skadenummer (läses ur arbetsordern)." },
|
||||
{ "id": "forsakring_bildbevis", "rubrik": "Bildbevis finns", "krav": "foto", "detaljVidBrist": "Försäkringsärenden kräver bilddokumentation." }
|
||||
],
|
||||
"Reklamation": [
|
||||
{ "id": "reklamation_historik", "rubrik": "Historik och tidigare försök kontrollerade", "krav": "historik", "detaljVidBrist": "Reklamationer kräver kontrollerad historik (tidigare reparationer/försök)." }
|
||||
],
|
||||
"Begagnatgaranti": [
|
||||
{ "id": "begagnat_miltal", "rubrik": "Miltal dokumenterat", "krav": "miltal", "detaljVidBrist": "Begagnatgaranti kräver dokumenterad mätarställning." }
|
||||
]
|
||||
},
|
||||
"undantagsorsaker": [
|
||||
"Komponenten är oåtkomlig",
|
||||
"Fordonet kan inte lyftas säkert",
|
||||
"Kunden avböjde demontering",
|
||||
"Dålig sikt/åtkomst",
|
||||
"Utrustning saknas"
|
||||
],
|
||||
"orsakskategorier": [
|
||||
"Normalt slitage",
|
||||
"Ålder",
|
||||
"Körsträcka",
|
||||
"Materialutmattning",
|
||||
"Tillverkningsfel",
|
||||
"Bristande underhåll",
|
||||
"Felaktig tidigare reparation",
|
||||
"Yttre påverkan",
|
||||
"Korrosion",
|
||||
"Överhettning",
|
||||
"Förorening",
|
||||
"Felaktig användning",
|
||||
"Modifiering",
|
||||
"Olycka eller skada",
|
||||
"Okänd orsak"
|
||||
],
|
||||
"underlagskallor": [
|
||||
"Foto",
|
||||
"Video",
|
||||
"Mätresultat",
|
||||
"Diagnosutläsning",
|
||||
"Tidigare historik",
|
||||
"Servicehistorik",
|
||||
"Teknisk dokumentation",
|
||||
"Direkt observation"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"beskrivning": "Register över märkesspecifika kopplingar. Leverantörer är data, inte kod: nya märken/dataleverantörer läggs till här (eller via ConfigMap-mount, INTEGRATIONER_FIL) utan att applikationen byggs om. Fält märkta hemlig=true maskeras alltid i API-svar och lagras krypterat.",
|
||||
"leverantorer": [
|
||||
{
|
||||
"id": "generisk_vin",
|
||||
"namn": "Generisk VIN-tjänst",
|
||||
"beskrivning": "Valfri dataleverantör med VIN-uppslag över HTTP. Ange bas-URL och nyckel enligt leverantörens dokumentation.",
|
||||
"falt": [
|
||||
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false },
|
||||
{ "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true }
|
||||
],
|
||||
"uppslag": {
|
||||
"urlFalt": "bas_url",
|
||||
"auth": "bearer",
|
||||
"authFalt": "api_nyckel",
|
||||
"svarsfalt": {
|
||||
"marke": "make",
|
||||
"modell": "model",
|
||||
"arsmodell": "year",
|
||||
"motor": "engine",
|
||||
"vaxellada": "transmission"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vag_erwin",
|
||||
"namn": "Volkswagen Group erWin",
|
||||
"beskrivning": "OEM-portal för VW, Audi, Škoda och SEAT. Kräver eget abonnemang hos tillverkaren.",
|
||||
"falt": [
|
||||
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false },
|
||||
{ "nyckel": "anvandarnamn", "etikett": "Användarnamn", "hemlig": false },
|
||||
{ "nyckel": "losenord", "etikett": "Lösenord", "hemlig": true }
|
||||
],
|
||||
"uppslag": {
|
||||
"urlFalt": "bas_url",
|
||||
"auth": "basic",
|
||||
"authFalt": "anvandarnamn",
|
||||
"authFalt2": "losenord",
|
||||
"svarsfalt": {
|
||||
"marke": "brand",
|
||||
"modell": "model",
|
||||
"arsmodell": "modelYear",
|
||||
"motor": "engineCode",
|
||||
"vaxellada": "gearbox"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "volvo_vida",
|
||||
"namn": "Volvo VIDA",
|
||||
"beskrivning": "Volvos verkstadsportal. Kräver eget abonnemang och verkstads-ID.",
|
||||
"falt": [
|
||||
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false },
|
||||
{ "nyckel": "verkstadsid", "etikett": "Verkstads-ID", "hemlig": false },
|
||||
{ "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true }
|
||||
],
|
||||
"uppslag": {
|
||||
"urlFalt": "bas_url",
|
||||
"auth": "header",
|
||||
"authHeader": "X-Api-Key",
|
||||
"authFalt": "api_nyckel",
|
||||
"svarsfalt": {
|
||||
"marke": "make",
|
||||
"modell": "model",
|
||||
"arsmodell": "year",
|
||||
"motor": "engine",
|
||||
"vaxellada": "transmission"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fordonsregister",
|
||||
"namn": "Fordonsregisteruppslag (regnr → fordon)",
|
||||
"beskrivning": "Nationell fordonsdata via vald leverantör. Uppslag sker på registreringsnummer i stället för VIN.",
|
||||
"nyckeltyp": "regnr",
|
||||
"falt": [
|
||||
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {regnr} som platshållare)", "hemlig": false },
|
||||
{ "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true }
|
||||
],
|
||||
"uppslag": {
|
||||
"urlFalt": "bas_url",
|
||||
"auth": "query",
|
||||
"authParam": "key",
|
||||
"authFalt": "api_nyckel",
|
||||
"svarsfalt": {
|
||||
"marke": "make",
|
||||
"modell": "model",
|
||||
"arsmodell": "year",
|
||||
"vin": "vin",
|
||||
"motor": "engine"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+479
@@ -0,0 +1,479 @@
|
||||
#!/usr/bin/env bash
|
||||
# Integrationstest för plattformstjänsten mot en riktig Postgres.
|
||||
# Kör hela flödet: registrering (org + admin), synk av händelser,
|
||||
# idempotens, append-only-triggern, organisationsisolering, publik
|
||||
# delning med filtrering samt rollstyrd användarhantering.
|
||||
#
|
||||
# Krav: postgres (initdb/pg_ctl), node, curl. Kör: bash integrationstest.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
PGPORT=5499
|
||||
APPPORT=8399
|
||||
BAS="http://127.0.0.1:$APPPORT"
|
||||
DATADIR=$(mktemp -d)
|
||||
PGBIN=$(ls -d /usr/lib/postgresql/*/bin | head -1)
|
||||
|
||||
stada() {
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
su postgres -c "$PGBIN/pg_ctl -D '$DATADIR' stop -m immediate" >/dev/null 2>&1 || true
|
||||
rm -rf "$DATADIR"
|
||||
}
|
||||
trap stada EXIT
|
||||
|
||||
# ---- Postgres upp ----
|
||||
chown postgres "$DATADIR"
|
||||
su postgres -c "$PGBIN/initdb -D '$DATADIR' -A trust" >/dev/null
|
||||
su postgres -c "$PGBIN/pg_ctl -D '$DATADIR' -o '-p $PGPORT -k /tmp -c listen_addresses=127.0.0.1' -l '$DATADIR/logg' start" >/dev/null
|
||||
su postgres -c "$PGBIN/psql -h /tmp -p $PGPORT -d postgres -qc \"create role plattform login password 'test'\""
|
||||
su postgres -c "$PGBIN/psql -h /tmp -p $PGPORT -d postgres -qc 'create database felsokning owner plattform'"
|
||||
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning -q -f ../../infra/postgres-init.sql
|
||||
|
||||
# ---- Tjänsten upp ----
|
||||
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
|
||||
JWT_SECRET=integrationshemlighet PORT=$APPPORT node server.mjs &
|
||||
SERVER_PID=$!
|
||||
sleep 1
|
||||
|
||||
falt() { node -pe "JSON.parse(require('fs').readFileSync(0,'utf8'))$1"; }
|
||||
kontroll() { # kontroll <namn> <faktiskt> <forvantat>
|
||||
if [ "$2" = "$3" ]; then echo "✓ $1"; else echo "✗ $1: fick '$2', väntade '$3'"; exit 1; fi
|
||||
}
|
||||
|
||||
# 1. Registrera organisation A (admin)
|
||||
SVAR=$(curl -s -X POST "$BAS/api/auth/registrera" -H 'Content-Type: application/json' \
|
||||
-d '{"epost":"anna@a.se","losenord":"hemligt123","namn":"Anna","organisation":"Verkstad A"}')
|
||||
TOKEN_A=$(echo "$SVAR" | falt .token); kontroll "registrering ger admin" "$(echo "$SVAR" | falt .roll)" "admin"
|
||||
kontroll "organisationsnamn" "$(echo "$SVAR" | falt .organisation)" "Verkstad A"
|
||||
|
||||
# 2. Skapa ärende + händelser (inkl. internt kategoribyte)
|
||||
curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"id":"arende-test1","nummer":1,"skapad":"2026-08-03T08:00:00Z","delningskod":"delkod123"}' >/dev/null
|
||||
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"handelser":[
|
||||
{"id":"h1","tidpunkt":"2026-08-03T08:01:00Z","anvandare":"Anna","handelse":{"typ":"felbeskrivning","text":"Startar inte"}},
|
||||
{"id":"h2","tidpunkt":"2026-08-03T08:02:00Z","anvandare":"Anna","handelse":{"typ":"kategori_byte","kategori":"provkorning"}}
|
||||
]}' >/dev/null
|
||||
ANTAL=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" | falt .handelser.length)
|
||||
kontroll "händelser sparade" "$ANTAL" "2"
|
||||
|
||||
# 3. Idempotens: samma push igen ändrar ingenting
|
||||
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"handelser":[{"id":"h1","tidpunkt":"2026-08-03T08:01:00Z","anvandare":"Anna","handelse":{"typ":"felbeskrivning","text":"ÄNDRAD"}}]}' >/dev/null
|
||||
TEXT=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" | falt '.handelser[0].handelse.text')
|
||||
kontroll "händelser skrivs aldrig över" "$TEXT" "Startar inte"
|
||||
|
||||
# 4. Append-only-triggern stoppar direkta ändringar i databasen
|
||||
if PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-qc "update felsokning_handelser set anvandare='hackad' where id='h1'" 2>/dev/null; then
|
||||
echo "✗ append-only-trigger saknas"; exit 1
|
||||
else
|
||||
echo "✓ databastriggern avvisar update"
|
||||
fi
|
||||
|
||||
# 5. Organisationsisolering: org B ser inte org A:s ärenden
|
||||
TOKEN_B=$(curl -s -X POST "$BAS/api/auth/registrera" -H 'Content-Type: application/json' \
|
||||
-d '{"epost":"bo@b.se","losenord":"hemligt123","namn":"Bo","organisation":"Verkstad B"}' | falt .token)
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_B")
|
||||
kontroll "org B nekas org A:s händelser" "$KOD" "404"
|
||||
ANTAL_B=$(curl -s "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_B" | falt .arenden.length)
|
||||
kontroll "org B:s ärendelista är tom" "$ANTAL_B" "0"
|
||||
|
||||
# 6. Publik delning: utan inloggning, interna poster filtrerade
|
||||
DELAT=$(curl -s "$BAS/api/delad/delkod123")
|
||||
kontroll "delning nås utan inloggning" "$(echo "$DELAT" | falt .handelser.length)" "1"
|
||||
kontroll "kategoribyte filtreras ur delning" "$(echo "$DELAT" | falt '.handelser[0].handelse.typ')" "felbeskrivning"
|
||||
|
||||
# 7. Rollstyrning: admin skapar tekniker; tekniker får inte hantera användare
|
||||
curl -s -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"epost":"johan@a.se","losenord":"hemligt123","namn":"Johan","roll":"tekniker"}' >/dev/null
|
||||
TOKEN_J=$(curl -s -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \
|
||||
-d '{"epost":"johan@a.se","losenord":"hemligt123"}' | falt .token)
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_J" \
|
||||
-H 'Content-Type: application/json' -d '{"epost":"x@a.se","losenord":"hemligt123","namn":"X","roll":"admin"}')
|
||||
kontroll "tekniker nekas användarhantering" "$KOD" "403"
|
||||
ANTAL_ANV=$(curl -s "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" | falt .anvandare.length)
|
||||
kontroll "org A har två användare" "$ANTAL_ANV" "2"
|
||||
|
||||
# 8. Tekniker i org A når ärendet (delad arbetsyta inom organisationen)
|
||||
ANTAL=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_J" | falt .handelser.length)
|
||||
kontroll "tekniker i samma org når ärendet" "$ANTAL" "2"
|
||||
|
||||
# 9. Organisationsöversikten: tekniker nekas; arbetsledare får status ur loggen
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/oversikt" -H "Authorization: Bearer $TOKEN_J")
|
||||
kontroll "tekniker nekas översikten" "$KOD" "403"
|
||||
curl -s -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"epost":"lisa@a.se","losenord":"hemligt123","namn":"Lisa","roll":"arbetsledare"}' >/dev/null
|
||||
TOKEN_L=$(curl -s -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \
|
||||
-d '{"epost":"lisa@a.se","losenord":"hemligt123"}' | falt .token)
|
||||
OVERSIKT=$(curl -s "$BAS/api/oversikt" -H "Authorization: Bearer $TOKEN_L")
|
||||
kontroll "arbetsledaren ser organisationens ärenden" "$(echo "$OVERSIKT" | falt .arenden.length)" "1"
|
||||
kontroll "översikten härleder felbeskrivning" "$(echo "$OVERSIKT" | falt '.arenden[0].felbeskrivning')" "Startar inte"
|
||||
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"
|
||||
|
||||
# 9b. Omfördelning: arbetsledaren listar användare och sätter ny ansvarig
|
||||
ANTAL_ANV=$(curl -s "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_L" | falt .anvandare.length)
|
||||
kontroll "arbetsledaren kan lista användare" "$ANTAL_ANV" "3"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_L" \
|
||||
-H 'Content-Type: application/json' -d '{"epost":"y@a.se","losenord":"hemligt123","namn":"Y","roll":"tekniker"}')
|
||||
kontroll "arbetsledaren nekas skapa användare" "$KOD" "403"
|
||||
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_L" -H 'Content-Type: application/json' \
|
||||
-d '{"handelser":[{"id":"h-omf","tidpunkt":"2026-08-03T08:05:00Z","anvandare":"Lisa","handelse":{"typ":"ansvarig_satt","ansvarig":"Johan"}}]}' >/dev/null
|
||||
kontroll "översikten visar ny ansvarig" "$(curl -s "$BAS/api/oversikt" -H "Authorization: Bearer $TOKEN_L" | falt '.arenden[0].ansvarig')" "Johan"
|
||||
|
||||
# 9c. Organisationsinställningar: alla läser, bara admin ändrar
|
||||
INST=$(curl -s "$BAS/api/organisation" -H "Authorization: Bearer $TOKEN_J")
|
||||
kontroll "inloggad läser organisationen" "$(echo "$INST" | falt .namn)" "Verkstad A"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/organisation/installningar" \
|
||||
-H "Authorization: Bearer $TOKEN_J" -H 'Content-Type: application/json' \
|
||||
-d '{"objekttyper":["Fordon"],"identifieringsmetoder":["VIN"]}')
|
||||
kontroll "tekniker nekas ändra inställningar" "$KOD" "403"
|
||||
curl -s -X POST "$BAS/api/organisation/installningar" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"objekttyper":["Fordon","Hydraulik"],"identifieringsmetoder":["VIN","Manuell inmatning"]}' >/dev/null
|
||||
INST=$(curl -s "$BAS/api/organisation" -H "Authorization: Bearer $TOKEN_J")
|
||||
kontroll "inställningarna gäller hela organisationen" "$(echo "$INST" | falt '.installningar.objekttyper.join(",")')" "Fordon,Hydraulik"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/organisation/installningar" \
|
||||
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"objekttyper":[],"identifieringsmetoder":["VIN"]}')
|
||||
kontroll "tomma listor avvisas" "$KOD" "400"
|
||||
|
||||
# 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"}},
|
||||
{"id":"h4","tidpunkt":"2026-08-03T08:04:00Z","anvandare":"Anna","handelse":{"typ":"arbetsorder_skannad","falt":[{"id":"kund_namn","etikett":"Namn","varde":"Kalle Kund","konfidens":0.97}]}}
|
||||
]}' >/dev/null
|
||||
kontroll "kundkoden filtrerar hypoteser och arbetsorder" "$(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 men inte arbetsordern" "$(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)" "5"
|
||||
|
||||
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"
|
||||
|
||||
# 10a. Fordonshistorik och felorsaksstatistik
|
||||
curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"id":"arende-test2","nummer":2,"skapad":"2026-08-03T09:00:00Z"}' >/dev/null
|
||||
curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"id":"arende-test3","nummer":3,"skapad":"2026-08-03T10:00:00Z"}' >/dev/null
|
||||
curl -s -X POST "$BAS/api/arenden/arende-test2/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"handelser":[{"id":"f1","tidpunkt":"2026-08-03T09:01:00Z","anvandare":"Anna","handelse":{"typ":"objekt_identifierat","objekt":{"typ":"Personbil","identifierare":"XYZ999","identifieringsmetod":"Regnr","beskrivning":"VW Golf 2023"}}}]}' >/dev/null
|
||||
curl -s -X POST "$BAS/api/arenden/arende-test3/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"handelser":[
|
||||
{"id":"f2","tidpunkt":"2026-08-03T10:01:00Z","anvandare":"Johan","handelse":{"typ":"objekt_identifierat","objekt":{"typ":"Personbil","identifierare":"xyz999","identifieringsmetod":"Regnr","beskrivning":"VW Golf 2023"}}},
|
||||
{"id":"f3","tidpunkt":"2026-08-03T10:02:00Z","anvandare":"Johan","handelse":{"typ":"felorsak","avvikelse":"Vattenpumpen läcker vid axeltätningen.","orsaker":["Normalt slitage","Ålder"],"underlag":["Foto"],"sakerhet":"hog","atgard":"Byt vattenpump."}}
|
||||
]}' >/dev/null
|
||||
HIST=$(curl -s "$BAS/api/fordon/XYZ999/historik" -H "Authorization: Bearer $TOKEN_J")
|
||||
kontroll "fordonshistoriken hittar båda ärendena (case-okänsligt)" "$(echo "$HIST" | falt .arenden.length)" "2"
|
||||
kontroll "historiken bär felorsakerna" "$(echo "$HIST" | falt '.arenden.flatMap(a=>a.felorsaker).length')" "1"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/fordon/XYZ999/historik" -H "Authorization: Bearer $TOKEN_B")
|
||||
kontroll "org B ser inte org A:s fordonshistorik" "$(curl -s "$BAS/api/fordon/XYZ999/historik" -H "Authorization: Bearer $TOKEN_B" | falt .arenden.length)" "0"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/statistik/felorsaker" -H "Authorization: Bearer $TOKEN_J")
|
||||
kontroll "tekniker nekas felorsaksstatistiken" "$KOD" "403"
|
||||
STAT=$(curl -s "$BAS/api/statistik/felorsaker" -H "Authorization: Bearer $TOKEN_L")
|
||||
kontroll "statistiken räknar orsakskategorier" "$(echo "$STAT" | falt .orsaker.length)" "2"
|
||||
kontroll "statistiken är organisationsknuten data" "$(echo "$STAT" | falt '.orsaker[0].antal')" "1"
|
||||
|
||||
# 10b. ECM Knowledge Library: regelpaketet serveras till inloggade klienter
|
||||
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"
|
||||
|
||||
# 10d. Märkesspecifika kopplingar: kundens egna credentials
|
||||
# Registret är läsbart för alla inloggade (inställningssidan behöver
|
||||
# veta vilka leverantörer som finns) men innehåller inga uppgifter.
|
||||
LEV=$(curl -s "$BAS/api/integrationer/leverantorer" -H "Authorization: Bearer $TOKEN_J")
|
||||
kontroll "leverantörsregistret är läsbart för tekniker" \
|
||||
"$(echo "$LEV" | falt '.leverantorer.some(l=>l.id==="generisk_vin")')" "true"
|
||||
kontroll "registret pekar ut hemliga fält" \
|
||||
"$(echo "$LEV" | falt '.leverantorer.every(l=>l.falt.some(f=>f.hemlig===true))')" "true"
|
||||
|
||||
# Uppgifterna är administratörens ensak
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_J")
|
||||
kontroll "tekniker ser inte kopplingarnas uppgifter" "$KOD" "403"
|
||||
|
||||
# Utan konfigurerad krypteringsnyckel sparas ingenting — fail closed
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"https://x.se/{vin}","api_nyckel":"k"}}')
|
||||
kontroll "utan krypteringsnyckel sparas inga uppgifter" "$KOD" "503"
|
||||
|
||||
# Starta om tjänsten med krypteringsnyckel konfigurerad
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
|
||||
JWT_SECRET=integrationshemlighet PORT=$APPPORT \
|
||||
INTEGRATION_NYCKEL=$(node -pe "require('crypto').randomBytes(32).toString('hex')") \
|
||||
node server.mjs &
|
||||
SERVER_PID=$!
|
||||
sleep 1
|
||||
|
||||
# Okänd leverantör och ofullständiga uppgifter avvisas
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' -d '{"leverantor":"hittepa","uppgifter":{"a":"b"}}')
|
||||
kontroll "okänd leverantör avvisas" "$KOD" "400"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' -d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"https://x.se/{vin}"}}')
|
||||
kontroll "ofullständiga uppgifter avvisas" "$KOD" "400"
|
||||
|
||||
# Administratören sparar organisationens egna credentials
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"http://127.0.0.1:9/vin/{vin}","api_nyckel":"sk-verkstad-123456"}}')
|
||||
kontroll "administratören kan spara credentials" "$KOD" "200"
|
||||
|
||||
# Hemligheten lämnar aldrig servern i klartext
|
||||
INT=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A")
|
||||
kontroll "krypteringen är konfigurerad" "$(echo "$INT" | falt .krypteringKonfigurerad)" "true"
|
||||
kontroll "hemligt fält maskeras i svaret" "$(echo "$INT" | falt '.integrationer[0].uppgifter.api_nyckel')" "••••3456"
|
||||
kontroll "öppet fält visas som det är" "$(echo "$INT" | falt '.integrationer[0].uppgifter.bas_url')" "http://127.0.0.1:9/vin/{vin}"
|
||||
|
||||
# … och ligger krypterad i databasen
|
||||
RAD=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-tAc "select uppgifter_krypt from integrationer where leverantor='generisk_vin'")
|
||||
case "$RAD" in
|
||||
*sk-verkstad-123456*) echo "✗ uppgifterna ligger i klartext i databasen"; exit 1 ;;
|
||||
*) echo "✓ uppgifterna ligger krypterade i databasen" ;;
|
||||
esac
|
||||
|
||||
# Uppslag: identifieraren valideras, okonfigurerad koppling ger 404
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \
|
||||
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"x"}')
|
||||
kontroll "ogiltig identifierare avvisas" "$KOD" "400"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/volvo_vida/uppslag" \
|
||||
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}')
|
||||
kontroll "okonfigurerad koppling ger 404" "$KOD" "404"
|
||||
|
||||
# Bas-URL:en pekar inåt (127.0.0.1) — uppslaget får inte bli en väg in i
|
||||
# klustret. Anropet ska stoppas innan det görs och rapporteras ärligt.
|
||||
SVAR=$(curl -s -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \
|
||||
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}')
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \
|
||||
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}')
|
||||
kontroll "uppslag mot intern adress avvisas (502)" "$KOD" "502"
|
||||
kontroll "felet säger varför" "$(echo "$SVAR" | falt '.error.includes("intern adress")')" "true"
|
||||
STATUS=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
| falt '.integrationer[0].senaste_status.slice(0,3)')
|
||||
kontroll "senaste testresultat sparas på kopplingen" "$STATUS" "fel"
|
||||
|
||||
# Kopplingar är organisationsknutna
|
||||
ANTAL_B=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_B" | falt .integrationer.length)
|
||||
kontroll "org B ser inte org A:s kopplingar" "$ANTAL_B" "0"
|
||||
|
||||
# Borttagning
|
||||
curl -s -X DELETE "$BAS/api/integrationer/generisk_vin" -H "Authorization: Bearer $TOKEN_A" >/dev/null
|
||||
ANTAL=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" | falt .integrationer.length)
|
||||
kontroll "kopplingen kan tas bort" "$ANTAL" "0"
|
||||
|
||||
# 10e. Kontospärr, återkallelse och takt-begränsning på inloggning
|
||||
# Skapa en tekniker att stänga av.
|
||||
SVAR=$(curl -s -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"epost":"karin@a.se","losenord":"hemligt123","namn":"Karin","roll":"tekniker"}')
|
||||
KARIN_ID=$(echo "$SVAR" | falt .id)
|
||||
kontroll "ny användare är aktiv" "$(echo "$SVAR" | falt .aktiv)" "true"
|
||||
|
||||
TOKEN_K=$(curl -s -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \
|
||||
-d '{"epost":"karin@a.se","losenord":"hemligt123"}' | falt .token)
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K")
|
||||
kontroll "teknikern kommer in" "$KOD" "200"
|
||||
|
||||
# Tekniker får inte stänga av någon
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare/$KARIN_ID/avaktivera" \
|
||||
-H "Authorization: Bearer $TOKEN_K")
|
||||
kontroll "tekniker kan inte stänga av konton" "$KOD" "403"
|
||||
|
||||
# Administratören kan inte stänga av sig själv
|
||||
ANNA_ID=$(curl -s "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" \
|
||||
| falt '.anvandare.find(a=>a.epost==="anna@a.se").id')
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare/$ANNA_ID/avaktivera" \
|
||||
-H "Authorization: Bearer $TOKEN_A")
|
||||
kontroll "admin kan inte stänga av sig själv" "$KOD" "400"
|
||||
|
||||
# Org B kan inte röra org A:s användare
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare/$KARIN_ID/avaktivera" \
|
||||
-H "Authorization: Bearer $TOKEN_B")
|
||||
kontroll "org B kan inte stänga av org A:s användare" "$KOD" "404"
|
||||
|
||||
# Avstängningen gäller OMEDELBART för redan utfärdad token
|
||||
curl -s -X POST "$BAS/api/anvandare/$KARIN_ID/avaktivera" -H "Authorization: Bearer $TOKEN_A" >/dev/null
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K")
|
||||
kontroll "utfärdad token slutar gälla direkt vid avstängning" "$KOD" "401"
|
||||
|
||||
# … och inloggning stängs
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \
|
||||
-d '{"epost":"karin@a.se","losenord":"hemligt123"}')
|
||||
kontroll "avstängt konto kan inte logga in" "$KOD" "403"
|
||||
|
||||
# Öppnas kontot igen fungerar inloggning, men den gamla token är död för gott
|
||||
curl -s -X POST "$BAS/api/anvandare/$KARIN_ID/aktivera" -H "Authorization: Bearer $TOKEN_A" >/dev/null
|
||||
TOKEN_K2=$(curl -s -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \
|
||||
-d '{"epost":"karin@a.se","losenord":"hemligt123"}' | falt .token)
|
||||
kontroll "återöppnat konto kan logga in" "$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K2")" "200"
|
||||
kontroll "den återkallade token förblir ogiltig" "$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K")" "401"
|
||||
|
||||
# Logga ut på alla enheter återkallar den egna sessionen
|
||||
curl -s -X POST "$BAS/api/auth/logga-ut-alla" -H "Authorization: Bearer $TOKEN_K2" >/dev/null
|
||||
kontroll "logga-ut-alla dödar den egna token" "$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K2")" "401"
|
||||
|
||||
# Takt-begränsning: efter tio misslyckade försök spärras kontot en stund
|
||||
SISTA=""
|
||||
for i in $(seq 1 11); do
|
||||
SISTA=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/auth/logga-in" \
|
||||
-H 'Content-Type: application/json' -d '{"epost":"karin@a.se","losenord":"fel-losenord"}')
|
||||
done
|
||||
kontroll "upprepade misslyckade inloggningar spärras" "$SISTA" "429"
|
||||
# Spärren gäller kontot även med RÄTT lösenord — annars vore den meningslös
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/auth/logga-in" \
|
||||
-H 'Content-Type: application/json' -d '{"epost":"karin@a.se","losenord":"hemligt123"}')
|
||||
kontroll "spärren gäller även rätt lösenord" "$KOD" "429"
|
||||
# Ett annat konto påverkas inte av spärren på det första
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/auth/logga-in" \
|
||||
-H 'Content-Type: application/json' -d '{"epost":"anna@a.se","losenord":"hemligt123"}')
|
||||
kontroll "andra konton påverkas inte" "$KOD" "200"
|
||||
|
||||
# 10f. Bilagor: innehållet ligger utanför händelsen, hashen i loggen
|
||||
PNG=$(mktemp); printf '\x89PNG\r\n\x1a\nTESTBILD-1' > "$PNG"
|
||||
SVAR=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: image/png' --data-binary "@$PNG")
|
||||
BIL_ID=$(echo "$SVAR" | falt .id)
|
||||
BIL_HASH=$(echo "$SVAR" | falt .hash)
|
||||
kontroll "bilagan får en hash" "${#BIL_HASH}" "64"
|
||||
kontroll "hashen är innehållets" "$BIL_HASH" "$(sha256sum "$PNG" | cut -d' ' -f1)"
|
||||
|
||||
# Samma innehåll igen: ny referens, men bara ett lagrat innehåll
|
||||
SVAR2=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: image/png' --data-binary "@$PNG")
|
||||
kontroll "identiskt innehåll ger samma hash" "$(echo "$SVAR2" | falt .hash)" "$BIL_HASH"
|
||||
ANTAL_INNEHALL=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-tAc "select count(*) from bilage_innehall where hash='$BIL_HASH'")
|
||||
kontroll "innehållsadresserat — lagras en gång" "$ANTAL_INNEHALL" "1"
|
||||
|
||||
# Hämtning ger tillbaka exakt samma bytes
|
||||
curl -s "$BAS/api/bilagor/$BIL_ID" -H "Authorization: Bearer $TOKEN_A" -o /tmp/hamtad.png
|
||||
kontroll "hämtat innehåll är identiskt" "$(sha256sum /tmp/hamtad.png | cut -d' ' -f1)" "$BIL_HASH"
|
||||
|
||||
# Fel mediatyp och tom kropp avvisas
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-test1/bilagor" \
|
||||
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/pdf' --data-binary "@$PNG")
|
||||
kontroll "endast bilder och video tas emot" "$KOD" "415"
|
||||
|
||||
# Organisationsgränsen gäller bilagor
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/bilagor/$BIL_ID" -H "Authorization: Bearer $TOKEN_B")
|
||||
kontroll "org B kommer inte åt org A:s bilaga" "$KOD" "404"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-test1/bilagor" \
|
||||
-H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: image/png' --data-binary "@$PNG")
|
||||
kontroll "org B kan inte ladda upp till org A:s ärende" "$KOD" "404"
|
||||
|
||||
# Manipulerat innehåll upptäcks: hashen i loggen är facit
|
||||
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-qc "update bilage_innehall set data = decode('4d414e4950554c45524154', 'hex') where hash='$BIL_HASH'"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/bilagor/$BIL_ID" -H "Authorization: Bearer $TOKEN_A")
|
||||
kontroll "utbytt innehåll upptäcks och lämnas inte ut" "$KOD" "409"
|
||||
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-qc "update bilage_innehall set data = pg_read_binary_file('$PNG') where hash='$BIL_HASH'" 2>/dev/null \
|
||||
|| PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-qc "delete from bilage_innehall where hash='$BIL_HASH'"
|
||||
|
||||
# Delningsfiltret gäller även bilagor: en bilaga som hör till en intern
|
||||
# händelsetyp får inte hämtas via kundlänken.
|
||||
BIL2=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: image/png' --data-binary "@$PNG" | falt .id)
|
||||
BIL3=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: image/png' --data-binary "@$PNG" | falt .id)
|
||||
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d "{\"handelser\":[
|
||||
{\"id\":\"h-foto\",\"tidpunkt\":\"2026-08-03T08:07:00Z\",\"anvandare\":\"Anna\",\"handelse\":{\"typ\":\"foto\",\"beskrivning\":\"Hjul\",\"bilagaId\":\"$BIL2\"}},
|
||||
{\"id\":\"h-ao\",\"tidpunkt\":\"2026-08-03T08:08:00Z\",\"anvandare\":\"Anna\",\"handelse\":{\"typ\":\"arbetsorder_skannad\",\"falt\":[],\"bilagaId\":\"$BIL3\"}}
|
||||
]}" >/dev/null
|
||||
NYKUND=$(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}" "$BAS/api/delad/$NYKUND/bilagor/$BIL2")
|
||||
kontroll "kunden når bilagan till ett foto" "$KOD" "200"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$NYKUND/bilagor/$BIL3")
|
||||
kontroll "kunden når INTE arbetsorderbilden" "$KOD" "404"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$NYKUND/bilagor/$BIL_ID")
|
||||
kontroll "bilaga utan händelse lämnas inte ut publikt" "$KOD" "404"
|
||||
rm -f "$PNG" /tmp/hamtad.png
|
||||
|
||||
# 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" ;;
|
||||
*) echo "✗ OpenAPI-specen saknas"; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo "Integrationstest: allt grönt"
|
||||
@@ -0,0 +1,1111 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Guidad Felsökning – plattforms-API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
API-first-ytan för Guidad Felsökning: autentisering, organisationens
|
||||
användare, ärenden med append-only händelselogg, arbetsledaröversikt,
|
||||
publik Live Share-delning samt AI-orkestern.
|
||||
|
||||
**Händelseloggen är append-only.** Det finns inga update- eller
|
||||
delete-operationer — historik kan aldrig ändras eller raderas, vilket
|
||||
även garanteras av databastriggers. En händelse med redan känt `id`
|
||||
ignoreras tyst (idempotent synk).
|
||||
|
||||
**Multi-tenant.** All ärendedata är organisationsknuten. Ett ärende i
|
||||
en annan organisation ger `404`, som om det inte fanns.
|
||||
|
||||
Specen serveras live av tjänsten på `GET /api/openapi.yaml`.
|
||||
servers:
|
||||
- url: https://app.exempel.se
|
||||
description: Klustrets ingress (ersätt med er domän)
|
||||
tags:
|
||||
- name: Auth
|
||||
- name: Användare
|
||||
- name: Ärenden
|
||||
- name: Översikt
|
||||
- name: Delning
|
||||
- name: AI
|
||||
- name: Integrationer
|
||||
- name: Drift
|
||||
|
||||
paths:
|
||||
/halsa:
|
||||
get:
|
||||
tags: [Drift]
|
||||
summary: Hälsokontroll
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
description: Tjänsten svarar.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status: { type: string, example: ok }
|
||||
|
||||
/api/openapi.yaml:
|
||||
get:
|
||||
tags: [Drift]
|
||||
summary: Denna specifikation
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
description: OpenAPI-specen i YAML.
|
||||
content:
|
||||
application/yaml:
|
||||
schema: { type: string }
|
||||
|
||||
/api/auth/registrera:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Skapa organisation + systemadministratör
|
||||
description: >
|
||||
Registrerar en ny organisation (tenant) och dess första användare,
|
||||
som blir systemadministratör. Kan stängas av driften
|
||||
(REGISTRERING_OPPEN=false → 403).
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [epost, losenord, namn, organisation]
|
||||
properties:
|
||||
epost: { type: string, format: email }
|
||||
losenord: { type: string, minLength: 8 }
|
||||
namn: { type: string }
|
||||
organisation: { type: string }
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/Inloggad" }
|
||||
"400": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
"409": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/auth/logga-in:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Logga in
|
||||
description: >
|
||||
Takt-begränsad i databasen, så spärren håller bakom flera
|
||||
repliker: 10 misslyckade försök per konto och 30 per källadress
|
||||
inom 15 minuter ger 429. Spärren gäller kontot även vid rätt
|
||||
lösenord — annars vore den meningslös. Ett avstängt konto svarar
|
||||
403.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [epost, losenord]
|
||||
properties:
|
||||
epost: { type: string, format: email }
|
||||
losenord: { type: string }
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/Inloggad" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403":
|
||||
description: Kontot är avstängt.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
"429":
|
||||
description: För många misslyckade försök.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
|
||||
/api/anvandare:
|
||||
get:
|
||||
tags: [Användare]
|
||||
summary: Lista organisationens användare
|
||||
description: Kräver rollen `admin` eller `arbetsledare`.
|
||||
responses:
|
||||
"200":
|
||||
description: Användare i den egna organisationen.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
anvandare:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Anvandare" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
post:
|
||||
tags: [Användare]
|
||||
summary: Skapa användare i den egna organisationen
|
||||
description: Kräver rollen `admin`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [epost, losenord, namn, roll]
|
||||
properties:
|
||||
epost: { type: string, format: email }
|
||||
losenord: { type: string, minLength: 8 }
|
||||
namn: { type: string }
|
||||
roll: { $ref: "#/components/schemas/Roll" }
|
||||
responses:
|
||||
"200":
|
||||
description: Den skapade användaren.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Anvandare" }
|
||||
"400": { $ref: "#/components/responses/Fel" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
"409": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/ecm/regler:
|
||||
get:
|
||||
tags: [Drift]
|
||||
summary: ECM Knowledge Library — aktuellt regelpaket
|
||||
description: >
|
||||
Serverdistribuerat, versionerat regelpaket (compliance-regler per
|
||||
ärendetyp, orsakskategorier, evidenskällor, undantagsorsaker).
|
||||
Uppdateras i driften utan appändring; klienten cachar och faller
|
||||
tillbaka till sitt inbyggda standardpaket offline.
|
||||
responses:
|
||||
"200":
|
||||
description: Regelpaketet.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version: { type: string }
|
||||
arendetypRegler: { type: object, additionalProperties: true }
|
||||
undantagsorsaker: { type: array, items: { type: string } }
|
||||
orsakskategorier: { type: array, items: { type: string } }
|
||||
underlagskallor: { type: array, items: { type: string } }
|
||||
|
||||
/api/fordon/{identifierare}/historik:
|
||||
get:
|
||||
tags: [Ärenden]
|
||||
summary: Fordonshistorik — tidigare ärenden på samma objekt
|
||||
description: >
|
||||
Organisationens tidigare ärenden där objektet (regnr/VIN) matchar,
|
||||
med dokumenterade felorsaker. Underlag för pre-diagnostikens
|
||||
historiksteg och orsakskedjan.
|
||||
parameters:
|
||||
- name: identifierare
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: Tidigare ärenden (senaste 20).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
arenden:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
nummer: { type: integer }
|
||||
skapad: { type: string, format: date-time }
|
||||
avslutat: { type: boolean }
|
||||
felbeskrivning: { type: string, nullable: true }
|
||||
felorsaker: { type: array, items: { type: object, additionalProperties: true } }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/statistik/felorsaker:
|
||||
get:
|
||||
tags: [Översikt]
|
||||
summary: Felorsaksstatistik per orsakskategori
|
||||
description: >
|
||||
Flottdata ur dokumenterade felorsaksanalyser i organisationen.
|
||||
Kräver rollen `arbetsledare` eller `admin`.
|
||||
responses:
|
||||
"200":
|
||||
description: Antal per orsakskategori, fallande.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
orsaker:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
orsak: { type: string }
|
||||
antal: { type: integer }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/anvandare/{anvandarId}/avaktivera:
|
||||
post:
|
||||
tags: [Användare]
|
||||
summary: Stäng av ett konto
|
||||
description: >
|
||||
Kräver rollen `admin` och att användaren tillhör samma
|
||||
organisation. Avstängningen höjer kontots token-version, så
|
||||
**pågående sessioner upphör omedelbart** — annars vore den
|
||||
verkningslös tills utfärdade tokens gick ut. Ett konto kan inte
|
||||
stänga av sig självt.
|
||||
parameters:
|
||||
- name: anvandarId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
responses:
|
||||
"200":
|
||||
description: Kontot är avstängt.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
namn: { type: string }
|
||||
aktiv: { type: boolean }
|
||||
"400":
|
||||
description: Försök att stänga av sitt eget konto.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
"404": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/anvandare/{anvandarId}/aktivera:
|
||||
post:
|
||||
tags: [Användare]
|
||||
summary: Öppna ett avstängt konto
|
||||
description: >
|
||||
Kräver rollen `admin`. Kontot kan logga in igen, men tokens som
|
||||
återkallades vid avstängningen förblir ogiltiga.
|
||||
parameters:
|
||||
- name: anvandarId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
responses:
|
||||
"200": { description: Kontot är öppnat. }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
"404": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/auth/logga-ut-alla:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Logga ut på alla enheter
|
||||
description: >
|
||||
Höjer den egna token-versionen, vilket gör samtliga utfärdade
|
||||
tokens för kontot ogiltiga direkt — vägen ut när en enhet
|
||||
tappats bort.
|
||||
responses:
|
||||
"200": { description: Alla sessioner är avslutade. }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/arenden/{arendeId}/bilagor:
|
||||
post:
|
||||
tags: [Ärenden]
|
||||
summary: Ladda upp en bilaga
|
||||
description: >
|
||||
Foton, videoklipp och instrumentbilder. Kroppen är råa bytes och
|
||||
`Content-Type` anger mediatypen — endast bild och video tas emot.
|
||||
Servern beräknar innehållets SHA-256 och returnerar en referens
|
||||
som ska läggas i händelsen; **hashen hamnar därmed i den
|
||||
append-only-skyddade loggen**, så en utbytt bild går att upptäcka.
|
||||
Innehållsadresserat: samma innehåll lagras en gång.
|
||||
parameters:
|
||||
- name: arendeId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
image/jpeg: { schema: { type: string, format: binary } }
|
||||
image/png: { schema: { type: string, format: binary } }
|
||||
image/webp: { schema: { type: string, format: binary } }
|
||||
video/mp4: { schema: { type: string, format: binary } }
|
||||
video/webm: { schema: { type: string, format: binary } }
|
||||
responses:
|
||||
"200":
|
||||
description: Referensen att spara i händelsen.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
hash: { type: string, description: "SHA-256 av innehållet, hex." }
|
||||
mediatyp: { type: string }
|
||||
storlek: { type: integer }
|
||||
"400": { $ref: "#/components/responses/Fel" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"404": { $ref: "#/components/responses/Fel" }
|
||||
"413":
|
||||
description: Bilagan är större än 32 MB.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
"415":
|
||||
description: Endast bilder och videoklipp tas emot.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
|
||||
/api/bilagor/{bilagaId}:
|
||||
get:
|
||||
tags: [Ärenden]
|
||||
summary: Hämta en bilaga
|
||||
description: >
|
||||
Organisationsknuten. Innehållet kontrolleras mot hashen innan det
|
||||
lämnas ut — stämmer det inte svarar tjänsten 409 i stället för att
|
||||
visa en bild som kan ha bytts ut.
|
||||
parameters:
|
||||
- name: bilagaId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: Innehållet.
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"404": { $ref: "#/components/responses/Fel" }
|
||||
"409":
|
||||
description: Innehållet stämmer inte med hashen i loggen.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
|
||||
/api/delad/{delningskod}/bilagor/{bilagaId}:
|
||||
get:
|
||||
tags: [Delning]
|
||||
summary: Hämta en bilaga via delningslänk
|
||||
description: >
|
||||
Samma filtrering som händelserna: bilagan lämnas bara ut om
|
||||
händelsen den hör till är synlig på delningens nivå. En bild som
|
||||
hör till en intern händelsetyp — t.ex. den skannade arbetsordern —
|
||||
nås alltså aldrig via kundlänken.
|
||||
security: []
|
||||
parameters:
|
||||
- name: delningskod
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: bilagaId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: Innehållet.
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary }
|
||||
"404": { $ref: "#/components/responses/Fel" }
|
||||
"409": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/integrationer/leverantorer:
|
||||
get:
|
||||
tags: [Integrationer]
|
||||
summary: Registret över märkesspecifika kopplingar
|
||||
description: >
|
||||
Leverantörer är data, inte kod: registret läses ur
|
||||
`integrationer.json` (eller filen i `INTEGRATIONER_FIL`) och kan
|
||||
bytas via ConfigMap utan att applikationen byggs om. Innehåller
|
||||
endast fältdefinitioner — aldrig någon organisations uppgifter.
|
||||
responses:
|
||||
"200":
|
||||
description: Leverantörsdefinitioner.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version: { type: string }
|
||||
leverantorer:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
namn: { type: string }
|
||||
beskrivning: { type: string }
|
||||
nyckeltyp:
|
||||
type: string
|
||||
enum: [vin, regnr]
|
||||
description: Vad uppslaget sker på. Utelämnat betyder VIN.
|
||||
falt:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
nyckel: { type: string }
|
||||
etikett: { type: string }
|
||||
hemlig:
|
||||
type: boolean
|
||||
description: >
|
||||
Hemliga fält maskeras alltid i svar och
|
||||
visas aldrig igen efter sparande.
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/integrationer:
|
||||
get:
|
||||
tags: [Integrationer]
|
||||
summary: Organisationens konfigurerade kopplingar
|
||||
description: >
|
||||
Kräver rollen `admin`. Uppgifterna lagras krypterade
|
||||
(AES-256-GCM) och returneras alltid maskerade — hemliga värden
|
||||
lämnar aldrig servern i klartext.
|
||||
responses:
|
||||
"200":
|
||||
description: Konfigurerade kopplingar med maskerade uppgifter.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
krypteringKonfigurerad:
|
||||
type: boolean
|
||||
description: Falskt om `INTEGRATION_NYCKEL` saknas — då kan inget sparas.
|
||||
integrationer:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
leverantor: { type: string }
|
||||
namn: { type: string }
|
||||
aktiv: { type: boolean }
|
||||
uppdaterad: { type: string, format: date-time }
|
||||
senast_testad: { type: string, format: date-time, nullable: true }
|
||||
senaste_status: { type: string, nullable: true }
|
||||
uppgifter:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
post:
|
||||
tags: [Integrationer]
|
||||
summary: Spara eller uppdatera en kopplings uppgifter
|
||||
description: >
|
||||
Kräver rollen `admin`. Endast leverantörens definierade fält
|
||||
sparas och samtliga måste fyllas i. Uppgifterna krypteras innan
|
||||
de skrivs. Ett sparande nollställer tidigare testresultat.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [leverantor, uppgifter]
|
||||
properties:
|
||||
leverantor: { type: string }
|
||||
aktiv: { type: boolean, default: true }
|
||||
uppgifter:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
responses:
|
||||
"200": { description: Sparad. }
|
||||
"400": { $ref: "#/components/responses/Fel" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
"503":
|
||||
description: Kryptering är inte konfigurerad (`INTEGRATION_NYCKEL` saknas).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
|
||||
/api/integrationer/{leverantor}:
|
||||
delete:
|
||||
tags: [Integrationer]
|
||||
summary: Ta bort en kopplings uppgifter
|
||||
description: Kräver rollen `admin`.
|
||||
parameters:
|
||||
- name: leverantor
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200": { description: Borttagen. }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/integrationer/{leverantor}/uppslag:
|
||||
post:
|
||||
tags: [Integrationer]
|
||||
summary: Slå upp ett fordon hos leverantören
|
||||
description: >
|
||||
Anropet görs alltid av servern — kundens leverantörsnycklar når
|
||||
aldrig webbläsaren. Svaret mappas till våra fält enligt
|
||||
registrets `svarsfalt`. Resultatet skrivs som kopplingens
|
||||
senaste teststatus.
|
||||
parameters:
|
||||
- name: leverantor
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [identifierare]
|
||||
properties:
|
||||
identifierare:
|
||||
type: string
|
||||
description: VIN eller registreringsnummer beroende på leverantörens `nyckeltyp`.
|
||||
responses:
|
||||
"200":
|
||||
description: Fordonsuppgifter från leverantören.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
fordon:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
"400": { $ref: "#/components/responses/Fel" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"404":
|
||||
description: Kopplingen är inte konfigurerad för organisationen.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
"502":
|
||||
description: Leverantören svarade med fel eller inga kända fält.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
"503":
|
||||
description: Kryptering är inte konfigurerad.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/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]
|
||||
summary: Hämta organisationens namn och inställningar
|
||||
description: >
|
||||
Inställningarna styr vad som visas när ett ärende startas
|
||||
(objekttyper och identifieringsmetoder). Alla inloggade läser.
|
||||
responses:
|
||||
"200":
|
||||
description: Organisationen.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
namn: { type: string }
|
||||
installningar: { $ref: "#/components/schemas/OrganisationsInstallningar" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/organisation/installningar:
|
||||
post:
|
||||
tags: [Organisation]
|
||||
summary: Uppdatera organisationens inställningar
|
||||
description: Kräver rollen `admin`. Minst ett alternativ per lista.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/OrganisationsInstallningar" }
|
||||
responses:
|
||||
"200":
|
||||
description: Sparat.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok: { type: boolean }
|
||||
"400": { $ref: "#/components/responses/Fel" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/arenden:
|
||||
get:
|
||||
tags: [Ärenden]
|
||||
summary: Lista organisationens ärenden
|
||||
responses:
|
||||
"200":
|
||||
description: Ärenden (senaste 200).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
arenden:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Arende" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
post:
|
||||
tags: [Ärenden]
|
||||
summary: Registrera ett ärende (idempotent)
|
||||
description: >
|
||||
Skapar ärendet i användarens organisation. Ett redan känt `id`
|
||||
ignoreras tyst — ärenderaden ändras aldrig.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [id, nummer, skapad]
|
||||
properties:
|
||||
id: { type: string }
|
||||
nummer: { type: integer }
|
||||
skapad: { type: string, format: date-time }
|
||||
delningskod: { type: string, nullable: true }
|
||||
metodikId: { type: string, nullable: true }
|
||||
responses:
|
||||
"200":
|
||||
description: Mottaget.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok: { type: boolean }
|
||||
"400": { $ref: "#/components/responses/Fel" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/arenden/{arendeId}/handelser:
|
||||
parameters:
|
||||
- name: arendeId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
get:
|
||||
tags: [Ärenden]
|
||||
summary: Hämta ärendets händelselogg
|
||||
responses:
|
||||
"200":
|
||||
description: Händelser i tidsordning.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
handelser:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/LoggPost" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"404": { $ref: "#/components/responses/Fel" }
|
||||
post:
|
||||
tags: [Ärenden]
|
||||
summary: Lägg till händelser (append-only, idempotent)
|
||||
description: >
|
||||
Lägger till händelser i loggen. Max 500 per anrop. En händelse med
|
||||
redan känt `id` ignoreras tyst — befintliga händelser skrivs aldrig
|
||||
över, och databastriggern stoppar alla ändringsförsök.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [handelser]
|
||||
properties:
|
||||
handelser:
|
||||
type: array
|
||||
maxItems: 500
|
||||
items: { $ref: "#/components/schemas/LoggPost" }
|
||||
responses:
|
||||
"200":
|
||||
description: Mottaget.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok: { type: boolean }
|
||||
"400": { $ref: "#/components/responses/Fel" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"404": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/oversikt:
|
||||
get:
|
||||
tags: [Översikt]
|
||||
summary: Organisationsöversikt med status och statistikunderlag
|
||||
description: >
|
||||
Kräver rollen `arbetsledare` eller `admin`. Status, deltagande
|
||||
tekniker och sammanfattning härleds ur händelseloggen.
|
||||
responses:
|
||||
"200":
|
||||
description: Alla ärenden i organisationen med härledd status.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
arenden:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/OversiktsRad" }
|
||||
"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. 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-Za-z0-9_-]+$" }
|
||||
responses:
|
||||
"200":
|
||||
description: Ärendet med nivåfiltrerad händelselogg.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
arende:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
nummer: { type: integer }
|
||||
skapad: { type: string, format: date-time }
|
||||
handelser:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/LoggPost" }
|
||||
niva: { $ref: "#/components/schemas/DelningsNiva" }
|
||||
"404": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/ai:
|
||||
post:
|
||||
tags: [AI]
|
||||
summary: AI-orkestern (separat tjänst bakom samma ingress)
|
||||
description: >
|
||||
Routas av ingressen till ai-orkester-tjänsten. Servern äger
|
||||
modellval, effort, systemprompt och svarsschema per uppgift:
|
||||
handledning (Claude Sonnet 5), granskning (Claude Opus 5),
|
||||
sammanfattning (Claude Sonnet 5), metodikval (Claude Haiku 4.5).
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [uppgift, prompt]
|
||||
properties:
|
||||
uppgift:
|
||||
type: string
|
||||
enum: [handledning, granskning, sammanfattning, metodikval]
|
||||
prompt: { type: string, maxLength: 40000 }
|
||||
responses:
|
||||
"200":
|
||||
description: Klassificerat AI-svar med modellen som svarade.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
modell: { type: string, example: claude-sonnet-5 }
|
||||
svar:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/AiSvar"
|
||||
- type: object
|
||||
properties:
|
||||
metodikId:
|
||||
type: string
|
||||
enum: [vibration, elsystem, generisk]
|
||||
"400": { $ref: "#/components/responses/Fel" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"502": { $ref: "#/components/responses/Fel" }
|
||||
"503": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: >
|
||||
HS256-JWT från /api/auth/logga-in eller /api/auth/registrera.
|
||||
Anspråk: sub (användar-id), namn, org (organisations-id),
|
||||
roll, iat, exp (12 timmar).
|
||||
responses:
|
||||
Inloggad:
|
||||
description: Inloggad — token + kontouppgifter.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
token: { type: string }
|
||||
namn: { type: string }
|
||||
roll: { $ref: "#/components/schemas/Roll" }
|
||||
organisation: { type: string }
|
||||
Fel:
|
||||
description: Felsvar.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error: { type: string }
|
||||
schemas:
|
||||
Fel:
|
||||
type: object
|
||||
properties:
|
||||
error: { type: string }
|
||||
Roll:
|
||||
type: string
|
||||
enum: [tekniker, arbetsledare, admin]
|
||||
OrganisationsInstallningar:
|
||||
type: object
|
||||
required: [objekttyper, identifieringsmetoder]
|
||||
properties:
|
||||
objekttyper:
|
||||
type: array
|
||||
minItems: 1
|
||||
items: { type: string }
|
||||
identifieringsmetoder:
|
||||
type: array
|
||||
minItems: 1
|
||||
items: { type: string }
|
||||
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:
|
||||
id: { type: string, format: uuid }
|
||||
epost: { type: string, format: email }
|
||||
namn: { type: string }
|
||||
roll: { $ref: "#/components/schemas/Roll" }
|
||||
Arende:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
nummer: { type: integer }
|
||||
skapad: { type: string, format: date-time }
|
||||
delningskod: { type: string, nullable: true }
|
||||
metodik_id: { type: string, nullable: true }
|
||||
LoggPost:
|
||||
type: object
|
||||
required: [id, tidpunkt, anvandare, handelse]
|
||||
properties:
|
||||
id: { type: string }
|
||||
tidpunkt: { type: string, format: date-time }
|
||||
anvandare: { type: string }
|
||||
handelse: { $ref: "#/components/schemas/Handelse" }
|
||||
Handelse:
|
||||
type: object
|
||||
description: >
|
||||
Diskriminerad på `typ`; fullständiga fältdefinitioner i klientens
|
||||
domänmodell (src/felsokning/domain.ts).
|
||||
required: [typ]
|
||||
additionalProperties: true
|
||||
properties:
|
||||
typ:
|
||||
type: string
|
||||
enum:
|
||||
- objekt_identifierat
|
||||
- felbeskrivning
|
||||
- fraga_besvarad
|
||||
- kontroll_utford
|
||||
- observation
|
||||
- matvarde
|
||||
- hypotes
|
||||
- foto
|
||||
- kommentar
|
||||
- kategori_byte
|
||||
- inaktivitet_forklarad
|
||||
- overlamning
|
||||
- ansvarig_satt
|
||||
- arbetsorder_skannad
|
||||
- arendetyp_satt
|
||||
- historik_kontrollerad
|
||||
- matarstallning
|
||||
- video
|
||||
- atgardsforslag
|
||||
- kundbeslut
|
||||
- atgard_utford
|
||||
- kvalitetskontroll
|
||||
- reproducering
|
||||
- felorsak
|
||||
- export_skapad
|
||||
- ai_svar
|
||||
- arende_avslutat
|
||||
OversiktsRad:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
nummer: { type: integer }
|
||||
skapad: { type: string, format: date-time }
|
||||
delningskod: { type: string, nullable: true }
|
||||
metodik_id: { type: string, nullable: true }
|
||||
antal_handelser: { type: integer }
|
||||
forsta: { type: string, format: date-time, nullable: true }
|
||||
senaste: { type: string, format: date-time, nullable: true }
|
||||
avslutat: { type: boolean }
|
||||
objekt: { type: string, nullable: true }
|
||||
felbeskrivning: { type: string, nullable: true }
|
||||
tekniker:
|
||||
type: array
|
||||
items: { type: string }
|
||||
nullable: true
|
||||
AiSvar:
|
||||
type: object
|
||||
required: [rader, nastaSteg]
|
||||
properties:
|
||||
rader:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [typ, text]
|
||||
properties:
|
||||
typ:
|
||||
type: string
|
||||
enum: [observation, verifierat, hypotes, rekommendation]
|
||||
text: { type: string }
|
||||
nastaSteg: { type: string }
|
||||
|
||||
security:
|
||||
- bearerAuth: []
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"name": "plattform",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plattform",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"pg": "^8.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "plattform",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "server.mjs",
|
||||
"scripts": {
|
||||
"start": "node server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"pg": "^8.13.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1174 @@
|
||||
// Plattformstjänsten — självhostad backend för Guidad Felsökning.
|
||||
//
|
||||
// Multi-tenant: varje organisation är en egen tenant. Registrering skapar
|
||||
// en organisation med en systemadministratör; admin skapar övriga
|
||||
// användare (tekniker/arbetsledare) i sin organisation. All ärendedata
|
||||
// är organisationsknuten — API:t släpper aldrig data över gränsen.
|
||||
//
|
||||
// Händelseloggen är append-only även i databasen (triggers) — den här
|
||||
// tjänsten exponerar medvetet inga update/delete-operationer.
|
||||
//
|
||||
// Miljövariabler:
|
||||
// DATABASE_URL Postgres-anslutning (krävs)
|
||||
// JWT_SECRET HS256-hemlighet, delas med ai-orkestern (krävs)
|
||||
// REGISTRERING_OPPEN "false" stänger nya organisationer (default öppen, beta)
|
||||
// INTEGRATION_NYCKEL 32 byte (hex/base64) — krypterar kundernas leverantörsuppgifter
|
||||
// TILLATNA_URSPRUNG kommaseparerade ursprung för CORS (utelämnad = "*")
|
||||
// TILLAT_INTERNA_UPPSLAG "true" tillåter leverantörsuppslag mot privata nät
|
||||
// ECM_REGLER_FIL / INTEGRATIONER_FIL sökvägar till utbytbar konfiguration
|
||||
// PORT default 8080
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import crypto, { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import pg from "pg";
|
||||
import { innehallsHash, mediatypGiltig, valjLager } from "./bilagor.mjs";
|
||||
|
||||
// API-first: OpenAPI-specen är en versionerad artefakt och serveras live.
|
||||
const OPENAPI = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "openapi.yaml"), "utf8");
|
||||
|
||||
// ECM Knowledge Library: regelpaketet är serverägd konfiguration — inte
|
||||
// appkod. Uppdateras genom att byta filen (ECM_REGLER_FIL kan peka på en
|
||||
// ConfigMap-mount i klustret) och starta om tjänsten; klienterna hämtar
|
||||
// det nya paketet vid nästa inloggning/sidladdning.
|
||||
const ECM_REGLER = readFileSync(
|
||||
process.env.ECM_REGLER_FIL ?? join(dirname(fileURLToPath(import.meta.url)), "ecm-regler.json"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Register över märkesspecifika kopplingar — data, inte kod. Nya
|
||||
// leverantörer läggs till i filen (eller via ConfigMap-mount) utan att
|
||||
// applikationen byggs om.
|
||||
const INTEGRATIONER = JSON.parse(
|
||||
readFileSync(
|
||||
process.env.INTEGRATIONER_FIL ?? join(dirname(fileURLToPath(import.meta.url)), "integrationer.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 8080);
|
||||
const MAX_KROPP = 4 * 1024 * 1024;
|
||||
// Bilagor får vara större än en händelse — ett videoklipp med ljud är
|
||||
// evidens som inte går att skala ned hur långt som helst.
|
||||
const MAX_BILAGA = 32 * 1024 * 1024;
|
||||
const TOKEN_LIVSTID_S = 12 * 60 * 60;
|
||||
const ROLLER = ["tekniker", "arbetsledare", "admin"];
|
||||
|
||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
|
||||
|
||||
// Var bilagornas innehåll hamnar. Felkonfigurerat s3-läge failar här,
|
||||
// vid start, i stället för vid första uppladdningen.
|
||||
const BILAGELAGER = valjLager(process.env, pool);
|
||||
|
||||
// Vilka ursprung som får anropa API:t från en webbläsare. I klusterdriften
|
||||
// serveras klienten från samma domän som API:t, så listan kan hållas kort.
|
||||
// TILLATNA_URSPRUNG="https://app.exempel.se,https://demo.exempel.se" —
|
||||
// utelämnad betyder "*" (öppet), vilket bara hör hemma i utveckling.
|
||||
const TILLATNA_URSPRUNG = (process.env.TILLATNA_URSPRUNG ?? "")
|
||||
.split(",")
|
||||
.map((u) => u.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
function ursprungFor(req) {
|
||||
const ursprung = req.headers.origin;
|
||||
if (TILLATNA_URSPRUNG.length === 0) return "*";
|
||||
return ursprung && TILLATNA_URSPRUNG.includes(ursprung) ? ursprung : TILLATNA_URSPRUNG[0];
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// ---- Takt-begränsning på inloggning -----------------------------------
|
||||
//
|
||||
// Den i minnet (beslutsvägen ovan) räcker för en pod. Inloggningen skalar
|
||||
// till flera repliker och behöver därför en gemensam räknare — den ligger
|
||||
// i databasen. Två spärrar: per konto (skyddar en enskild användare) och
|
||||
// per källa (stoppar den som betar av många konton från samma håll).
|
||||
|
||||
const INLOGG_FONSTER = "15 minutes";
|
||||
const INLOGG_TAK_KONTO = 10;
|
||||
const INLOGG_TAK_KALLA = 30;
|
||||
|
||||
function kallaFor(req) {
|
||||
// Bakom ingressen står klientens adress först i X-Forwarded-For.
|
||||
const vidarebefordrad = req.headers["x-forwarded-for"];
|
||||
const forsta = typeof vidarebefordrad === "string" ? vidarebefordrad.split(",")[0].trim() : "";
|
||||
return (forsta || req.socket?.remoteAddress || "").slice(0, 64);
|
||||
}
|
||||
|
||||
async function inloggningSparrad(epost, kalla) {
|
||||
const rad = await pool.query(
|
||||
`select
|
||||
count(*) filter (where epost = $1) as konto,
|
||||
count(*) filter (where kalla = $2 and $2 <> '') as kalla
|
||||
from inloggningsforsok
|
||||
where lyckades = false and tidpunkt > now() - interval '${INLOGG_FONSTER}'`,
|
||||
[epost, kalla],
|
||||
);
|
||||
const { konto, kalla: franKalla } = rad.rows[0];
|
||||
return Number(konto) >= INLOGG_TAK_KONTO || Number(franKalla) >= INLOGG_TAK_KALLA;
|
||||
}
|
||||
|
||||
async function loggaForsok(epost, kalla, lyckades) {
|
||||
await pool.query(
|
||||
`insert into inloggningsforsok (epost, kalla, lyckades) values ($1, $2, $3)`,
|
||||
[epost, kalla, lyckades],
|
||||
);
|
||||
// Städa bort det som inte längre kan påverka någon spärr. Billigt nog
|
||||
// att göra i skrivvägen och slipper ett schemalagt jobb.
|
||||
if (Math.random() < 0.02) {
|
||||
await pool.query(`delete from inloggningsforsok where tidpunkt < now() - interval '1 day'`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- JWT (HS256, utan beroenden) --------------------------------------
|
||||
|
||||
const b64url = (data) => Buffer.from(data).toString("base64url");
|
||||
|
||||
export function skapaJwt(anspr, hemlighet) {
|
||||
const huvud = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const kropp = b64url(JSON.stringify(anspr));
|
||||
const signatur = createHmac("sha256", hemlighet).update(`${huvud}.${kropp}`).digest("base64url");
|
||||
return `${huvud}.${kropp}.${signatur}`;
|
||||
}
|
||||
|
||||
export function verifieraJwt(token, hemlighet) {
|
||||
const delar = token.split(".");
|
||||
if (delar.length !== 3) return null;
|
||||
const forvantad = createHmac("sha256", hemlighet).update(`${delar[0]}.${delar[1]}`).digest("base64url");
|
||||
const a = Buffer.from(delar[2]);
|
||||
const b = Buffer.from(forvantad);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
try {
|
||||
const anspr = JSON.parse(Buffer.from(delar[1], "base64url").toString("utf8"));
|
||||
if (typeof anspr.exp === "number" && anspr.exp * 1000 < Date.now()) return null;
|
||||
return anspr;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Integrationsuppgifter: kryptering i vila ------------------------
|
||||
//
|
||||
// Kundens egna leverantörsnycklar lagras krypterade med AES-256-GCM.
|
||||
// Nyckeln kommer ur INTEGRATION_NYCKEL (32 byte, hex eller base64) och
|
||||
// finns bara i driftens hemlighetshantering. Saknas nyckeln kan
|
||||
// integrationer varken sparas eller användas — fail closed.
|
||||
|
||||
function integrationsNyckel() {
|
||||
const ra = process.env.INTEGRATION_NYCKEL;
|
||||
if (!ra) return null;
|
||||
const buf = /^[0-9a-fA-F]{64}$/.test(ra) ? Buffer.from(ra, "hex") : Buffer.from(ra, "base64");
|
||||
return buf.length === 32 ? buf : null;
|
||||
}
|
||||
|
||||
export function kryptera(klartext, nyckel) {
|
||||
const iv = randomBytes(12);
|
||||
const chiffer = createCipheriv("aes-256-gcm", nyckel, iv);
|
||||
const data = Buffer.concat([chiffer.update(klartext, "utf8"), chiffer.final()]);
|
||||
return `${iv.toString("base64")}.${chiffer.getAuthTag().toString("base64")}.${data.toString("base64")}`;
|
||||
}
|
||||
|
||||
export function dekryptera(paket, nyckel) {
|
||||
const [iv, tagg, data] = paket.split(".");
|
||||
const dechiffer = createDecipheriv("aes-256-gcm", nyckel, Buffer.from(iv, "base64"));
|
||||
dechiffer.setAuthTag(Buffer.from(tagg, "base64"));
|
||||
return Buffer.concat([dechiffer.update(Buffer.from(data, "base64")), dechiffer.final()]).toString("utf8");
|
||||
}
|
||||
|
||||
// Hemliga fält lämnar aldrig servern i klartext — klienten ser bara att
|
||||
// ett värde finns och dess sista tecken.
|
||||
export function maskera(varde) {
|
||||
if (typeof varde !== "string" || varde.length === 0) return "";
|
||||
if (varde.length <= 4) return "••••";
|
||||
return `••••${varde.slice(-4)}`;
|
||||
}
|
||||
|
||||
function leverantorsDef(id) {
|
||||
return INTEGRATIONER.leverantorer.find((l) => l.id === id);
|
||||
}
|
||||
|
||||
// ---- Delningsfilter: tillåtelselista, inte nekalista ------------------
|
||||
//
|
||||
// Vilka händelsetyper som får lämna verkstaden är en integritetsgräns.
|
||||
// Med en nekalista blir varje NY händelsetyp automatiskt synlig för
|
||||
// kunden tills någon kommer ihåg att neka den — fel håll att fela åt.
|
||||
// Här listas i stället uttryckligen vad som får delas; allt annat är
|
||||
// internt tills det aktivt släpps fram. Testet i delning.test.ts kräver
|
||||
// att varje händelsetyp i domänmodellen är klassificerad.
|
||||
export const DELBART_KUND = [
|
||||
"objekt_identifierat",
|
||||
"arendetyp_satt",
|
||||
"felbeskrivning",
|
||||
"fraga_besvarad",
|
||||
"kontroll_utford",
|
||||
"observation",
|
||||
"matvarde",
|
||||
"foto",
|
||||
"video",
|
||||
"kommentar",
|
||||
"inaktivitet_forklarad",
|
||||
"overlamning",
|
||||
"historik_kontrollerad",
|
||||
"matarstallning",
|
||||
"reproducering",
|
||||
"felorsak",
|
||||
"atgardsforslag",
|
||||
"kundbeslut",
|
||||
"atgard_utford",
|
||||
"kvalitetskontroll",
|
||||
"export_skapad",
|
||||
"arende_avslutat",
|
||||
];
|
||||
|
||||
// Extern partner (försäkringsbolag, tillverkare) ser dessutom hypoteser
|
||||
// — alltid märkta som ej verifierade.
|
||||
export const DELBART_PARTNER = [...DELBART_KUND, "hypotes"];
|
||||
|
||||
// Aldrig utanför organisationen: arbetsledning, arbetsmaterial och
|
||||
// underlag som kan läsas som konstateranden.
|
||||
export const ENDAST_INTERNT = ["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"];
|
||||
|
||||
export function synligaTyper(niva) {
|
||||
if (niva === "intern") return null; // full insyn — ingen filtrering
|
||||
return niva === "partner" ? DELBART_PARTNER : DELBART_KUND;
|
||||
}
|
||||
|
||||
// ---- Hjälpare ---------------------------------------------------------
|
||||
|
||||
function svara(res, status, kropp) {
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json",
|
||||
// Ursprunget sätts en gång per anrop i hanteraren nedan.
|
||||
"Access-Control-Allow-Origin": res.ursprung ?? "*",
|
||||
Vary: "Origin",
|
||||
"Access-Control-Allow-Headers": "authorization, content-type",
|
||||
});
|
||||
res.end(JSON.stringify(kropp));
|
||||
}
|
||||
|
||||
async function lasKropp(req) {
|
||||
const bitar = [];
|
||||
let storlek = 0;
|
||||
for await (const bit of req) {
|
||||
storlek += bit.length;
|
||||
if (storlek > MAX_KROPP) throw new Error("för stor kropp");
|
||||
bitar.push(bit);
|
||||
}
|
||||
return JSON.parse(Buffer.concat(bitar).toString("utf8"));
|
||||
}
|
||||
|
||||
async function lasBinart(req, tak) {
|
||||
const bitar = [];
|
||||
let storlek = 0;
|
||||
for await (const bit of req) {
|
||||
storlek += bit.length;
|
||||
if (storlek > tak) throw new Error("för stor kropp");
|
||||
bitar.push(bit);
|
||||
}
|
||||
return Buffer.concat(bitar);
|
||||
}
|
||||
|
||||
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) : "";
|
||||
return token ? verifieraJwt(token, hemlighet) : null;
|
||||
}
|
||||
|
||||
// En giltig signatur räcker inte. Kontot måste fortfarande vara aktivt,
|
||||
// och token-versionen måste stämma med kontots — annars har den
|
||||
// återkallats. Ett uppslag på primärnyckeln per anrop, vilket gör
|
||||
// återkallelsen omedelbar i stället för att gälla vid nästa utgång.
|
||||
async function kontoGiltigt(anspr) {
|
||||
const rad = await pool.query(
|
||||
`select aktiv, token_version from anvandare where id = $1 and organisation_id = $2`,
|
||||
[anspr.sub, anspr.org],
|
||||
);
|
||||
if (rad.rowCount === 0) return false;
|
||||
if (rad.rows[0].aktiv === false) return false;
|
||||
return (anspr.tv ?? 0) === rad.rows[0].token_version;
|
||||
}
|
||||
|
||||
// Verifierar att ärendet tillhör användarens organisation.
|
||||
async function arendeIOrg(arendeId, organisationId) {
|
||||
const rader = await pool.query(
|
||||
`select 1 from felsokning_arenden where id = $1 and organisation_id = $2`,
|
||||
[arendeId, organisationId],
|
||||
);
|
||||
return rader.rowCount > 0;
|
||||
}
|
||||
|
||||
// Adresser som aldrig får nås utifrån ett kundkonfigurerat uppslag:
|
||||
// loopback, privata nät, link-local (inkl. molnens metadatatjänst),
|
||||
// CGNAT och IPv6-motsvarigheterna.
|
||||
export function arPrivatAdress(adress) {
|
||||
const v4 = adress.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (v4) {
|
||||
const [a, b] = v4.slice(1).map(Number);
|
||||
return (
|
||||
a === 0 || a === 10 || a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
a >= 224
|
||||
);
|
||||
}
|
||||
const v6 = adress.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
if (v6 === "::1" || v6 === "::") return true;
|
||||
// Unika lokala adresser (fc00::/7), link-local (fe80::/10) och
|
||||
// IPv4-mappade adresser som ::ffff:127.0.0.1.
|
||||
if (/^f[cd]/.test(v6) || /^fe[89ab]/.test(v6)) return true;
|
||||
const mappad = v6.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
return mappad ? arPrivatAdress(mappad[1]) : false;
|
||||
}
|
||||
|
||||
// Slår upp värdnamnet och avgör om något av svaren pekar inåt. Namn som
|
||||
// resolvar till interna adresser fångas också — inte bara IP-literaler.
|
||||
export async function pekarInat(url, slaUpp = lookup) {
|
||||
let vard;
|
||||
try {
|
||||
vard = new URL(url).hostname;
|
||||
} catch {
|
||||
return "ogiltig URL";
|
||||
}
|
||||
const bar = vard.replace(/^\[|\]$/g, "");
|
||||
if (/^[\d.]+$/.test(bar) || bar.includes(":")) {
|
||||
return arPrivatAdress(bar) ? bar : null;
|
||||
}
|
||||
if (bar === "localhost" || bar.endsWith(".localhost") || bar.endsWith(".internal") || bar.endsWith(".local")) {
|
||||
return bar;
|
||||
}
|
||||
try {
|
||||
const traffar = await slaUpp(bar, { all: true });
|
||||
const intern = traffar.find((t) => arPrivatAdress(t.address));
|
||||
return intern ? intern.address : null;
|
||||
} catch {
|
||||
// Namnet går inte att slå upp — låt anropet självt misslyckas i
|
||||
// stället för att påstå något om var det pekar.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Generiskt uppslag mot en leverantör. All variation ligger i registret
|
||||
// (URL-mall, autentiseringstyp, svarsmappning) — inga leverantörs-
|
||||
// specifika kodgrenar.
|
||||
export async function gorUppslag(def, uppgifter, identifierare, hamtare = fetch) {
|
||||
const u = def.uppslag ?? {};
|
||||
const mall = uppgifter[u.urlFalt ?? "bas_url"];
|
||||
if (typeof mall !== "string" || !/^https?:\/\//.test(mall)) {
|
||||
return { ok: false, fel: "Bas-URL saknas eller är ogiltig." };
|
||||
}
|
||||
let url = mall.replace(/\{vin\}/gi, encodeURIComponent(identifierare)).replace(/\{regnr\}/gi, encodeURIComponent(identifierare));
|
||||
|
||||
// Bas-URL:en sätts av kundens administratör men anropet görs av vår
|
||||
// server. Utan spärr blir det en väg in i klustrets interna nät och
|
||||
// molnets metadatatjänst (169.254.169.254) — tenantens administratör
|
||||
// är inte infrastrukturens ägare. Interna mål tillåts bara när driften
|
||||
// uttryckligen öppnat för det (verkstäder med OEM-server på egna nätet).
|
||||
if (process.env.TILLAT_INTERNA_UPPSLAG !== "true") {
|
||||
const internt = await pekarInat(url);
|
||||
if (internt) return { ok: false, fel: `Bas-URL:en pekar på en intern adress (${internt}) och tillåts inte.` };
|
||||
}
|
||||
const headers = { Accept: "application/json" };
|
||||
|
||||
if (u.auth === "bearer") headers.Authorization = `Bearer ${uppgifter[u.authFalt]}`;
|
||||
if (u.auth === "header") headers[u.authHeader ?? "X-Api-Key"] = uppgifter[u.authFalt];
|
||||
if (u.auth === "basic") {
|
||||
const par = `${uppgifter[u.authFalt]}:${uppgifter[u.authFalt2]}`;
|
||||
headers.Authorization = `Basic ${Buffer.from(par).toString("base64")}`;
|
||||
}
|
||||
if (u.auth === "query") {
|
||||
url += `${url.includes("?") ? "&" : "?"}${encodeURIComponent(u.authParam ?? "key")}=${encodeURIComponent(uppgifter[u.authFalt])}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const svarFran = await hamtare(url, { headers, signal: AbortSignal.timeout(10_000) });
|
||||
if (!svarFran.ok) return { ok: false, fel: `Leverantören svarade ${svarFran.status}.` };
|
||||
const data = await svarFran.json();
|
||||
const fordon = {};
|
||||
for (const [vart, deras] of Object.entries(u.svarsfalt ?? {})) {
|
||||
const varde = deras.split(".").reduce((niva, del) => (niva == null ? niva : niva[del]), data);
|
||||
if (varde !== undefined && varde !== null && `${varde}`.trim()) fordon[vart] = `${varde}`.trim();
|
||||
}
|
||||
if (Object.keys(fordon).length === 0) return { ok: false, fel: "Leverantören returnerade inga kända fält." };
|
||||
return { ok: true, fordon };
|
||||
} catch (fel) {
|
||||
return { ok: false, fel: fel?.name === "TimeoutError" ? "Leverantören svarade inte i tid." : "Anropet misslyckades." };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Server -----------------------------------------------------------
|
||||
|
||||
// Lämnar ut innehållet — men bara efter att det kontrollerats mot
|
||||
// hashen i loggen. Stämmer det inte säger vi det rakt ut i stället för
|
||||
// att visa en bild som kan ha bytts ut.
|
||||
async function skickaBilaga(res, rad) {
|
||||
const data = await BILAGELAGER.hamta(rad.hash);
|
||||
if (!data) return svara(res, 404, { error: "Innehållet saknas i lagringen." });
|
||||
if (innehallsHash(data) !== rad.hash) {
|
||||
console.error("bilaga: innehållet stämmer inte med hashen i loggen", rad.hash);
|
||||
return svara(res, 409, { error: "Innehållet stämmer inte med det som dokumenterades." });
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": rad.mediatyp,
|
||||
"Content-Length": data.length,
|
||||
// Innehållsadresserat — samma id ger alltid samma bytes.
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"Access-Control-Allow-Origin": res.ursprung ?? "*",
|
||||
Vary: "Origin",
|
||||
});
|
||||
return res.end(data);
|
||||
}
|
||||
|
||||
export function skapaServer() {
|
||||
function loggaIn(res, rad, hemlighet) {
|
||||
const nu = Math.floor(Date.now() / 1000);
|
||||
const token = skapaJwt(
|
||||
{
|
||||
sub: rad.id,
|
||||
namn: rad.namn,
|
||||
org: rad.organisation_id,
|
||||
roll: rad.roll,
|
||||
// Bärs med så att en återkallelse gör token ogiltig direkt.
|
||||
tv: rad.token_version ?? 0,
|
||||
iat: nu,
|
||||
exp: nu + TOKEN_LIVSTID_S,
|
||||
},
|
||||
hemlighet,
|
||||
);
|
||||
return svara(res, 200, { token, namn: rad.namn, roll: rad.roll, organisation: rad.org_namn });
|
||||
}
|
||||
|
||||
return createServer(async (req, res) => {
|
||||
res.ursprung = ursprungFor(req);
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204, {
|
||||
"Access-Control-Allow-Origin": res.ursprung,
|
||||
Vary: "Origin",
|
||||
"Access-Control-Allow-Headers": "authorization, content-type",
|
||||
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||
});
|
||||
return res.end();
|
||||
}
|
||||
const url = new URL(req.url ?? "/", "http://intern");
|
||||
const vag = url.pathname;
|
||||
|
||||
try {
|
||||
if (req.method === "GET" && vag === "/halsa") {
|
||||
return svara(res, 200, { status: "ok" });
|
||||
}
|
||||
if (req.method === "GET" && vag === "/api/openapi.yaml") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/yaml; charset=utf-8",
|
||||
"Access-Control-Allow-Origin": res.ursprung,
|
||||
});
|
||||
return res.end(OPENAPI);
|
||||
}
|
||||
|
||||
const hemlighet = process.env.JWT_SECRET;
|
||||
if (!hemlighet) return svara(res, 503, { error: "Tjänsten är inte konfigurerad." });
|
||||
|
||||
// -- Registrering: skapar organisation + systemadministratör --
|
||||
if (req.method === "POST" && vag === "/api/auth/registrera") {
|
||||
if (process.env.REGISTRERING_OPPEN === "false") {
|
||||
return svara(res, 403, { error: "Registrering är stängd — kontakta er administratör." });
|
||||
}
|
||||
const { epost, losenord, namn, organisation } = await lasKropp(req);
|
||||
if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim() || !organisation?.trim()) {
|
||||
return svara(res, 400, {
|
||||
error: "Ange organisation, namn, e-post och lösenord (minst 8 tecken).",
|
||||
});
|
||||
}
|
||||
const klientDb = await pool.connect();
|
||||
try {
|
||||
await klientDb.query("begin");
|
||||
const org = await klientDb.query(
|
||||
`insert into organisationer (namn) values ($1) returning id, namn`,
|
||||
[organisation.trim()],
|
||||
);
|
||||
const rad = await klientDb.query(
|
||||
`insert into anvandare (organisation_id, epost, losen_hash, namn, roll)
|
||||
values ($1, lower($2), crypt($3, gen_salt('bf')), $4, 'admin')
|
||||
on conflict (epost) do nothing
|
||||
returning id, namn, organisation_id, roll, token_version`,
|
||||
[org.rows[0].id, epost.trim(), losenord, namn.trim()],
|
||||
);
|
||||
if (rad.rowCount === 0) {
|
||||
await klientDb.query("rollback");
|
||||
return svara(res, 409, { error: "E-postadressen är redan registrerad." });
|
||||
}
|
||||
await klientDb.query("commit");
|
||||
return loggaIn(res, { ...rad.rows[0], org_namn: org.rows[0].namn }, hemlighet);
|
||||
} catch (fel) {
|
||||
await klientDb.query("rollback");
|
||||
throw fel;
|
||||
} finally {
|
||||
klientDb.release();
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "POST" && vag === "/api/auth/logga-in") {
|
||||
const { epost, losenord } = await lasKropp(req);
|
||||
const normaliserad = (epost ?? "").trim().toLowerCase();
|
||||
const kalla = kallaFor(req);
|
||||
|
||||
if (await inloggningSparrad(normaliserad, kalla)) {
|
||||
return svara(res, 429, { error: "För många misslyckade försök — vänta en stund och försök igen." });
|
||||
}
|
||||
|
||||
const rader = await pool.query(
|
||||
`select a.id, a.namn, a.organisation_id, a.roll, a.aktiv, a.token_version, o.namn as org_namn
|
||||
from anvandare a join organisationer o on o.id = a.organisation_id
|
||||
where a.epost = $1 and a.losen_hash = crypt($2, a.losen_hash)`,
|
||||
[normaliserad, losenord ?? ""],
|
||||
);
|
||||
if (rader.rowCount === 0) {
|
||||
await loggaForsok(normaliserad, kalla, false);
|
||||
return svara(res, 401, { error: "Fel e-post eller lösenord." });
|
||||
}
|
||||
// Ett avaktiverat konto räknas som misslyckat försök: annars blir
|
||||
// svarstiden ett sätt att lista ut vilka konton som finns.
|
||||
if (rader.rows[0].aktiv === false) {
|
||||
await loggaForsok(normaliserad, kalla, false);
|
||||
return svara(res, 403, { error: "Kontot är avstängt — kontakta er administratör." });
|
||||
}
|
||||
await loggaForsok(normaliserad, kalla, true);
|
||||
return loggaIn(res, rader.rows[0], hemlighet);
|
||||
}
|
||||
|
||||
// -- Publik delning (Live Share via delningskod) --
|
||||
// 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) {
|
||||
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 (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 synliga = synligaTyper(niva);
|
||||
const handelser = synliga
|
||||
? await pool.query(
|
||||
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
|
||||
where arende_id = $1 and handelse->>'typ' = any($2)
|
||||
order by tidpunkt, id`,
|
||||
[arendeId, synliga],
|
||||
)
|
||||
: await pool.query(
|
||||
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
|
||||
where arende_id = $1 order by tidpunkt, id`,
|
||||
[arendeId],
|
||||
);
|
||||
return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows, niva });
|
||||
}
|
||||
|
||||
// Bilaga via delningslänk. Bilden får bara hämtas om den hör till
|
||||
// en händelse som nivån faktiskt får se — annars vore det en väg
|
||||
// runt delningsfiltret.
|
||||
const delatBilaga = vag.match(/^\/api\/delad\/([A-Za-z0-9_-]+)\/bilagor\/([A-Za-z0-9_-]+)$/);
|
||||
if (req.method === "GET" && delatBilaga) {
|
||||
const delning = await pool.query(
|
||||
`select arende_id, niva from delningar where kod = $1 and aterkallad is null`,
|
||||
[delatBilaga[1]],
|
||||
);
|
||||
if (delning.rowCount === 0) return svara(res, 404, { error: "Bilagan är inte tillgänglig." });
|
||||
const synliga = synligaTyper(delning.rows[0].niva);
|
||||
const rad = await pool.query(
|
||||
`select b.hash, b.mediatyp from bilagor b
|
||||
where b.id = $1 and b.arende_id = $2
|
||||
and exists (
|
||||
select 1 from felsokning_handelser h
|
||||
where h.arende_id = b.arende_id
|
||||
and h.handelse->>'bilagaId' = b.id
|
||||
and ($3::text[] is null or h.handelse->>'typ' = any($3))
|
||||
)`,
|
||||
[delatBilaga[2], delning.rows[0].arende_id, synliga],
|
||||
);
|
||||
if (rad.rowCount === 0) return svara(res, 404, { error: "Bilagan är inte tillgänglig." });
|
||||
return skickaBilaga(res, rad.rows[0]);
|
||||
}
|
||||
|
||||
// -- 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." });
|
||||
if (!(await kontoGiltigt(anspr))) {
|
||||
return svara(res, 401, { error: "Sessionen gäller inte längre — logga in på nytt." });
|
||||
}
|
||||
|
||||
// Användarhantering: endast systemadministratör, endast egen org.
|
||||
if (vag === "/api/anvandare") {
|
||||
// Läsning: admin + arbetsledare (behövs för omfördelning).
|
||||
// Skapande: endast admin.
|
||||
if (anspr.roll === "tekniker") return svara(res, 403, { error: "Kräver arbetsledar- eller administratörsbehörighet." });
|
||||
if (req.method === "GET") {
|
||||
const rader = await pool.query(
|
||||
`select id, epost, namn, roll, aktiv from anvandare where organisation_id = $1 order by namn`,
|
||||
[anspr.org],
|
||||
);
|
||||
return svara(res, 200, { anvandare: rader.rows });
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
|
||||
const { epost, losenord, namn, roll } = await lasKropp(req);
|
||||
if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim() || !ROLLER.includes(roll)) {
|
||||
return svara(res, 400, { error: "Ange namn, e-post, roll och lösenord (minst 8 tecken)." });
|
||||
}
|
||||
const rad = await pool.query(
|
||||
`insert into anvandare (organisation_id, epost, losen_hash, namn, roll)
|
||||
values ($1, lower($2), crypt($3, gen_salt('bf')), $4, $5)
|
||||
on conflict (epost) do nothing
|
||||
returning id, epost, namn, roll, aktiv`,
|
||||
[anspr.org, epost.trim(), losenord, namn.trim(), roll],
|
||||
);
|
||||
if (rad.rowCount === 0) return svara(res, 409, { error: "E-postadressen är redan registrerad." });
|
||||
return svara(res, 200, rad.rows[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Stäng av eller öppna ett konto. Att stänga av höjer också
|
||||
// token-versionen, så pågående sessioner upphör direkt — annars
|
||||
// vore avstängningen verkningslös i upp till tolv timmar.
|
||||
const kontoVag = vag.match(/^\/api\/anvandare\/([0-9a-fA-F-]{36})\/(avaktivera|aktivera)$/);
|
||||
if (req.method === "POST" && kontoVag) {
|
||||
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
|
||||
const [, id, atgard] = kontoVag;
|
||||
if (id === anspr.sub) {
|
||||
return svara(res, 400, { error: "Du kan inte stänga av ditt eget konto." });
|
||||
}
|
||||
const aktivera = atgard === "aktivera";
|
||||
const rad = await pool.query(
|
||||
`update anvandare
|
||||
set aktiv = $3,
|
||||
token_version = token_version + case when $3 then 0 else 1 end
|
||||
where id = $1 and organisation_id = $2
|
||||
returning id, namn, aktiv`,
|
||||
[id, anspr.org, aktivera],
|
||||
);
|
||||
if (rad.rowCount === 0) return svara(res, 404, { error: "Användaren finns inte." });
|
||||
return svara(res, 200, rad.rows[0]);
|
||||
}
|
||||
|
||||
// Logga ut på alla enheter — den egna vägen ut när en telefon
|
||||
// tappats bort. Höjer den egna token-versionen.
|
||||
if (req.method === "POST" && vag === "/api/auth/logga-ut-alla") {
|
||||
await pool.query(
|
||||
`update anvandare set token_version = token_version + 1 where id = $1 and organisation_id = $2`,
|
||||
[anspr.sub, anspr.org],
|
||||
);
|
||||
return svara(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// -- Bilagor --
|
||||
// Innehållet ligger utanför händelsen; loggen bär referensen och
|
||||
// innehållets hash. Uppladdningen sker före händelsen skrivs, så
|
||||
// en händelse aldrig pekar på något som inte finns.
|
||||
const laddaUppVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/bilagor$/);
|
||||
if (req.method === "POST" && laddaUppVag) {
|
||||
if (!(await arendeIOrg(laddaUppVag[1], anspr.org))) {
|
||||
return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
|
||||
}
|
||||
const mediatyp = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
|
||||
if (!mediatypGiltig(mediatyp)) {
|
||||
return svara(res, 415, { error: "Endast bilder och videoklipp kan laddas upp." });
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await lasBinart(req, MAX_BILAGA);
|
||||
} catch {
|
||||
return svara(res, 413, { error: `Bilagan är för stor (max ${MAX_BILAGA / 1024 / 1024} MB).` });
|
||||
}
|
||||
if (data.length === 0) return svara(res, 400, { error: "Bilagan är tom." });
|
||||
|
||||
const hash = innehallsHash(data);
|
||||
await BILAGELAGER.spara(hash, data);
|
||||
const id = `bil-${nyKod()}`;
|
||||
await pool.query(
|
||||
`insert into bilagor (id, organisation_id, arende_id, hash, mediatyp, storlek, laddad_av)
|
||||
values ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[id, anspr.org, laddaUppVag[1], hash, mediatyp, data.length, anspr.sub],
|
||||
);
|
||||
// Hashen går tillbaka till klienten och hamnar i händelsen —
|
||||
// därmed står den i den append-only-skyddade loggen.
|
||||
return svara(res, 200, { id, hash, mediatyp, storlek: data.length });
|
||||
}
|
||||
|
||||
const bilagaVag = vag.match(/^\/api\/bilagor\/([A-Za-z0-9_-]+)$/);
|
||||
if (req.method === "GET" && bilagaVag) {
|
||||
const rad = await pool.query(
|
||||
`select hash, mediatyp from bilagor where id = $1 and organisation_id = $2`,
|
||||
[bilagaVag[1], anspr.org],
|
||||
);
|
||||
if (rad.rowCount === 0) return svara(res, 404, { error: "Bilagan finns inte." });
|
||||
return skickaBilaga(res, rad.rows[0]);
|
||||
}
|
||||
|
||||
// ECM Knowledge Library: aktuellt regelpaket för inloggade klienter.
|
||||
if (req.method === "GET" && vag === "/api/ecm/regler") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": res.ursprung,
|
||||
});
|
||||
return res.end(ECM_REGLER);
|
||||
}
|
||||
|
||||
// -- Märkesspecifika kopplingar (integrationer) --
|
||||
// Registret läses av alla inloggade (så inställningssidan kan visa
|
||||
// vilka leverantörer som finns); uppgifterna hanteras endast av
|
||||
// systemadministratören och returneras alltid maskerade.
|
||||
if (req.method === "GET" && vag === "/api/integrationer/leverantorer") {
|
||||
return svara(res, 200, INTEGRATIONER);
|
||||
}
|
||||
|
||||
if (vag === "/api/integrationer") {
|
||||
if (req.method === "GET") {
|
||||
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
|
||||
const nyckel = integrationsNyckel();
|
||||
const rader = await pool.query(
|
||||
`select leverantor, uppgifter_krypt, aktiv, uppdaterad, senast_testad, senaste_status
|
||||
from integrationer where organisation_id = $1 order by leverantor`,
|
||||
[anspr.org],
|
||||
);
|
||||
const integrationer = rader.rows.map((rad) => {
|
||||
const def = leverantorsDef(rad.leverantor);
|
||||
let uppgifter = {};
|
||||
try {
|
||||
if (nyckel) uppgifter = JSON.parse(dekryptera(rad.uppgifter_krypt, nyckel));
|
||||
} catch {
|
||||
// Fel nyckel eller manipulerad rad — visa inga värden.
|
||||
}
|
||||
const maskerade = {};
|
||||
for (const falt of def?.falt ?? []) {
|
||||
const varde = uppgifter[falt.nyckel];
|
||||
maskerade[falt.nyckel] = falt.hemlig ? maskera(varde) : (varde ?? "");
|
||||
}
|
||||
return {
|
||||
leverantor: rad.leverantor,
|
||||
namn: def?.namn ?? rad.leverantor,
|
||||
aktiv: rad.aktiv,
|
||||
uppdaterad: rad.uppdaterad,
|
||||
senast_testad: rad.senast_testad,
|
||||
senaste_status: rad.senaste_status,
|
||||
uppgifter: maskerade,
|
||||
};
|
||||
});
|
||||
return svara(res, 200, { integrationer, krypteringKonfigurerad: !!nyckel });
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
|
||||
const nyckel = integrationsNyckel();
|
||||
if (!nyckel) {
|
||||
return svara(res, 503, {
|
||||
error: "Kryptering är inte konfigurerad (INTEGRATION_NYCKEL saknas) — uppgifter kan inte sparas.",
|
||||
});
|
||||
}
|
||||
const { leverantor, uppgifter, aktiv } = await lasKropp(req);
|
||||
const def = leverantorsDef(leverantor);
|
||||
if (!def) return svara(res, 400, { error: "Okänd leverantör." });
|
||||
if (!uppgifter || typeof uppgifter !== "object") {
|
||||
return svara(res, 400, { error: "Uppgifter saknas." });
|
||||
}
|
||||
// Endast leverantörens definierade fält sparas, och varje fält
|
||||
// måste ha ett värde — inga tomma nycklar i vila.
|
||||
const rena = {};
|
||||
for (const falt of def.falt) {
|
||||
const varde = uppgifter[falt.nyckel];
|
||||
if (typeof varde !== "string" || !varde.trim()) {
|
||||
return svara(res, 400, { error: `Fältet "${falt.etikett}" måste fyllas i.` });
|
||||
}
|
||||
if (varde.length > 2000) return svara(res, 400, { error: "Ett värde är för långt." });
|
||||
rena[falt.nyckel] = varde.trim();
|
||||
}
|
||||
await pool.query(
|
||||
`insert into integrationer (organisation_id, leverantor, uppgifter_krypt, aktiv)
|
||||
values ($1, $2, $3, $4)
|
||||
on conflict (organisation_id, leverantor)
|
||||
do update set uppgifter_krypt = excluded.uppgifter_krypt,
|
||||
aktiv = excluded.aktiv,
|
||||
uppdaterad = now(),
|
||||
senast_testad = null,
|
||||
senaste_status = null`,
|
||||
[anspr.org, leverantor, kryptera(JSON.stringify(rena), nyckel), aktiv !== false],
|
||||
);
|
||||
return svara(res, 200, { ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
const integrationVag = vag.match(/^\/api\/integrationer\/([a-z0-9_]+)$/);
|
||||
if (req.method === "DELETE" && integrationVag) {
|
||||
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
|
||||
await pool.query(`delete from integrationer where organisation_id = $1 and leverantor = $2`, [
|
||||
anspr.org,
|
||||
integrationVag[1],
|
||||
]);
|
||||
return svara(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// Uppslag mot märkesspecifik koppling. Anropet görs alltid av
|
||||
// servern — kundens leverantörsnycklar når aldrig webbläsaren.
|
||||
const uppslagVag = vag.match(/^\/api\/integrationer\/([a-z0-9_]+)\/uppslag$/);
|
||||
if (req.method === "POST" && uppslagVag) {
|
||||
const nyckel = integrationsNyckel();
|
||||
const def = leverantorsDef(uppslagVag[1]);
|
||||
if (!def) return svara(res, 400, { error: "Okänd leverantör." });
|
||||
if (!nyckel) return svara(res, 503, { error: "Kryptering är inte konfigurerad." });
|
||||
|
||||
const { identifierare } = await lasKropp(req);
|
||||
if (typeof identifierare !== "string" || !/^[A-Za-z0-9-]{4,20}$/.test(identifierare.trim())) {
|
||||
return svara(res, 400, { error: "Ogiltig identifierare." });
|
||||
}
|
||||
const rad = await pool.query(
|
||||
`select uppgifter_krypt from integrationer
|
||||
where organisation_id = $1 and leverantor = $2 and aktiv = true`,
|
||||
[anspr.org, uppslagVag[1]],
|
||||
);
|
||||
if (rad.rowCount === 0) return svara(res, 404, { error: "Kopplingen är inte konfigurerad." });
|
||||
|
||||
let uppgifter;
|
||||
try {
|
||||
uppgifter = JSON.parse(dekryptera(rad.rows[0].uppgifter_krypt, nyckel));
|
||||
} catch {
|
||||
return svara(res, 500, { error: "Uppgifterna kunde inte läsas — spara om kopplingen." });
|
||||
}
|
||||
|
||||
const resultat = await gorUppslag(def, uppgifter, identifierare.trim().toUpperCase());
|
||||
await pool.query(
|
||||
`update integrationer set senast_testad = now(), senaste_status = $3
|
||||
where organisation_id = $1 and leverantor = $2`,
|
||||
[anspr.org, uppslagVag[1], resultat.ok ? "ok" : `fel: ${resultat.fel}`.slice(0, 200)],
|
||||
);
|
||||
if (!resultat.ok) return svara(res, 502, { error: resultat.fel });
|
||||
return svara(res, 200, { fordon: resultat.fordon });
|
||||
}
|
||||
|
||||
// Organisationens inställningar: vad som visas när ett ärende
|
||||
// startas (objekttyper, identifieringsmetoder). Alla inloggade
|
||||
// läser; endast systemadministratören ändrar.
|
||||
if (req.method === "GET" && vag === "/api/organisation") {
|
||||
const rader = await pool.query(
|
||||
`select namn, installningar from organisationer where id = $1`,
|
||||
[anspr.org],
|
||||
);
|
||||
if (rader.rowCount === 0) return svara(res, 404, { error: "Organisationen finns inte." });
|
||||
return svara(res, 200, rader.rows[0]);
|
||||
}
|
||||
|
||||
if (req.method === "POST" && vag === "/api/organisation/installningar") {
|
||||
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
|
||||
const { objekttyper, identifieringsmetoder } = await lasKropp(req);
|
||||
const giltigLista = (lista) =>
|
||||
Array.isArray(lista) && lista.length > 0 && lista.length <= 50 &&
|
||||
lista.every((v) => typeof v === "string" && v.length <= 100);
|
||||
if (!giltigLista(objekttyper) || !giltigLista(identifieringsmetoder)) {
|
||||
return svara(res, 400, { error: "Ange minst en objekttyp och en identifieringsmetod." });
|
||||
}
|
||||
await pool.query(
|
||||
`update organisationer set installningar = $2 where id = $1`,
|
||||
[anspr.org, JSON.stringify({ objekttyper, identifieringsmetoder })],
|
||||
);
|
||||
return svara(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// Fordonshistorik: organisationens tidigare ärenden på samma objekt
|
||||
// (regnr/VIN), med dokumenterade felorsaker — pre-diagnostikens
|
||||
// historiksteg och orsakskedjan. Organisationsgränsen gäller alltid.
|
||||
const fordonVag = vag.match(/^\/api\/fordon\/([^/]+)\/historik$/);
|
||||
if (req.method === "GET" && fordonVag) {
|
||||
const ident = decodeURIComponent(fordonVag[1]).trim().toUpperCase();
|
||||
if (!ident) return svara(res, 400, { error: "Identifierare saknas." });
|
||||
const rader = await pool.query(
|
||||
`select a.id, a.nummer, a.skapad,
|
||||
coalesce(bool_or(h.handelse->>'typ' = 'arende_avslutat'), false) as avslutat,
|
||||
(array_agg(h.handelse->>'text')
|
||||
filter (where h.handelse->>'typ' = 'felbeskrivning'))[1] as felbeskrivning,
|
||||
coalesce(json_agg(json_build_object(
|
||||
'avvikelse', h.handelse->>'avvikelse',
|
||||
'orsaker', h.handelse->'orsaker',
|
||||
'atgard', h.handelse->>'atgard'))
|
||||
filter (where h.handelse->>'typ' = 'felorsak'), '[]') as felorsaker
|
||||
from felsokning_arenden a
|
||||
join felsokning_handelser h on h.arende_id = a.id
|
||||
where a.organisation_id = $1
|
||||
and a.id in (
|
||||
select arende_id from felsokning_handelser
|
||||
where handelse->>'typ' = 'objekt_identifierat'
|
||||
and upper(handelse->'objekt'->>'identifierare') = $2
|
||||
)
|
||||
group by a.id
|
||||
order by a.skapad desc
|
||||
limit 20`,
|
||||
[anspr.org, ident],
|
||||
);
|
||||
return svara(res, 200, { arenden: rader.rows });
|
||||
}
|
||||
|
||||
// Flottdata: felorsaksstatistik per orsakskategori över hela
|
||||
// organisationen (arbetsledare/admin) — återkommande fel blir
|
||||
// synliga när orsakerna är strukturerad data.
|
||||
if (req.method === "GET" && vag === "/api/statistik/felorsaker") {
|
||||
if (anspr.roll !== "arbetsledare" && anspr.roll !== "admin") {
|
||||
return svara(res, 403, { error: "Kräver arbetsledar- eller administratörsbehörighet." });
|
||||
}
|
||||
const rader = await pool.query(
|
||||
`select orsak, count(*)::int as antal
|
||||
from felsokning_arenden a
|
||||
join felsokning_handelser h on h.arende_id = a.id,
|
||||
jsonb_array_elements_text(h.handelse->'orsaker') as orsak
|
||||
where a.organisation_id = $1 and h.handelse->>'typ' = 'felorsak'
|
||||
group by orsak
|
||||
order by antal desc`,
|
||||
[anspr.org],
|
||||
);
|
||||
return svara(res, 200, { orsaker: rader.rows });
|
||||
}
|
||||
|
||||
// Organisationsöversikt för arbetsledare/admin: alla ärenden med
|
||||
// status, deltagande tekniker och sammanfattning — härlett ur
|
||||
// händelseloggen, aldrig lagrat separat.
|
||||
if (req.method === "GET" && vag === "/api/oversikt") {
|
||||
if (anspr.roll !== "arbetsledare" && anspr.roll !== "admin") {
|
||||
return svara(res, 403, { error: "Kräver arbetsledar- eller administratörsbehörighet." });
|
||||
}
|
||||
const rader = await pool.query(
|
||||
`select a.id, a.nummer, a.skapad, a.delningskod, a.metodik_id,
|
||||
count(h.id)::int as antal_handelser,
|
||||
min(h.tidpunkt) as forsta,
|
||||
max(h.tidpunkt) as senaste,
|
||||
coalesce(bool_or(h.handelse->>'typ' = 'arende_avslutat'), false) as avslutat,
|
||||
(array_agg(h.handelse->'objekt'->>'beskrivning')
|
||||
filter (where h.handelse->>'typ' = 'objekt_identifierat'))[1] as objekt,
|
||||
(array_agg(h.handelse->>'text')
|
||||
filter (where h.handelse->>'typ' = 'felbeskrivning'))[1] as felbeskrivning,
|
||||
(array_agg(coalesce(h.handelse->>'ansvarig', h.handelse->>'till') order by h.tidpunkt desc)
|
||||
filter (where h.handelse->>'typ' in ('ansvarig_satt', 'overlamning')
|
||||
and coalesce(h.handelse->>'ansvarig', h.handelse->>'till') is not null))[1] as ansvarig,
|
||||
(array_agg(h.anvandare order by h.tidpunkt asc))[1] as skapare,
|
||||
array_agg(distinct h.anvandare) filter (where h.anvandare is not null) as tekniker
|
||||
from felsokning_arenden a
|
||||
left join felsokning_handelser h on h.arende_id = a.id
|
||||
where a.organisation_id = $1
|
||||
group by a.id
|
||||
order by max(h.tidpunkt) desc nulls last
|
||||
limit 200`,
|
||||
[anspr.org],
|
||||
);
|
||||
return svara(res, 200, { arenden: rader.rows });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && vag === "/api/arenden") {
|
||||
const rader = await pool.query(
|
||||
`select id, nummer, skapad, delningskod, metodik_id from felsokning_arenden
|
||||
where organisation_id = $1 order by skapad desc limit 200`,
|
||||
[anspr.org],
|
||||
);
|
||||
return svara(res, 200, { arenden: rader.rows });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && vag === "/api/arenden") {
|
||||
const { id, nummer, skapad, delningskod, metodikId } = await lasKropp(req);
|
||||
if (typeof id !== "string" || typeof nummer !== "number" || !skapad) {
|
||||
return svara(res, 400, { error: "Ogiltigt ärende." });
|
||||
}
|
||||
await pool.query(
|
||||
`insert into felsokning_arenden (id, organisation_id, nummer, skapad, delningskod, metodik_id, skapad_av)
|
||||
values ($1, $2, $3, $4, $5, $6, $7) on conflict (id) do nothing`,
|
||||
[id, anspr.org, nummer, skapad, delningskod ?? null, metodikId ?? null, anspr.sub],
|
||||
);
|
||||
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.
|
||||
if (!(await arendeIOrg(handelserVag[1], anspr.org))) {
|
||||
return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
|
||||
}
|
||||
if (req.method === "GET") {
|
||||
const rader = await pool.query(
|
||||
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
|
||||
where arende_id = $1 order by tidpunkt, id`,
|
||||
[handelserVag[1]],
|
||||
);
|
||||
return svara(res, 200, { handelser: rader.rows });
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
const { handelser } = await lasKropp(req);
|
||||
if (!Array.isArray(handelser) || handelser.length > 500) {
|
||||
return svara(res, 400, { error: "Ogiltig händelselista." });
|
||||
}
|
||||
for (const post of handelser) {
|
||||
if (typeof post?.id !== "string" || !post.tidpunkt || typeof post.anvandare !== "string" || !post.handelse) {
|
||||
return svara(res, 400, { error: "Ogiltig händelse." });
|
||||
}
|
||||
// Append-only: on conflict do nothing — en befintlig händelse
|
||||
// skrivs aldrig över, och databastriggern stoppar allt annat.
|
||||
await pool.query(
|
||||
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse)
|
||||
values ($1, $2, $3, $4, $5) on conflict (id) do nothing`,
|
||||
[post.id, handelserVag[1], post.tidpunkt, post.anvandare, post.handelse],
|
||||
);
|
||||
}
|
||||
return svara(res, 200, { ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
return svara(res, 404, { error: "Okänd resurs." });
|
||||
} catch (fel) {
|
||||
console.error("plattform:", fel);
|
||||
return svara(res, 500, { error: "Förfrågan misslyckades." });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
skapaServer().listen(PORT, () => {
|
||||
console.log(`plattform lyssnar på :${PORT}`);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user