diff --git a/docs/MVP.md b/docs/MVP.md index 733f71e..3103129 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -25,8 +25,10 @@ npm test # projektionstester | Röstinmatning (tal in, text ut) | ✅ Push-to-Talk via webbläsarens taligenkänning (sv-SE): lyssnar bara efter aktivt tryck, röd indikator med realtidstranskript, texten hamnar i ett redigerbart fält och skickas aldrig automatiskt. Knappen visas bara i webbläsare med talstöd. Produktionsversionen byter motor till leverantörens Voice-to-Text bakom samma gränssnitt. | | Verifierade checklistor | ✅ Varje kontroll i metodiken har ett minimikrav (foto, mätvärde eller kort observation). Foto-kontroller verifieras med bild; mätningar kan inte markeras verifierade utan värde. | | Export | ✅ Versionsmärkt JSON-export (version = antal händelser vid exporttillfället, med användare och tidpunkt); exporten loggas själv som händelse. PDF via utskrift. CSV och API i backend-fasen. | -| Öppet API | 🔶 Datamodellen är API-klar (händelser som JSON), men MVP:t lagrar lokalt (localStorage). Nästa steg: backend med samma händelsemodell. | -| Live Share | 🔶 Kräver backend — specificerad i [moduler/live-share.md](moduler/live-share.md). Kundrapporten är förberedd som samma projektion. | +| Backend & synk | ✅ Databas-migration (`supabase/migrations/20260802230000_guidad_felsokning.sql`): ärenden + händelser med RLS, append-only även i databasen (inga update/delete-rättigheter). Synklager i klienten: konfliktfri ihopflätning av händelser per id (testad), push av lokala + pull av kollegors händelser var 15:e sekund. Utan inloggning arbetar appen i lokalt läge; status visas i ärendehuvudet. | +| Metodiker | ✅ Tre: vibration, elsystem/strömförsörjning (relä-exemplet ur visionen) och generisk — vald automatiskt utifrån felbeskrivningen. | +| Live Share | ✅ Skrivskyddad livevy per ärende (`/felsokning/dela/:id`): status ✔/🔄/⏳, bilder, mätvärdestabell, tidslinje, rekommenderat nästa steg. Uppdateras automatiskt, interna poster filtreras bort. Anonym extern åtkomst via delningskod är förberedd i databasen (security definer-funktion `hamta_delat_arende`); varje ärende får en slumpad delningskod vid skapande. Behörighetsnivåer (kund/intern/partner) i nästa fas. | +| Öppet API | 🔶 Datamodellen är API-klar (händelser som JSON i Postgres). REST/OpenAPI-lager i nästa fas. | ## Arkitekturprinciper i koden diff --git a/src/App.tsx b/src/App.tsx index 54a745b..92dc160 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import ChangeSubscription from "./pages/ChangeSubscription"; import Arendelista from "./pages/felsokning/Arendelista"; import NyttArende from "./pages/felsokning/NyttArende"; import ArendeSida from "./pages/felsokning/ArendeSida"; +import DelatArende from "./pages/felsokning/DelatArende"; import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner"; const queryClient = new QueryClient(); @@ -39,6 +40,7 @@ function AppContent() { } /> } /> } /> + } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> diff --git a/src/felsokning/__tests__/projektioner.test.ts b/src/felsokning/__tests__/projektioner.test.ts index f8917cf..c8f6a56 100644 --- a/src/felsokning/__tests__/projektioner.test.ts +++ b/src/felsokning/__tests__/projektioner.test.ts @@ -28,8 +28,8 @@ const OBJEKT: Handelse = { describe("verifierade checklistor", () => { it("varje kontroll i metodikerna har ett definierat minimikrav", async () => { - const { GENERISK_METODIK } = await import("../metodik"); - for (const metodik of [VIBRATION_METODIK, GENERISK_METODIK]) { + const { METODIKER } = await import("../metodik"); + for (const metodik of METODIKER) { for (const steg of metodik.steg) { for (const kontroll of steg.kontroller ?? []) { expect(kontroll.krav, `${metodik.id}/${steg.id}/${kontroll.id}`).toBeDefined(); @@ -44,6 +44,12 @@ describe("metodikval", () => { expect(valjMetodik("Bilen vibrerar runt 88 km/h").id).toBe("vibration"); expect(valjMetodik("Motorn startar inte").id).toBe("generisk"); }); + + it("väljer elsystemmetodiken vid elrelaterade symptom", () => { + expect(valjMetodik("Reläet klickar inte").id).toBe("elsystem"); + expect(valjMetodik("Ingen spänning till bränslepumpen").id).toBe("elsystem"); + expect(valjMetodik("Belysningen fungerar inte").id).toBe("elsystem"); + }); }); describe("nastaSteg", () => { diff --git a/src/felsokning/__tests__/synk.test.ts b/src/felsokning/__tests__/synk.test.ts new file mode 100644 index 0000000..131b386 --- /dev/null +++ b/src/felsokning/__tests__/synk.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import type { LoggPost } from "../domain"; +import { flataIhop } from "../synk"; + +function post(id: string, tidpunkt: string, anvandare = "Anna"): LoggPost { + return { id, tidpunkt, anvandare, handelse: { typ: "kommentar", text: id } }; +} + +describe("flataIhop", () => { + const lokala = [post("a", "2026-08-02T08:00:00.000Z"), post("c", "2026-08-02T08:10:00.000Z")]; + const fjarr = [post("a", "2026-08-02T08:00:00.000Z"), post("b", "2026-08-02T08:05:00.000Z", "Johan")]; + + it("flätar ihop två enheters loggar utan dubbletter, i tidsordning", () => { + const resultat = flataIhop(lokala, fjarr); + expect(resultat.map((p) => p.id)).toEqual(["a", "b", "c"]); + }); + + it("skriver aldrig över: alla poster från båda källorna finns kvar", () => { + const resultat = flataIhop(lokala, fjarr); + expect(resultat).toHaveLength(3); + expect(resultat.find((p) => p.id === "b")?.anvandare).toBe("Johan"); + }); + + it("är idempotent och deterministisk", () => { + const enGang = flataIhop(lokala, fjarr); + const tvaGanger = flataIhop(enGang, fjarr); + expect(tvaGanger).toEqual(enGang); + expect(flataIhop(fjarr, lokala).map((p) => p.id)).toEqual(enGang.map((p) => p.id)); + }); + + it("använder id som stabil ordning vid exakt samma tidpunkt", () => { + const samtidiga = flataIhop( + [post("x2", "2026-08-02T08:00:00.000Z")], + [post("x1", "2026-08-02T08:00:00.000Z")], + ); + expect(samtidiga.map((p) => p.id)).toEqual(["x1", "x2"]); + }); +}); diff --git a/src/felsokning/domain.ts b/src/felsokning/domain.ts index c4fa020..9173b03 100644 --- a/src/felsokning/domain.ts +++ b/src/felsokning/domain.ts @@ -65,6 +65,9 @@ export interface Arende { id: string; nummer: number; skapad: string; // ISO 8601 + // Slumpad kod för Live Share-länken; sätts vid skapande och kan aldrig + // ändras (ärenderaden är append-only även i databasen). + delningskod?: string; handelser: LoggPost[]; } diff --git a/src/felsokning/metodik.ts b/src/felsokning/metodik.ts index e7ef0eb..77c69a6 100644 --- a/src/felsokning/metodik.ts +++ b/src/felsokning/metodik.ts @@ -153,7 +153,64 @@ export const GENERISK_METODIK: Metodik = { ], }; -const METODIKER = [VIBRATION_METODIK, GENERISK_METODIK]; +// Elsystem-metodik enligt visionens relä-exempel: mätningar med krav på +// värden, reläkontroll och funktionstest. +export const ELSYSTEM_METODIK: Metodik = { + id: "elsystem", + namn: "Elsystem / strömförsörjning", + steg: [ + { + id: "symptom", + rubrik: "Verifiera symptom", + fragor: [ + { id: "reproducerbart", text: "Går felet att reproducera?", svarstyp: "janej" }, + { + id: "omfattning", + text: "Är funktionen helt död eller delvis fungerande?", + svarstyp: "val", + val: ["Helt död", "Delvis fungerande", "Intermittent"], + }, + { id: "funktion", text: "Vilken funktion saknas eller felar?", svarstyp: "text" }, + { id: "forandring", text: "Har något åtgärdats eller förändrats nyligen?", svarstyp: "text" }, + ], + }, + { + id: "visuell", + rubrik: "Visuell kontroll", + kontroller: [ + { id: "foto_komponent", text: "Fotografera komponenten och dess anslutningar", krav: "foto" }, + { id: "skador", text: "Kontrollera synliga skador, korrosion och lösa kontaktdon", krav: "kommentar" }, + ], + }, + { + id: "matningar", + rubrik: "Mätningar", + kontroller: [ + { id: "batterispanning", text: "Mät batterispänning", krav: "matvarde" }, + { id: "sakringar", text: "Kontrollera berörda säkringar", krav: "kommentar" }, + { id: "matningsspanning", text: "Mät matningsspänning vid komponenten", krav: "matvarde" }, + { id: "jord", text: "Mät spänningsfall mot jord", krav: "matvarde" }, + ], + }, + { + id: "rela", + rubrik: "Relä och styrsignal", + kontroller: [ + { id: "rela_klick", text: "Kontrollera om reläet klickar vid aktivering", krav: "kommentar" }, + { id: "styrsignal", text: "Mät styrsignal till reläet", krav: "matvarde" }, + ], + }, + { + id: "funktionstest", + rubrik: "Funktionstest", + kontroller: [ + { id: "test", text: "Genomför funktionstest och dokumentera resultatet", krav: "kommentar" }, + ], + }, + ], +}; + +export const METODIKER = [VIBRATION_METODIK, ELSYSTEM_METODIK, GENERISK_METODIK]; export function metodikForId(id: string): Metodik { return METODIKER.find((m) => m.id === id) ?? GENERISK_METODIK; @@ -162,6 +219,9 @@ export function metodikForId(id: string): Metodik { export function valjMetodik(felbeskrivning: string): Metodik { const text = felbeskrivning.toLowerCase(); if (/vibr|skak|obalans/.test(text)) return VIBRATION_METODIK; + if (/relä|rela\b|säkring|elektr|spänning|volt\b|batteri|lampa|belysning|ström|kortslut/.test(text)) { + return ELSYSTEM_METODIK; + } return GENERISK_METODIK; } diff --git a/src/felsokning/store.ts b/src/felsokning/store.ts index 38ae731..79ce977 100644 --- a/src/felsokning/store.ts +++ b/src/felsokning/store.ts @@ -5,7 +5,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; -import type { Arende, Handelse, Objekt } from "./domain"; +import type { Arende, Handelse, LoggPost, Objekt } from "./domain"; import { nyLoggPost } from "./domain"; import { felbeskrivning } from "./projektioner"; import { GENERISK_METODIK, valjMetodik, type Metodik } from "./metodik"; @@ -17,6 +17,15 @@ interface FelsokningState { sattAnvandare: (namn: string) => void; skapaArende: (objekt: Objekt, felbeskrivningText: string) => string; laggTill: (arendeId: string, handelse: Handelse) => void; + // Används endast av synken: ersätter listan med den ihopflätade versionen. + // Semantiken är fortfarande append-only — flätningen lägger bara till. + sammanfoga: (arendeId: string, handelser: LoggPost[]) => void; +} + +function nyDelningskod(): string { + return Array.from(crypto.getRandomValues(new Uint8Array(12)), (b) => + "abcdefghijklmnopqrstuvwxyz0123456789"[b % 36], + ).join(""); } export const useFelsokning = create()( @@ -35,6 +44,7 @@ export const useFelsokning = create()( id, nummer: nastaNummer, skapad: new Date().toISOString(), + delningskod: nyDelningskod(), handelser: [ nyLoggPost(anvandare, { typ: "objekt_identifierat", objekt }), nyLoggPost(anvandare, { typ: "felbeskrivning", text: felbeskrivningText }), @@ -63,6 +73,16 @@ export const useFelsokning = create()( }; }); }, + + sammanfoga: (arendeId, handelser) => { + set((s) => { + const arende = s.arenden[arendeId]; + if (!arende || handelser.length === arende.handelser.length) return s; + return { + arenden: { ...s.arenden, [arendeId]: { ...arende, handelser } }, + }; + }); + }, }), { name: "guidad-felsokning" }, ), diff --git a/src/felsokning/synk.ts b/src/felsokning/synk.ts new file mode 100644 index 0000000..47c877f --- /dev/null +++ b/src/felsokning/synk.ts @@ -0,0 +1,98 @@ +// Synkronisering mot backend. Append-only-modellen gör den konfliktfri: +// händelser flätas ihop per id och ingenting skrivs över — enheter kan +// arbeta offline och synka i efterhand utan att någon post går förlorad. +// +// Kräver inloggning (Supabase Auth). Utan session arbetar appen vidare +// i lokalt läge; synk återupptas när användaren loggat in. + +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Arende, Handelse, LoggPost } from "./domain"; + +// Deterministisk ihopflätning: dedupe per id, ordning per tidpunkt (id som +// stabil sekundärnyckel). Samma indata ger alltid samma resultat. +export function flataIhop(lokala: LoggPost[], fjarr: LoggPost[]): LoggPost[] { + const perId = new Map(); + for (const post of [...lokala, ...fjarr]) { + if (!perId.has(post.id)) perId.set(post.id, post); + } + return [...perId.values()].sort( + (a, b) => a.tidpunkt.localeCompare(b.tidpunkt) || a.id.localeCompare(b.id), + ); +} + +export type SynkStatus = "lokal" | "synkar" | "synkad" | "offline"; + +export interface SynkResultat { + status: SynkStatus; + handelser?: LoggPost[]; +} + +interface FjarrHandelseRad { + id: string; + tidpunkt: string; + anvandare: string; + handelse: Handelse; +} + +async function klient(): Promise { + try { + const { supabase } = await import("@/integrations/supabase/client"); + // Tabellerna är nya — den genererade Database-typen regenereras av + // Lovable/Supabase CLI; tills dess används klienten otypad här. + return supabase as unknown as SupabaseClient; + } catch { + return undefined; + } +} + +export async function synkroniseraArende(arende: Arende): Promise { + const supabase = await klient(); + if (!supabase) return { status: "lokal" }; + try { + const { data } = await supabase.auth.getSession(); + if (!data.session) return { status: "lokal" }; + + const { error: arendeFel } = await supabase.from("felsokning_arenden").upsert( + { + id: arende.id, + nummer: arende.nummer, + skapad: arende.skapad, + delningskod: arende.delningskod ?? null, + }, + { onConflict: "id", ignoreDuplicates: true }, + ); + if (arendeFel) throw arendeFel; + + const { data: fjarrRader, error: lasFel } = await supabase + .from("felsokning_handelser") + .select("id,tidpunkt,anvandare,handelse") + .eq("arende_id", arende.id); + if (lasFel) throw lasFel; + + const fjarr: LoggPost[] = ((fjarrRader ?? []) as FjarrHandelseRad[]).map((rad) => ({ + id: rad.id, + tidpunkt: new Date(rad.tidpunkt).toISOString(), + anvandare: rad.anvandare, + handelse: rad.handelse, + })); + + const fjarrIds = new Set(fjarr.map((p) => p.id)); + const nya = arende.handelser + .filter((p) => !fjarrIds.has(p.id)) + .map((p) => ({ + id: p.id, + arende_id: arende.id, + tidpunkt: p.tidpunkt, + anvandare: p.anvandare, + handelse: p.handelse, + })); + if (nya.length > 0) { + const { error: skrivFel } = await supabase.from("felsokning_handelser").insert(nya); + if (skrivFel) throw skrivFel; + } + + return { status: "synkad", handelser: flataIhop(arende.handelser, fjarr) }; + } catch { + return { status: "offline" }; + } +} diff --git a/src/pages/felsokning/ArendeSida.tsx b/src/pages/felsokning/ArendeSida.tsx index fff52f9..14ff123 100644 --- a/src/pages/felsokning/ArendeSida.tsx +++ b/src/pages/felsokning/ArendeSida.tsx @@ -17,6 +17,7 @@ import { tidsfordelningsRader, } from "@/felsokning/projektioner"; import { metodikForArende, useFelsokning } from "@/felsokning/store"; +import { synkroniseraArende, type SynkStatus } from "@/felsokning/synk"; import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; import { skalaNerFoto, tidKlockslag } from "@/felsokning/format"; @@ -29,12 +30,50 @@ const FLIKAR = [ type Flik = (typeof FLIKAR)[number]["id"]; +const SYNKSTATUS_LABEL: Record = { + lokal: "Lokalt läge", + synkar: "Synkar …", + synkad: "Synkad", + offline: "Offline", +}; + +// Synkar ärendet mot backend: direkt vid nya händelser och därefter var +// 15:e sekund (hämtar kollegors händelser). Utan inloggning: lokalt läge. +function useSynk(arende: Arende | undefined): SynkStatus { + const [status, setStatus] = useState("lokal"); + const sammanfoga = useFelsokning((s) => s.sammanfoga); + const arendeId = arende?.id; + const antal = arende?.handelser.length ?? 0; + + useEffect(() => { + if (!arende || !arendeId) return; + let aktiv = true; + const kor = async () => { + setStatus((s) => (s === "synkad" ? "synkar" : s)); + const resultat = await synkroniseraArende(arende); + if (!aktiv) return; + setStatus(resultat.status); + if (resultat.handelser) sammanfoga(arendeId, resultat.handelser); + }; + kor(); + const timer = setInterval(kor, 15000); + return () => { + aktiv = false; + clearInterval(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- antal driver omsynk vid nya händelser + }, [arendeId, antal, sammanfoga]); + + return status; +} + export default function ArendeSida() { const { id } = useParams<{ id: string }>(); const arende = useFelsokning((s) => (id ? s.arenden[id] : undefined)); const laggTill = useFelsokning((s) => s.laggTill); const [flik, setFlik] = useState("guide"); const [nu, setNu] = useState(() => new Date().toISOString()); + const synkStatus = useSynk(arende); useEffect(() => { const timer = setInterval(() => setNu(new Date().toISOString()), 30000); @@ -63,7 +102,9 @@ export default function ArendeSida() { hoger={

