Files
alva/services/plattform/server.mjs
T
Claude 3a0be7261e Märkesspecifika kopplingar — kunden lägger in sina egna credentials
Verkstaden har redan sina avtal: Volvo-verkstaden har VIDA, VAG-verkstaden
har erWin, den fria verkstaden har en fordonsdataleverantör. Kopplingarna
konfigureras därför av kunden själv under Inställningar, med sina egna
uppgifter — vi tillhandahåller ramen, inte kontot.

Uppgifterna når aldrig webbläsaren. De krypteras med AES-256-GCM
(INTEGRATION_NYCKEL) innan de skrivs till tabellen integrationer, och
API:t returnerar hemliga fält maskerade. Alla uppslag görs av servern.
Saknas krypteringsnyckeln sparas ingenting alls — 503 och en förklaring i
gränssnittet i stället för klartext i databasen. Endast
systemadministratören hanterar uppgifterna; kopplingarna är
organisationsknutna som all annan ärendedata.

Leverantörer är data, inte kod: URL-mall, autentiseringstyp
(bearer/header/basic/query) och svarsmappning beskrivs i
services/plattform/integrationer.json, utbytbar via ConfigMap
(INTEGRATIONER_FIL). Nya märken läggs till utan att appen byggs om.

Varje uppslag skriver senast_testad och senaste_status på kopplingen, så
ett utgånget abonnemang syns i inställningarna i stället för att ge tysta
tomma svar.

Två latenta krascher hittade av klicktestet och åtgärdade: TextFalt och
UNDANTAGSORSAKER användes utan import. vite build typkontrollerar inte,
så de passerade bygget — därav nya npm-skriptet typkontroll, nu del av
verifieringen.

Verifierat: 80 vitest-tester, typkontroll, eslint, OpenAPI-validering,
integrationstest mot riktig Postgres (rollstyrning, kryptering i vila,
maskering, organisationsisolering, fail closed, borttagning) och
klickgenomgång mot en körande plattform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
2026-08-03 12:50:44 +00:00

809 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
// PORT default 8080
import { createServer } from "node:http";
import crypto, { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
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 });
// 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;
}
// ---- 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);
}
// ---- Hjälpare ---------------------------------------------------------
function svara(res, status, kropp) {
res.writeHead(status, {
"Content-Type": "application/json",
"Access-Control-Allow-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;
}
// 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;
}
// 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));
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, 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) => {
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, content-type",
"Access-Control-Allow-Methods": "GET, POST, 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": "*",
});
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`,
[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 rader = await pool.query(
`select a.id, a.namn, a.organisation_id, a.roll, o.namn as org_namn
from anvandare a join organisationer o on o.id = a.organisation_id
where a.epost = lower($1) and a.losen_hash = crypt($2, a.losen_hash)`,
[epost ?? "", losenord ?? ""],
);
if (rader.rowCount === 0) return svara(res, 401, { error: "Fel e-post eller lösenord." });
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 bortfiltrerat =
niva === "intern" ? [] : niva === "partner" ? ["kategori_byte", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"] : ["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"];
const handelser = await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 and not (handelse->>'typ' = any($2))
order by tidpunkt, id`,
[arendeId, bortfiltrerat],
);
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." });
// 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 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`,
[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]);
}
}
// 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": "*",
});
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}`);
});
}