a7574015b1
Två luckor från genomgången, båda i auth. En utfärdad token gällde sina tolv timmar ut. Det fanns dessutom inget sätt att stänga av ett konto alls — en person som slutade behöll åtkomsten till organisationens ärenden. Nu bär token en version, och varje autentiserat anrop slår upp kontot och kontrollerar att det är aktivt och att versionen stämmer. Det kostar ett uppslag på primärnyckeln per anrop och ger i gengäld omedelbar verkan i stället för en avstängning som börjar gälla någon gång i morgon. Administratören stänger av och öppnar konton i användarlistan. Att stänga av höjer token-versionen, så pågående sessioner upphör direkt; öppnas kontot igen förblir de gamla token döda. Ingen kan stänga av sig själv och organisationsgränsen gäller. Var och en kan dessutom logga ut på alla enheter — vägen ut när en telefon tappats bort. Händelseloggen rörs aldrig: historiken är fortfarande knuten till den som utförde arbetet. Lösenord kunde gissas i obegränsad takt. Spärren ligger nu i databasen, inte i minnet, så den håller bakom flera repliker: 10 misslyckade försök per konto och 30 per källadress inom 15 minuter. Spärren gäller kontot även vid rätt lösenord — annars kunde den kringgås av den som till slut gissar rätt. Andra konton påverkas inte. Inget lösenord lagras, bara att ett försök skedde och om det lyckades, och rader äldre än ett dygn städas bort i skrivvägen. Ett avstängt konto räknas som misslyckat försök så att svarstiden inte avslöjar vilka konton som finns. Verifierat: 87 vitest-tester, typkontroll, eslint, OpenAPI-validering och integrationstest mot riktig Postgres med 13 nya kontroller — rollstyrning, självavstängning, organisationsgränsen, att en utfärdad token dör direkt, att den förblir död efter återöppning, logga-ut-alla samt att spärren slår till, gäller även rätt lösenord och inte smittar andra konton. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
1065 lines
46 KiB
JavaScript
1065 lines
46 KiB
JavaScript
// 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";
|
||
|
||
// 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;
|
||
const TOKEN_LIVSTID_S = 12 * 60 * 60;
|
||
const ROLLER = ["tekniker", "arbetsledare", "admin"];
|
||
|
||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
|
||
|
||
// 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"));
|
||
}
|
||
|
||
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 -----------------------------------------------------------
|
||
|
||
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 });
|
||
}
|
||
|
||
// -- 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 });
|
||
}
|
||
|
||
// 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}`);
|
||
});
|
||
}
|