// 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; } // ---- 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; } // 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, 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`, [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 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." }); // 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": 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}`); }); }