Merge main: Guidad Felsökning flyttar ur roten till felsokning/

Main är sedan den här grenen skapades en helt annan produkt — Semantika,
en mobilapp med egen CDK-infrastruktur. Den äger nu repots rot: en
npm-workspaces-monorepo med apps/mobile, services/api och infra.

För att båda ska rymmas i samma repo flyttar Guidad Felsökning in i en
egen katalog i stället för att göra anspråk på roten:

  felsokning/app        webbklienten (Vite, egen package.json och
                        eslint-/vitest-konfiguration)
  felsokning/services   plattformstjänsten och AI-orkestern
  felsokning/infra      Terraform och databasschemat
  felsokning/docs       vision, moduler, drift
  felsokning/supabase   edge-funktion och migrationer

Merge:n hade tagit bort 128 filer som Guidad Felsökning bygger på —
värdapplikationens komponenter, Supabase-klienten, tillgångar — eftersom
main raderat dem och den här grenen inte råkat ändra just dem. De är
återställda på sin nya plats. Utan dem gick varken bygget eller
testerna: ai.ts och synk.ts importerar Supabase-klienten.

Semantikas rotfiler är orörda: package.json, eslint.config.js och
.github/workflows/ är deras. Guidad Felsökning har egna motsvarigheter i
sin katalog.

CI flyttar samtidigt från GitHub Actions till .gitea/workflows — samma
syntax, egna runners. .github/workflows/ tillhör Semantika härefter.