{total}

-

{avslutat ? "Avslutat" : "Pågår"}

+

+ {avslutat ? "Avslutat" : "Pågår"} · {SYNKSTATUS_LABEL[synkStatus]} +

} > @@ -600,6 +641,9 @@ function RapportFlik({ Exportera JSON + + 🟢 Öppna Live Share-vy + {b.objekt && ( diff --git a/src/pages/felsokning/DelatArende.tsx b/src/pages/felsokning/DelatArende.tsx new file mode 100644 index 0000000..cabc034 --- /dev/null +++ b/src/pages/felsokning/DelatArende.tsx @@ -0,0 +1,137 @@ +// Live Share: skrivskyddad livevy av ett ärende — det kunden, arbetsledaren +// eller en extern partner ser via delningslänken. Uppdateras automatiskt +// när ny information registreras. Interna poster (kategoribyten) visas inte. + +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { handelseRubrik } from "@/felsokning/domain"; +import { arAvslutat, brief, foton, tidsfordelningsRader } from "@/felsokning/projektioner"; +import { metodikForArende, useFelsokning } from "@/felsokning/store"; +import { tidDatum, tidKlockslag } from "@/felsokning/format"; +import { FelsokningSkal, Panel } from "@/felsokning/ui"; + +export default function DelatArende() { + const { id } = useParams<{ id: string }>(); + const arende = useFelsokning((s) => (id ? s.arenden[id] : undefined)); + const [nu, setNu] = useState(() => new Date().toISOString()); + + useEffect(() => { + const timer = setInterval(() => setNu(new Date().toISOString()), 30000); + return () => clearInterval(timer); + }, []); + + if (!arende) { + return ( + +

Ärendet är inte tillgängligt.

+
+ ); + } + + const metodik = metodikForArende(arende); + const b = brief(arende, metodik, nu); + const avslutat = arAvslutat(arende); + const bilder = foton(arende); + const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde"); + const kundposter = arende.handelser.filter((p) => p.handelse.typ !== "kategori_byte"); + const pagaende = b.rekommenderatNastaSteg[0]; + + return ( + + {avslutat ? "Avslutat" : "🟢 Felsökning pågår"} + + } + > +

+ Skrivskyddad livevy — uppdateras automatiskt när verkstaden registrerar ny information. Extern delning + via säker länk aktiveras när backend är i drift; detta är samma vy. +

+ + {b.felbeskrivning && ( + +

”{b.felbeskrivning}”

+
+ )} + + + {b.utfordaKontroller.map((k, i) => ( +

✔ {k.text}

+ ))} + {!avslutat && pagaende &&

🔄 {pagaende}

} + {b.ejKontrollerat.slice(avslutat ? 0 : 1).map((e, i) => ( +

⏳ {e}

+ ))} +
+ + {bilder.length > 0 && ( + +
+ {bilder.map((bild, i) => ( +
+ {bild.beskrivning} +
{bild.beskrivning}
+
+ ))} +
+
+ )} + + {matvarden.length > 0 && ( + + + + {matvarden.map((p) => { + const h = p.handelse; + if (h.typ !== "matvarde") return null; + return ( + + + + + ); + })} + +
{h.beskrivning} + {h.varde} + {h.enhet ? ` ${h.enhet}` : ""} +
+
+ )} + + + {kundposter.map((post) => ( +

+ {tidKlockslag(post.tidpunkt)}{" "} + {handelseRubrik(post)} +

+ ))} +
+ + {!avslutat && pagaende && ( + +

{pagaende}

+
+ )} + + +

{b.totalArbetstid}

+ {tidsfordelningsRader(arende, nu).map((r) => ( +

+ {r.label}: {r.tid} +

+ ))} +
+ +

+ Ärende #{arende.nummer} · startat {tidDatum(arende.skapad)} · genererad ur ärendets händelselogg +

+
+ ); +} diff --git a/supabase/migrations/20260802230000_guidad_felsokning.sql b/supabase/migrations/20260802230000_guidad_felsokning.sql new file mode 100644 index 0000000..b353192 --- /dev/null +++ b/supabase/migrations/20260802230000_guidad_felsokning.sql @@ -0,0 +1,71 @@ +-- Guidad Felsökning: ärenden och append-only händelselogg. +-- Loggen är enda sanningskällan; alla vyer är projektioner. +-- Append-only garanteras även i databasen: inga update/delete-policyer. + +create table public.felsokning_arenden ( + id text primary key, + nummer integer not null, + skapad timestamptz not null, + skapad_av uuid not null default auth.uid(), + delningskod text unique, + insatt timestamptz not null default now() +); + +create table public.felsokning_handelser ( + id text primary key, + arende_id text not null references public.felsokning_arenden(id), + tidpunkt timestamptz not null, + anvandare text not null, + handelse jsonb not null, + insatt timestamptz not null default now() +); + +create index felsokning_handelser_arende_idx + on public.felsokning_handelser (arende_id, tidpunkt); + +alter table public.felsokning_arenden enable row level security; +alter table public.felsokning_handelser enable row level security; + +-- Beta: alla inloggade användare delar arbetsyta (samarbete i ärenden). +-- Multi-tenant-uppdelning per organisation införs i nästa fas. +create policy "Inloggade läser ärenden" + on public.felsokning_arenden for select to authenticated using (true); +create policy "Inloggade skapar ärenden" + on public.felsokning_arenden for insert to authenticated with check (true); +create policy "Inloggade läser händelser" + on public.felsokning_handelser for select to authenticated using (true); +create policy "Inloggade lägger till händelser" + on public.felsokning_handelser for insert to authenticated with check (true); + +-- Inga update/delete-policyer, och rättigheterna återkallas dessutom explicit: +-- historiken kan aldrig ändras eller raderas från klienten. +revoke update, delete on public.felsokning_arenden from authenticated, anon; +revoke update, delete on public.felsokning_handelser from authenticated, anon; + +-- Live Share: anonym läsning sker ENDAST via delningskod genom en +-- security definer-funktion — tabellerna exponeras aldrig direkt för anon. +-- Interna poster (kategoribyten) filtreras bort ur den delade vyn. +create or replace function public.hamta_delat_arende(kod text) +returns jsonb +language sql +security definer +set search_path = public +stable +as $$ + select jsonb_build_object( + 'arende', to_jsonb(a) - 'skapad_av', + 'handelser', coalesce(( + select jsonb_agg(to_jsonb(h) order by h.tidpunkt, h.id) + from felsokning_handelser h + where h.arende_id = a.id + and h.handelse->>'typ' <> 'kategori_byte' + ), '[]'::jsonb) + ) + from felsokning_arenden a + where a.delningskod = kod and a.delningskod is not null; +$$; + +grant execute on function public.hamta_delat_arende(text) to anon, authenticated; + +-- Realtime-uppdateringar för framtida prenumerationer i klienten. +alter publication supabase_realtime add table public.felsokning_handelser;