3a0be7261e
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
809 lines
36 KiB
JavaScript
809 lines
36 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)
|
||
// 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}`);
|
||
});
|
||
}
|