Verifierat på den nya platsen: 96 vitest-tester, typkontroll, eslint på
både klient och tjänster, bygge, och integrationstest mot riktig Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-04 12:22:49 +00:00
298 changed files with 23491 additions and 6140 deletions
@@ -0,0 +1,62 @@
-- Create profiles table for user data
CREATE TABLE public.profiles (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE,
display_name TEXT,
phone TEXT,
address TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
-- Enable Row Level Security
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
-- Users can view their own profile
CREATE POLICY "Users can view their own profile"
ON public.profiles
FOR SELECT
USING (auth.uid() = user_id);
-- Users can insert their own profile
CREATE POLICY "Users can insert their own profile"
ON public.profiles
FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can update their own profile
CREATE POLICY "Users can update their own profile"
ON public.profiles
FOR UPDATE
USING (auth.uid() = user_id);
-- Create function to update timestamps
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql SET search_path = public;
-- Create trigger for automatic timestamp updates
CREATE TRIGGER update_profiles_updated_at
BEFORE UPDATE ON public.profiles
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- Create function to automatically create profile on signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (user_id)
VALUES (NEW.id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
-- Create trigger to auto-create profile on user signup
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION public.handle_new_user();
@@ -0,0 +1,3 @@
-- Add company_name column to profiles table
ALTER TABLE public.profiles
ADD COLUMN company_name text;
@@ -0,0 +1,5 @@
-- Add separate address fields to profiles table
ALTER TABLE public.profiles
ADD COLUMN street_address text,
ADD COLUMN postal_code text,
ADD COLUMN city text;
@@ -0,0 +1,36 @@
-- Create orders table to store e-commerce orders
CREATE TABLE public.orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_number TEXT NOT NULL,
customer_email TEXT NOT NULL,
customer_name TEXT,
items JSONB NOT NULL DEFAULT '[]',
total_amount DECIMAL(10,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'SEK',
status TEXT NOT NULL DEFAULT 'pending',
shopify_checkout_id TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
-- Enable RLS
ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;
-- Create policy for authenticated users (admins) to view all orders
CREATE POLICY "Authenticated users can view all orders"
ON public.orders
FOR SELECT
TO authenticated
USING (true);
-- Create policy for inserting orders (from edge function)
CREATE POLICY "Service role can insert orders"
ON public.orders
FOR INSERT
WITH CHECK (true);
-- Create trigger for updated_at
CREATE TRIGGER update_orders_updated_at
BEFORE UPDATE ON public.orders
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
@@ -0,0 +1,50 @@
-- Create customers table
CREATE TABLE public.customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT,
phone TEXT,
company TEXT,
total_orders INTEGER DEFAULT 0,
total_spent DECIMAL(10,2) DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
-- Create payments table
CREATE TABLE public.payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID REFERENCES public.orders(id) ON DELETE CASCADE,
amount DECIMAL(10,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'SEK',
status TEXT NOT NULL DEFAULT 'pending',
payment_method TEXT,
shopify_payment_id TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
-- Add customer reference to orders
ALTER TABLE public.orders ADD COLUMN customer_id UUID REFERENCES public.customers(id);
-- Enable RLS
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.payments ENABLE ROW LEVEL SECURITY;
-- RLS policies for customers (only authenticated admins)
CREATE POLICY "Authenticated users can view customers"
ON public.customers FOR SELECT TO authenticated USING (true);
CREATE POLICY "Authenticated users can manage customers"
ON public.customers FOR ALL TO authenticated USING (true) WITH CHECK (true);
-- RLS policies for payments
CREATE POLICY "Authenticated users can view payments"
ON public.payments FOR SELECT TO authenticated USING (true);
CREATE POLICY "Service can insert payments"
ON public.payments FOR INSERT WITH CHECK (true);
-- Triggers for updated_at
CREATE TRIGGER update_customers_updated_at
BEFORE UPDATE ON public.customers
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
@@ -0,0 +1,62 @@
-- Role enum
DO $$ BEGIN
CREATE TYPE public.app_role AS ENUM ('admin', 'user');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- user_roles table
CREATE TABLE IF NOT EXISTS public.user_roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role public.app_role NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, role)
);
GRANT SELECT ON public.user_roles TO authenticated;
GRANT ALL ON public.user_roles TO service_role;
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can view own roles" ON public.user_roles;
CREATE POLICY "Users can view own roles"
ON public.user_roles FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
-- Security definer role check
CREATE OR REPLACE FUNCTION public.has_role(_user_id uuid, _role public.app_role)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1 FROM public.user_roles
WHERE user_id = _user_id AND role = _role
)
$$;
-- Backfill admin for existing owner
INSERT INTO public.user_roles (user_id, role)
SELECT id, 'admin'::public.app_role
FROM auth.users
WHERE lower(email) = 'tiffanysvson@gmail.com'
ON CONFLICT (user_id, role) DO NOTHING;
-- Auto-grant admin on future signup with owner email
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO public.profiles (user_id) VALUES (NEW.id)
ON CONFLICT DO NOTHING;
IF lower(NEW.email) = 'tiffanysvson@gmail.com' THEN
INSERT INTO public.user_roles (user_id, role)
VALUES (NEW.id, 'admin')
ON CONFLICT (user_id, role) DO NOTHING;
END IF;
RETURN NEW;
END;
$$;
@@ -0,0 +1,7 @@
REVOKE ALL ON FUNCTION public.has_role(uuid, public.app_role) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.has_role(uuid, public.app_role) TO authenticated, service_role;
REVOKE ALL ON FUNCTION public.handle_new_user() FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.update_updated_at_column() FROM PUBLIC, anon, authenticated;
@@ -0,0 +1,27 @@
-- Orders: admin-only reads
DROP POLICY IF EXISTS "Authenticated users can view all orders" ON public.orders;
CREATE POLICY "Admins can view orders"
ON public.orders FOR SELECT
TO authenticated
USING (public.has_role(auth.uid(), 'admin'));
-- Customers: admin-only
DROP POLICY IF EXISTS "Authenticated users can manage customers" ON public.customers;
DROP POLICY IF EXISTS "Authenticated users can view customers" ON public.customers;
CREATE POLICY "Admins can view customers"
ON public.customers FOR SELECT
TO authenticated
USING (public.has_role(auth.uid(), 'admin'));
CREATE POLICY "Admins can manage customers"
ON public.customers FOR ALL
TO authenticated
USING (public.has_role(auth.uid(), 'admin'))
WITH CHECK (public.has_role(auth.uid(), 'admin'));
-- Payments: admin-only reads
DROP POLICY IF EXISTS "Authenticated users can view payments" ON public.payments;
CREATE POLICY "Admins can view payments"
ON public.payments FOR SELECT
TO authenticated
USING (public.has_role(auth.uid(), 'admin'));
@@ -0,0 +1,3 @@
DROP POLICY IF EXISTS "Service role can insert orders" ON public.orders;
DROP POLICY IF EXISTS "Service can insert payments" ON public.payments;
@@ -0,0 +1,29 @@
-- Restrict direct execution of SECURITY DEFINER function has_role.
-- Switching to SECURITY INVOKER: when called as auth.uid() the user_roles
-- RLS policy already lets a user see (only) their own role rows, so
-- has_role(auth.uid(), 'admin') still returns correctly.
-- We also revoke EXECUTE from anon/public so the function cannot be
-- invoked directly from the Data API.
CREATE OR REPLACE FUNCTION public.has_role(_user_id uuid, _role app_role)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY INVOKER
SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1
FROM public.user_roles
WHERE user_id = _user_id
AND role = _role
)
$$;
REVOKE EXECUTE ON FUNCTION public.has_role(uuid, app_role) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION public.has_role(uuid, app_role) FROM anon;
-- authenticated retains EXECUTE so RLS policies referencing has_role
-- continue to work; the function itself is now SECURITY INVOKER so it
-- cannot be used to bypass RLS.
GRANT EXECUTE ON FUNCTION public.has_role(uuid, app_role) TO authenticated;
GRANT EXECUTE ON FUNCTION public.has_role(uuid, app_role) TO service_role;
@@ -0,0 +1,60 @@
CREATE TABLE public.subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
customer_email TEXT NOT NULL,
company_name TEXT,
stripe_subscription_id TEXT NOT NULL UNIQUE,
stripe_customer_id TEXT NOT NULL,
price_id TEXT,
kg_per_week NUMERIC,
monthly_amount NUMERIC,
status TEXT NOT NULL DEFAULT 'active',
collection_method TEXT,
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
environment TEXT NOT NULL DEFAULT 'sandbox',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_subscriptions_user_id ON public.subscriptions(user_id);
CREATE INDEX idx_subscriptions_customer_email ON public.subscriptions(customer_email);
CREATE INDEX idx_subscriptions_stripe_id ON public.subscriptions(stripe_subscription_id);
GRANT SELECT ON public.subscriptions TO authenticated;
GRANT ALL ON public.subscriptions TO service_role;
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own subscriptions"
ON public.subscriptions FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
CREATE POLICY "Admins can view all subscriptions"
ON public.subscriptions FOR SELECT
TO authenticated
USING (public.has_role(auth.uid(), 'admin'::app_role));
CREATE TRIGGER update_subscriptions_updated_at
BEFORE UPDATE ON public.subscriptions
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE OR REPLACE FUNCTION public.has_active_kaka_subscription(user_uuid UUID)
RETURNS BOOLEAN
LANGUAGE SQL
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1 FROM public.subscriptions
WHERE user_id = user_uuid
AND (
(status IN ('active','trialing','past_due') AND (current_period_end IS NULL OR current_period_end > now()))
OR (status = 'canceled' AND current_period_end > now())
)
);
$$;
@@ -0,0 +1,3 @@
REVOKE EXECUTE ON FUNCTION public.has_active_kaka_subscription(UUID) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.has_active_kaka_subscription(UUID) TO service_role;
@@ -0,0 +1,72 @@
-- 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, hypoteser) filtreras bort ur den delade
-- vyn: hypoteser är arbetsmaterial och får aldrig läsas som konstaterade fel.
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' not in ('kategori_byte', 'hypotes', 'ai_svar', 'ansvarig_satt', 'arbetsorder_skannad')
), '[]'::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;
@@ -0,0 +1,38 @@
-- Delningsgränsen görs om från nekalista till tillåtelselista.
--
-- Tidigare filtrerade hamta_delat_arende bort en uppräknad mängd interna
-- händelsetyper. Varje ny händelsetyp blev därmed automatiskt synlig för
-- kunden tills någon kom ihåg att neka den — fel håll att fela åt på en
-- integritetsgräns. Nu räknas i stället upp vad som FÅR delas; allt annat
-- är internt tills det aktivt släpps fram.
--
-- Listan speglar DELBART_KUND i services/plattform/server.mjs, som låses
-- av testet src/felsokning/__tests__/delning.test.ts.
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' in (
'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'
)
), '[]'::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;