Helt självhostat: plattformsbackend, Postgres i klustret, klientläge
Sista externa beroendet borta — hela stacken kör i eget kluster: - Ny plattformstjänst (services/plattform): egen inloggning (registrering + inloggning, lösenord bcrypt-hashade med pgcrypto, HS256-JWT), append-only händelse-API för synken (endast insert, on conflict do nothing) och publik delningsendpoint för Live Share med samma filtrering som Supabase-funktionen. Fail closed utan hemligheter; inga update/delete-operationer exponeras. - Postgres som StatefulSet med PVC och init-schema; append-only garanterat i databasen med triggers på både händelser och ärenden — historik kan inte ändras oavsett ansluten roll. CloudNativePG rekommenderas för produktion i docs. - JWT-hemligheten delas mellan plattform och AI-orkester (orkestern läser JWT_SECRET med SUPABASE_JWT_SECRET som fallback) — flödet livetestat: plattformens token accepteras av orkestern, fel hemlighet och utgångna tokens avvisas. - Klienten får självhostat läge via VITE_PLATTFORM_URL: inloggnings- panel på startsidan, synk mot plattformens API (samma konfliktfria flätning), Live Share via /api/delad och AI via klustrets orkester. Utan variabeln är Supabase-läget oförändrat. - Ingress routar /api/ai → orkestern, /api + /halsa → plattformen, / → webben; kustomize inkluderar hela stacken; CI bygger alla tre bilderna; DRIFT.md omskriven för självhostad drift. Verifierat: 28 vitest-tester gröna (inkl. append-only-triggers och API-ytan), plattformstjänsten livetestad (hälsa, 401, fail closed), JWT-paritet mellan tjänsterna testad, alla manifest YAML-validerade, produktionsbygge ok, Playwright-röktest grönt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -26,3 +26,4 @@ jobs:
|
|||||||
# till registry sker i ett separat, behörighetsstyrt flöde.
|
# till registry sker i ett separat, behörighetsstyrt flöde.
|
||||||
- run: docker build -t guidad-felsokning-web .
|
- run: docker build -t guidad-felsokning-web .
|
||||||
- run: docker build -t guidad-felsokning-ai-orkester services/ai-orkester
|
- run: docker build -t guidad-felsokning-ai-orkester services/ai-orkester
|
||||||
|
- run: docker build -t guidad-felsokning-plattform services/plattform
|
||||||
|
|||||||
+3
-1
@@ -10,10 +10,12 @@ ARG VITE_SUPABASE_URL
|
|||||||
ARG VITE_SUPABASE_PUBLISHABLE_KEY
|
ARG VITE_SUPABASE_PUBLISHABLE_KEY
|
||||||
ARG VITE_SUPABASE_PROJECT_ID
|
ARG VITE_SUPABASE_PROJECT_ID
|
||||||
ARG VITE_AI_ORKESTER_URL
|
ARG VITE_AI_ORKESTER_URL
|
||||||
|
ARG VITE_PLATTFORM_URL
|
||||||
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL \
|
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL \
|
||||||
VITE_SUPABASE_PUBLISHABLE_KEY=$VITE_SUPABASE_PUBLISHABLE_KEY \
|
VITE_SUPABASE_PUBLISHABLE_KEY=$VITE_SUPABASE_PUBLISHABLE_KEY \
|
||||||
VITE_SUPABASE_PROJECT_ID=$VITE_SUPABASE_PROJECT_ID \
|
VITE_SUPABASE_PROJECT_ID=$VITE_SUPABASE_PROJECT_ID \
|
||||||
VITE_AI_ORKESTER_URL=$VITE_AI_ORKESTER_URL
|
VITE_AI_ORKESTER_URL=$VITE_AI_ORKESTER_URL \
|
||||||
|
VITE_PLATTFORM_URL=$VITE_PLATTFORM_URL
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginxinc/nginx-unprivileged:1.27-alpine
|
FROM nginxinc/nginx-unprivileged:1.27-alpine
|
||||||
|
|||||||
+25
-26
@@ -1,68 +1,67 @@
|
|||||||
# Guidad Felsökning – Drift i Kubernetes
|
# Guidad Felsökning – Drift i Kubernetes (helt självhostat)
|
||||||
|
|
||||||
Målarkitekturen ur [Master Prompt](MASTER-PROMPT.md) som kod: allt körbart i kluster, med secrets för plattformsnycklarna och CI som verifierar både kod och containerbyggen.
|
Målarkitekturen ur [Master Prompt](MASTER-PROMPT.md) som kod: hela stacken körbar i eget kluster — webb, AI-orkester, plattformsbackend (auth + händelse-API + Live Share) och Postgres. Inga externa tjänstberoenden utöver Anthropic-API:et för AI-anropen.
|
||||||
|
|
||||||
## Arkitektur
|
## Arkitektur
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
T[Tekniker] -->|HTTPS| I[Ingress + TLS]
|
T[Tekniker] -->|HTTPS| I[Ingress + TLS]
|
||||||
|
T2[Kund via delningslänk] -->|HTTPS| I
|
||||||
I -->|/| W[web\n2–10 pods, HPA]
|
I -->|/| W[web\n2–10 pods, HPA]
|
||||||
I -->|/api/ai| A[ai-orkester\n2–10 pods, HPA]
|
I -->|/api/ai| A[ai-orkester\n2–10 pods, HPA]
|
||||||
|
I -->|/api, /halsa| P[plattform\n2–10 pods, HPA]
|
||||||
A -->|Claude API| C[(Anthropic)]
|
A -->|Claude API| C[(Anthropic)]
|
||||||
W & A -.->|auth + data| S[(Supabase\nPostgres · Auth)]
|
P --> DB[(Postgres\nStatefulSet + PVC)]
|
||||||
K[Secret: felsokning-hemligheter] --> A
|
K[Secret: felsokning-hemligheter] --> A & P & DB
|
||||||
```
|
```
|
||||||
|
|
||||||
| Komponent | Vad | Var |
|
| Komponent | Vad | Var |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `web` | SPA:n bakom oprivilegierad nginx (`Dockerfile`, `docker/nginx.conf`) | Deployment + Service + HPA + PDB |
|
| `web` | SPA:n bakom oprivilegierad nginx (`Dockerfile`, `docker/nginx.conf`) | Deployment + Service + HPA + PDB |
|
||||||
| `ai-orkester` | AI-orkestern som egen tjänst (`services/ai-orkester`) — samma routing som edge-funktionen, med egen JWT-verifiering och `/halsa` | Deployment + Service + HPA + PDB |
|
| `plattform` | Självhostad backend (`services/plattform`): inloggning (bcrypt via pgcrypto, HS256-JWT), append-only händelse-API för synken, publik delningsendpoint för Live Share | Deployment + Service + HPA + PDB |
|
||||||
| Hemligheter | `ANTHROPIC_API_KEY` + `SUPABASE_JWT_SECRET` | Secret `felsokning-hemligheter` — aldrig i bilder eller manifest |
|
| `ai-orkester` | AI-orkestern (`services/ai-orkester`): fyra uppgifter routade till Sonnet 5 / Opus 5 / Haiku 4.5 — verifierar plattformens JWT (delad hemlighet) | Deployment + Service + HPA + PDB |
|
||||||
| Databas & auth | Postgres (händelselogg, RLS, `hamta_delat_arende`) och inloggning | Supabase — managerad, eller självhostad Supabase i klustret när det steget tas |
|
| `postgres` | Händelselogg + användare; **append-only garanterat med databastriggers** — historik kan inte ändras eller raderas oavsett roll | StatefulSet + PVC (10 Gi). Produktion: CloudNativePG-operatorn för backup/failover/PITR |
|
||||||
|
| Hemligheter | `anthropic-api-key`, `jwt-secret` (delas av plattform + orkester), `postgres-losenord` | Secret `felsokning-hemligheter` — aldrig i bilder eller manifest |
|
||||||
|
|
||||||
Klienten väljer AI-väg vid byggtillfället: med `VITE_AI_ORKESTER_URL` satt går anropen till orkestertjänsten i klustret; utan den används Supabase-edge-funktionen. Samma orkester (modeller, systemprompter, scheman) i båda.
|
**Klienten har två driftlägen**, valda vid bygget: med `VITE_PLATTFORM_URL` går inloggning, synk, Live Share och AI mot klustret (helt självhostat); utan den används Supabase-läget (edge-funktion + managerad Postgres/Auth) som tidigare. Samma händelsemodell, samma orkester — låst av paritetstester.
|
||||||
|
|
||||||
## Driftsätta
|
## Driftsätta
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 1. Bygg och publicera bilderna (ersätt registry i infra/k8s/*.yaml)
|
# 1. Bygg och publicera bilderna (ersätt registry i infra/k8s/*.yaml)
|
||||||
docker build -t ghcr.io/ORG/guidad-felsokning-web \
|
docker build -t ghcr.io/ORG/guidad-felsokning-web \
|
||||||
--build-arg VITE_SUPABASE_URL=… \
|
--build-arg VITE_PLATTFORM_URL=https://app.exempel.se .
|
||||||
--build-arg VITE_SUPABASE_PUBLISHABLE_KEY=… \
|
|
||||||
--build-arg VITE_SUPABASE_PROJECT_ID=… \
|
|
||||||
--build-arg VITE_AI_ORKESTER_URL=https://app.exempel.se .
|
|
||||||
docker build -t ghcr.io/ORG/guidad-felsokning-ai-orkester services/ai-orkester
|
docker build -t ghcr.io/ORG/guidad-felsokning-ai-orkester services/ai-orkester
|
||||||
|
docker build -t ghcr.io/ORG/guidad-felsokning-plattform services/plattform
|
||||||
docker push ghcr.io/ORG/guidad-felsokning-web
|
docker push ghcr.io/ORG/guidad-felsokning-web
|
||||||
docker push ghcr.io/ORG/guidad-felsokning-ai-orkester
|
docker push ghcr.io/ORG/guidad-felsokning-ai-orkester
|
||||||
|
docker push ghcr.io/ORG/guidad-felsokning-plattform
|
||||||
|
|
||||||
# 2. Skapa secret:en (eller använd External Secrets/Sealed Secrets)
|
# 2. Skapa secret:en (eller använd External Secrets/Sealed Secrets)
|
||||||
kubectl create namespace guidad-felsokning
|
kubectl create namespace guidad-felsokning
|
||||||
kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \
|
kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \
|
||||||
--from-literal=anthropic-api-key='sk-ant-…' \
|
--from-literal=anthropic-api-key='sk-ant-…' \
|
||||||
--from-literal=supabase-jwt-secret='…'
|
--from-literal=jwt-secret="$(openssl rand -base64 48)" \
|
||||||
|
--from-literal=postgres-losenord="$(openssl rand -base64 24)"
|
||||||
|
|
||||||
# 3. Applicera manifesten
|
# 3. Applicera manifesten (Postgres initieras med schema + append-only-triggers)
|
||||||
kubectl apply -k infra/k8s
|
kubectl apply -k infra/k8s
|
||||||
|
|
||||||
# 4. Verifiera
|
# 4. Verifiera
|
||||||
kubectl -n guidad-felsokning get pods
|
kubectl -n guidad-felsokning get pods
|
||||||
curl https://app.exempel.se/halsa # → {"status":"ok"}
|
curl https://app.exempel.se/halsa # → {"status":"ok"} (plattformen)
|
||||||
```
|
```
|
||||||
|
|
||||||
Byt domän och cert-issuer i `infra/k8s/ingress.yaml`.
|
Byt domän och cert-issuer i `infra/k8s/ingress.yaml`. Självregistrering är öppen i beta — stäng med `REGISTRERING_OPPEN=false` på plattformens Deployment när organisationsstyrd användarhantering införs.
|
||||||
|
|
||||||
## Skalning och robusthet
|
## Säkerhet och robusthet
|
||||||
|
|
||||||
- **HPA** 2–10 pods per tjänst på 70 % CPU; **PDB** håller minst en pod uppe vid noddränering.
|
- **Append-only i tre lager:** klienten lägger bara till, API:t exponerar inga update/delete, och databastriggers avvisar ändringar även för en felkonfigurerad roll.
|
||||||
- Båda containrarna kör **non-root** utan capabilities; orkestern dessutom med read-only rotfilsystem.
|
- **JWT-flödet är verifierat tvärs tjänsterna:** plattformen signerar, orkestern verifierar samma hemlighet; fel hemlighet och utgångna tokens avvisas (testat).
|
||||||
- **Hälsokontroller**: nginx svarar på `/`, orkestern på `/halsa` (readiness + liveness + Docker HEALTHCHECK).
|
- Alla containrar kör **non-root** utan capabilities; backend-tjänsterna med read-only rotfilsystem. Båda failar closed utan sina hemligheter.
|
||||||
- Orkestern **failar closed**: utan nyckel/JWT-hemlighet svarar den 503, utan giltig JWT 401.
|
- **HPA** 2–10 pods per tjänst på 70 % CPU; **PDB** minst en pod uppe vid noddränering; readiness/liveness-prober överallt (`pg_isready` för Postgres).
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
`.github/workflows/ci.yml` kör tester + produktionsbygge och verifierar båda Dockerfilerna på varje push/PR. Publicering till registry och `kubectl apply` läggs i ett separat, behörighetsstyrt deploy-flöde (t.ex. environments med godkännande eller GitOps via Argo CD/Flux).
|
`.github/workflows/ci.yml` kör tester + produktionsbygge och verifierar alla tre Dockerfilerna på varje push/PR. Publicering och `kubectl apply` läggs i ett separat behörighetsstyrt deploy-flöde (GitOps via Argo CD/Flux rekommenderas).
|
||||||
|
|
||||||
## Kvar mot full självhostning
|
|
||||||
|
|
||||||
Synk, Live Share-funktionen och inloggningen går fortfarande mot Supabase (managerad Postgres + Auth). Nästa steg för "allt i kluster" är självhostad Supabase eller ett eget API-lager över Postgres i klustret — händelsemodellen är redan API-klar, så det bytet påverkar inte klienten.
|
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ spec:
|
|||||||
- name: ANTHROPIC_API_KEY
|
- name: ANTHROPIC_API_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef: { name: felsokning-hemligheter, key: anthropic-api-key }
|
secretKeyRef: { name: felsokning-hemligheter, key: anthropic-api-key }
|
||||||
- name: SUPABASE_JWT_SECRET
|
- name: JWT_SECRET
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef: { name: felsokning-hemligheter, key: supabase-jwt-secret }
|
secretKeyRef: { name: felsokning-hemligheter, key: jwt-secret }
|
||||||
resources:
|
resources:
|
||||||
requests: { cpu: 100m, memory: 128Mi }
|
requests: { cpu: 100m, memory: 128Mi }
|
||||||
limits: { cpu: "1", memory: 256Mi }
|
limits: { cpu: "1", memory: 256Mi }
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
#
|
#
|
||||||
# kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \
|
# kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \
|
||||||
# --from-literal=anthropic-api-key='sk-ant-…' \
|
# --from-literal=anthropic-api-key='sk-ant-…' \
|
||||||
# --from-literal=supabase-jwt-secret='…'
|
# --from-literal=jwt-secret="$(openssl rand -base64 48)" \
|
||||||
|
# --from-literal=postgres-losenord="$(openssl rand -base64 24)"
|
||||||
#
|
#
|
||||||
# I produktion: använd extern secrets-hantering (External Secrets Operator,
|
# I produktion: använd extern secrets-hantering (External Secrets Operator,
|
||||||
# Sealed Secrets eller molnleverantörens secret manager).
|
# Sealed Secrets eller molnleverantörens secret manager).
|
||||||
@@ -14,4 +15,5 @@ metadata:
|
|||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
anthropic-api-key: ERSATT-MIG
|
anthropic-api-key: ERSATT-MIG
|
||||||
supabase-jwt-secret: ERSATT-MIG
|
jwt-secret: ERSATT-MIG
|
||||||
|
postgres-losenord: ERSATT-MIG
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# Ingress med TLS (cert-manager): webben på /, AI-orkestern på /api/ai.
|
# Ingress med TLS (cert-manager). Längsta prefix vinner:
|
||||||
|
# /api/ai → AI-orkestern, övriga /api + /halsa → plattformen, / → webben.
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
@@ -19,10 +20,14 @@ spec:
|
|||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service: { name: ai-orkester, port: { number: 80 } }
|
service: { name: ai-orkester, port: { number: 80 } }
|
||||||
|
- path: /api
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service: { name: plattform, port: { number: 80 } }
|
||||||
- path: /halsa
|
- path: /halsa
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service: { name: ai-orkester, port: { number: 80 } }
|
service: { name: plattform, port: { number: 80 } }
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1
|
|||||||
kind: Kustomization
|
kind: Kustomization
|
||||||
resources:
|
resources:
|
||||||
- namespace.yaml
|
- namespace.yaml
|
||||||
|
- postgres.yaml
|
||||||
|
- plattform.yaml
|
||||||
- web.yaml
|
- web.yaml
|
||||||
- ai-orkester.yaml
|
- ai-orkester.yaml
|
||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Plattformstjänsten: självhostad auth, händelse-API och Live Share-delning.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: plattform
|
||||||
|
namespace: guidad-felsokning
|
||||||
|
labels: { app: plattform }
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels: { app: plattform }
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: { app: plattform }
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
containers:
|
||||||
|
- name: plattform
|
||||||
|
image: ghcr.io/ORGANISATION/guidad-felsokning-plattform:latest # ersätt med ert registry
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
env:
|
||||||
|
- name: JWT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: { name: felsokning-hemligheter, key: jwt-secret }
|
||||||
|
- name: POSTGRES_LOSENORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: { name: felsokning-hemligheter, key: postgres-losenord }
|
||||||
|
- name: DATABASE_URL
|
||||||
|
value: postgresql://plattform:$(POSTGRES_LOSENORD)@postgres:5432/felsokning
|
||||||
|
resources:
|
||||||
|
requests: { cpu: 100m, memory: 128Mi }
|
||||||
|
limits: { cpu: "1", memory: 256Mi }
|
||||||
|
readinessProbe:
|
||||||
|
httpGet: { path: /halsa, port: 8080 }
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
livenessProbe:
|
||||||
|
httpGet: { path: /halsa, port: 8080 }
|
||||||
|
periodSeconds: 15
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities: { drop: [ALL] }
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: plattform
|
||||||
|
namespace: guidad-felsokning
|
||||||
|
spec:
|
||||||
|
selector: { app: plattform }
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8080
|
||||||
|
---
|
||||||
|
apiVersion: autoscaling/v2
|
||||||
|
kind: HorizontalPodAutoscaler
|
||||||
|
metadata:
|
||||||
|
name: plattform
|
||||||
|
namespace: guidad-felsokning
|
||||||
|
spec:
|
||||||
|
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: plattform }
|
||||||
|
minReplicas: 2
|
||||||
|
maxReplicas: 10
|
||||||
|
metrics:
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target: { type: Utilization, averageUtilization: 70 }
|
||||||
|
---
|
||||||
|
apiVersion: policy/v1
|
||||||
|
kind: PodDisruptionBudget
|
||||||
|
metadata:
|
||||||
|
name: plattform
|
||||||
|
namespace: guidad-felsokning
|
||||||
|
spec:
|
||||||
|
minAvailable: 1
|
||||||
|
selector:
|
||||||
|
matchLabels: { app: plattform }
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Självhostad Postgres för Guidad Felsökning.
|
||||||
|
# För produktion rekommenderas CloudNativePG-operatorn (automatiska
|
||||||
|
# säkerhetskopior, failover, PITR) — den här StatefulSet:en är den enkla
|
||||||
|
# grundplattan med samma schema.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: postgres-init
|
||||||
|
namespace: guidad-felsokning
|
||||||
|
data:
|
||||||
|
# Körs vid första starten. Append-only garanteras med triggers —
|
||||||
|
# historiken kan inte ändras eller raderas oavsett vilken roll som
|
||||||
|
# ansluter.
|
||||||
|
init.sql: |
|
||||||
|
create extension if not exists pgcrypto;
|
||||||
|
|
||||||
|
create table if not exists anvandare (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
epost text unique not null,
|
||||||
|
losen_hash text not null,
|
||||||
|
namn text not null,
|
||||||
|
skapad timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists felsokning_arenden (
|
||||||
|
id text primary key,
|
||||||
|
nummer integer not null,
|
||||||
|
skapad timestamptz not null,
|
||||||
|
delningskod text unique,
|
||||||
|
metodik_id text,
|
||||||
|
skapad_av uuid references anvandare(id),
|
||||||
|
insatt timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists felsokning_handelser (
|
||||||
|
id text primary key,
|
||||||
|
arende_id text not null references felsokning_arenden(id),
|
||||||
|
tidpunkt timestamptz not null,
|
||||||
|
anvandare text not null,
|
||||||
|
handelse jsonb not null,
|
||||||
|
insatt timestamptz not null default now()
|
||||||
|
);
|
||||||
|
create index if not exists felsokning_handelser_arende_idx
|
||||||
|
on felsokning_handelser (arende_id, tidpunkt);
|
||||||
|
|
||||||
|
create or replace function forbjud_andring() returns trigger
|
||||||
|
language plpgsql as $$
|
||||||
|
begin
|
||||||
|
raise exception 'Loggen är append-only — historik kan inte ändras eller raderas';
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
drop trigger if exists handelser_append_only on felsokning_handelser;
|
||||||
|
create trigger handelser_append_only
|
||||||
|
before update or delete on felsokning_handelser
|
||||||
|
for each row execute function forbjud_andring();
|
||||||
|
|
||||||
|
drop trigger if exists arenden_append_only on felsokning_arenden;
|
||||||
|
create trigger arenden_append_only
|
||||||
|
before update or delete on felsokning_arenden
|
||||||
|
for each row execute function forbjud_andring();
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: guidad-felsokning
|
||||||
|
spec:
|
||||||
|
serviceName: postgres
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels: { app: postgres }
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: { app: postgres }
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:17-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
value: felsokning
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
value: plattform
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: { name: felsokning-hemligheter, key: postgres-losenord }
|
||||||
|
- name: PGDATA
|
||||||
|
value: /var/lib/postgresql/data/pgdata
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
- name: init
|
||||||
|
mountPath: /docker-entrypoint-initdb.d
|
||||||
|
resources:
|
||||||
|
requests: { cpu: 250m, memory: 256Mi }
|
||||||
|
limits: { cpu: "1", memory: 1Gi }
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["pg_isready", "-U", "plattform", "-d", "felsokning"]
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
volumes:
|
||||||
|
- name: init
|
||||||
|
configMap: { name: postgres-init }
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: data
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteOnce]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 10Gi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: guidad-felsokning
|
||||||
|
spec:
|
||||||
|
selector: { app: postgres }
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
@@ -156,7 +156,9 @@ export function skapaServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const apiNyckel = process.env.ANTHROPIC_API_KEY;
|
const apiNyckel = process.env.ANTHROPIC_API_KEY;
|
||||||
const jwtHemlighet = process.env.SUPABASE_JWT_SECRET;
|
// JWT_SECRET i självhostat läge (delas med plattformstjänsten);
|
||||||
|
// SUPABASE_JWT_SECRET när auth ligger hos Supabase.
|
||||||
|
const jwtHemlighet = process.env.JWT_SECRET ?? process.env.SUPABASE_JWT_SECRET;
|
||||||
if (!apiNyckel || !jwtHemlighet) {
|
if (!apiNyckel || !jwtHemlighet) {
|
||||||
return svara(res, 503, { error: "AI-tjänsten är inte konfigurerad." });
|
return svara(res, 503, { error: "AI-tjänsten är inte konfigurerad." });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Plattformstjänsten — självhostad backend (auth, händelse-API, delning).
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
|
||||||
|
COPY server.mjs ./
|
||||||
|
|
||||||
|
ENV NODE_ENV=production PORT=8080
|
||||||
|
USER node
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK CMD wget -qO- http://127.0.0.1:8080/halsa || exit 1
|
||||||
|
CMD ["node", "server.mjs"]
|
||||||
Generated
+161
@@ -0,0 +1,161 @@
|
|||||||
|
{
|
||||||
|
"name": "plattform",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "plattform",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"pg": "^8.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||||
|
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.14.0",
|
||||||
|
"pg-pool": "^3.14.0",
|
||||||
|
"pg-protocol": "^1.15.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "plattform",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "server.mjs",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pg": "^8.13.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// Plattformstjänsten — självhostad backend för Guidad Felsökning.
|
||||||
|
//
|
||||||
|
// Ersätter Supabase i klustret: egen inloggning (HS256-JWT, lösenord
|
||||||
|
// hashade med pgcrypto/bcrypt i Postgres), händelse-API för synken och
|
||||||
|
// publik delningsendpoint för Live Share. 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 självregistrering (default öppen, beta)
|
||||||
|
// PORT default 8080
|
||||||
|
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT ?? 8080);
|
||||||
|
const MAX_KROPP = 4 * 1024 * 1024;
|
||||||
|
const TOKEN_LIVSTID_S = 12 * 60 * 60;
|
||||||
|
|
||||||
|
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
|
||||||
|
|
||||||
|
// ---- 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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 kravAuth(req, hemlighet) {
|
||||||
|
const auth = req.headers.authorization ?? "";
|
||||||
|
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
||||||
|
return token ? verifieraJwt(token, hemlighet) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Server -----------------------------------------------------------
|
||||||
|
|
||||||
|
export function skapaServer() {
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hemlighet = process.env.JWT_SECRET;
|
||||||
|
if (!hemlighet) return svara(res, 503, { error: "Tjänsten är inte konfigurerad." });
|
||||||
|
|
||||||
|
// -- Auth (öppna endpoints) --
|
||||||
|
if (req.method === "POST" && vag === "/api/auth/registrera") {
|
||||||
|
if (process.env.REGISTRERING_OPPEN === "false") {
|
||||||
|
return svara(res, 403, { error: "Registrering är stängd." });
|
||||||
|
}
|
||||||
|
const { epost, losenord, namn } = await lasKropp(req);
|
||||||
|
if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim()) {
|
||||||
|
return svara(res, 400, { error: "Ogiltig e-post, namn eller för kort lösenord (minst 8 tecken)." });
|
||||||
|
}
|
||||||
|
const rader = await pool.query(
|
||||||
|
`insert into anvandare (epost, losen_hash, namn)
|
||||||
|
values (lower($1), crypt($2, gen_salt('bf')), $3)
|
||||||
|
on conflict (epost) do nothing
|
||||||
|
returning id, namn`,
|
||||||
|
[epost.trim(), losenord, namn.trim()],
|
||||||
|
);
|
||||||
|
if (rader.rowCount === 0) return svara(res, 409, { error: "E-postadressen är redan registrerad." });
|
||||||
|
return loggaIn(res, rader.rows[0], hemlighet);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && vag === "/api/auth/logga-in") {
|
||||||
|
const { epost, losenord } = await lasKropp(req);
|
||||||
|
const rader = await pool.query(
|
||||||
|
`select id, namn from anvandare
|
||||||
|
where epost = lower($1) and losen_hash = crypt($2, 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) --
|
||||||
|
const delad = vag.match(/^\/api\/delad\/([a-z0-9-]+)$/);
|
||||||
|
if (req.method === "GET" && delad) {
|
||||||
|
const arende = await pool.query(
|
||||||
|
`select id, nummer, skapad from felsokning_arenden where delningskod = $1`,
|
||||||
|
[delad[1]],
|
||||||
|
);
|
||||||
|
if (arende.rowCount === 0) return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
|
||||||
|
const handelser = await pool.query(
|
||||||
|
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
|
||||||
|
where arende_id = $1 and handelse->>'typ' not in ('kategori_byte','hypotes','ai_svar')
|
||||||
|
order by tidpunkt, id`,
|
||||||
|
[arende.rows[0].id],
|
||||||
|
);
|
||||||
|
return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Skyddade endpoints --
|
||||||
|
const anspr = kravAuth(req, hemlighet);
|
||||||
|
if (!anspr) return svara(res, 401, { error: "Inloggning krävs." });
|
||||||
|
|
||||||
|
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, nummer, skapad, delningskod, metodik_id, skapad_av)
|
||||||
|
values ($1, $2, $3, $4, $5, $6) on conflict (id) do nothing`,
|
||||||
|
[id, nummer, skapad, delningskod ?? null, metodikId ?? null, anspr.sub],
|
||||||
|
);
|
||||||
|
return svara(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const handelserVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/handelser$/);
|
||||||
|
if (handelserVag && 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 (handelserVag && 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." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function loggaIn(res, anvandare, hemlighet) {
|
||||||
|
const nu = Math.floor(Date.now() / 1000);
|
||||||
|
const token = skapaJwt(
|
||||||
|
{ sub: anvandare.id, namn: anvandare.namn, iat: nu, exp: nu + TOKEN_LIVSTID_S },
|
||||||
|
hemlighet,
|
||||||
|
);
|
||||||
|
return svara(res, 200, { token, namn: anvandare.namn });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "test") {
|
||||||
|
skapaServer().listen(PORT, () => {
|
||||||
|
console.log(`plattform lyssnar på :${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -36,6 +36,27 @@ describe("AI-orkestern", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("självhostade stacken: plattformstjänst + append-only i databasen", () => {
|
||||||
|
const plattform = readFileSync("services/plattform/server.mjs", "utf8");
|
||||||
|
for (const bit of [
|
||||||
|
"/api/auth/logga-in",
|
||||||
|
"/api/auth/registrera",
|
||||||
|
"/api/arenden",
|
||||||
|
String.raw`\/api\/delad\/`,
|
||||||
|
"gen_salt('bf')",
|
||||||
|
"JWT_SECRET",
|
||||||
|
]) {
|
||||||
|
expect(plattform).toContain(bit);
|
||||||
|
}
|
||||||
|
// API:t exponerar medvetet inga update/delete-operationer.
|
||||||
|
expect(plattform).not.toMatch(/\b(update|delete)\s+felsokning_handelser/i);
|
||||||
|
|
||||||
|
const postgres = readFileSync("infra/k8s/postgres.yaml", "utf8");
|
||||||
|
expect(postgres).toContain("before update or delete on felsokning_handelser");
|
||||||
|
expect(postgres).toContain("before update or delete on felsokning_arenden");
|
||||||
|
expect(postgres).toContain("append-only");
|
||||||
|
});
|
||||||
|
|
||||||
it("endpointen kodar AI-reglerna i grundprompten", () => {
|
it("endpointen kodar AI-reglerna i grundprompten", () => {
|
||||||
expect(ENDPOINT).toContain("Hitta aldrig på fakta");
|
expect(ENDPOINT).toContain("Hitta aldrig på fakta");
|
||||||
expect(ENDPOINT).toContain("aldrig en hypotes som ett konstaterat fel");
|
expect(ENDPOINT).toContain("aldrig en hypotes som ett konstaterat fel");
|
||||||
|
|||||||
+16
-3
@@ -96,23 +96,36 @@ const ORKESTER_URL = (import.meta.env.VITE_AI_ORKESTER_URL as string | undefined
|
|||||||
// Returnerar null i lokalt läge (ej inloggad) — orkestern nås via
|
// Returnerar null i lokalt läge (ej inloggad) — orkestern nås via
|
||||||
// plattformens autentiserade backend.
|
// plattformens autentiserade backend.
|
||||||
async function anropa(uppgift: AiUppgift, prompt: string): Promise<{ modell: string; svar: unknown } | null> {
|
async function anropa(uppgift: AiUppgift, prompt: string): Promise<{ modell: string; svar: unknown } | null> {
|
||||||
|
const { plattformAktiv, plattformToken, PLATTFORM_URL } = await import("./plattform");
|
||||||
|
|
||||||
|
let token: string | null = null;
|
||||||
|
if (plattformAktiv()) {
|
||||||
|
// Självhostat: plattformens egen JWT — samma hemlighet verifieras
|
||||||
|
// av AI-orkestern i klustret.
|
||||||
|
token = plattformToken();
|
||||||
|
if (!token) return null;
|
||||||
|
} else {
|
||||||
const { supabase } = await import("@/integrations/supabase/client");
|
const { supabase } = await import("@/integrations/supabase/client");
|
||||||
const { data } = await supabase.auth.getSession();
|
const { data } = await supabase.auth.getSession();
|
||||||
if (!data.session) return null;
|
if (!data.session) return null;
|
||||||
|
token = data.session.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
let resultat: unknown;
|
let resultat: unknown;
|
||||||
if (ORKESTER_URL) {
|
const bas = ORKESTER_URL ?? (plattformAktiv() ? PLATTFORM_URL : undefined);
|
||||||
const res = await fetch(`${ORKESTER_URL}/api/ai`, {
|
if (bas) {
|
||||||
|
const res = await fetch(`${bas}/api/ai`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${data.session.access_token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ uppgift, prompt }),
|
body: JSON.stringify({ uppgift, prompt }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`AI-orkestern svarade ${res.status}`);
|
if (!res.ok) throw new Error(`AI-orkestern svarade ${res.status}`);
|
||||||
resultat = await res.json();
|
resultat = await res.json();
|
||||||
} else {
|
} else {
|
||||||
|
const { supabase } = await import("@/integrations/supabase/client");
|
||||||
const { data: svar, error } = await supabase.functions.invoke("felsokning-ai", {
|
const { data: svar, error } = await supabase.functions.invoke("felsokning-ai", {
|
||||||
body: { uppgift, prompt },
|
body: { uppgift, prompt },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Självhostat läge: klienten pratar med plattformstjänsten i klustret
|
||||||
|
// (services/plattform) i stället för Supabase. Aktiveras vid bygget med
|
||||||
|
// VITE_PLATTFORM_URL; utan den används Supabase-läget precis som förut.
|
||||||
|
|
||||||
|
export const PLATTFORM_URL = (import.meta.env.VITE_PLATTFORM_URL as string | undefined)?.replace(/\/$/, "");
|
||||||
|
|
||||||
|
const TOKEN_NYCKEL = "gf-plattform-token";
|
||||||
|
const NAMN_NYCKEL = "gf-plattform-namn";
|
||||||
|
|
||||||
|
export function plattformAktiv(): boolean {
|
||||||
|
return !!PLATTFORM_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function plattformToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_NYCKEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function plattformNamn(): string | null {
|
||||||
|
return localStorage.getItem(NAMN_NYCKEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loggaUtPlattform(): void {
|
||||||
|
localStorage.removeItem(TOKEN_NYCKEL);
|
||||||
|
localStorage.removeItem(NAMN_NYCKEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authAnrop(vag: string, kropp: object): Promise<string> {
|
||||||
|
const res = await fetch(`${PLATTFORM_URL}${vag}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(kropp),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error((data as { error?: string }).error ?? `Fel ${res.status}`);
|
||||||
|
const { token, namn } = data as { token: string; namn: string };
|
||||||
|
localStorage.setItem(TOKEN_NYCKEL, token);
|
||||||
|
localStorage.setItem(NAMN_NYCKEL, namn);
|
||||||
|
return namn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loggaInPlattform(epost: string, losenord: string): Promise<string> {
|
||||||
|
return authAnrop("/api/auth/logga-in", { epost, losenord });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registreraPlattform(epost: string, losenord: string, namn: string): Promise<string> {
|
||||||
|
return authAnrop("/api/auth/registrera", { epost, losenord, namn });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autentiserat anrop mot plattformen. En utgången token rensas (401) så
|
||||||
|
// att appen faller tillbaka till lokalt läge tills nästa inloggning.
|
||||||
|
export async function plattformFetch(vag: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const token = plattformToken();
|
||||||
|
if (!token) throw new Error("Ej inloggad");
|
||||||
|
const res = await fetch(`${PLATTFORM_URL}${vag}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...init?.headers,
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status === 401) loggaUtPlattform();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -45,7 +45,50 @@ async function klient(): Promise<SupabaseClient | undefined> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Självhostat läge: samma konfliktfria flätning, men mot plattformstjänsten.
|
||||||
|
async function synkroniseraMotPlattform(arende: Arende): Promise<SynkResultat> {
|
||||||
|
const { plattformToken, plattformFetch } = await import("./plattform");
|
||||||
|
if (!plattformToken()) return { status: "lokal" };
|
||||||
|
try {
|
||||||
|
await plattformFetch("/api/arenden", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: arende.id,
|
||||||
|
nummer: arende.nummer,
|
||||||
|
skapad: arende.skapad,
|
||||||
|
delningskod: arende.delningskod,
|
||||||
|
metodikId: arende.metodikId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const hamtat = await plattformFetch(`/api/arenden/${arende.id}/handelser`);
|
||||||
|
if (!hamtat.ok) throw new Error(`Fel ${hamtat.status}`);
|
||||||
|
const { handelser: fjarrRader } = (await hamtat.json()) as { handelser: FjarrHandelseRad[] };
|
||||||
|
const fjarr: LoggPost[] = fjarrRader.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));
|
||||||
|
if (nya.length > 0) {
|
||||||
|
const skrivet = await plattformFetch(`/api/arenden/${arende.id}/handelser`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ handelser: nya }),
|
||||||
|
});
|
||||||
|
if (!skrivet.ok) throw new Error(`Fel ${skrivet.status}`);
|
||||||
|
}
|
||||||
|
return { status: "synkad", handelser: flataIhop(arende.handelser, fjarr) };
|
||||||
|
} catch {
|
||||||
|
const { plattformToken: kvarToken } = await import("./plattform");
|
||||||
|
return { status: kvarToken() ? "offline" : "lokal" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function synkroniseraArende(arende: Arende): Promise<SynkResultat> {
|
export async function synkroniseraArende(arende: Arende): Promise<SynkResultat> {
|
||||||
|
const { plattformAktiv } = await import("./plattform");
|
||||||
|
if (plattformAktiv()) return synkroniseraMotPlattform(arende);
|
||||||
|
|
||||||
const supabase = await klient();
|
const supabase = await klient();
|
||||||
if (!supabase) return { status: "lokal" };
|
if (!supabase) return { status: "lokal" };
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,11 +3,98 @@ import { Link } from "react-router-dom";
|
|||||||
import { useFelsokning, metodikForArende } from "@/felsokning/store";
|
import { useFelsokning, metodikForArende } from "@/felsokning/store";
|
||||||
import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner";
|
import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner";
|
||||||
import { byggDemoArende } from "@/felsokning/demo";
|
import { byggDemoArende } from "@/felsokning/demo";
|
||||||
|
import {
|
||||||
|
loggaInPlattform,
|
||||||
|
loggaUtPlattform,
|
||||||
|
plattformAktiv,
|
||||||
|
plattformNamn,
|
||||||
|
registreraPlattform,
|
||||||
|
} from "@/felsokning/plattform";
|
||||||
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
||||||
import { tidDatum, tidKlockslag } from "@/felsokning/format";
|
import { tidDatum, tidKlockslag } from "@/felsokning/format";
|
||||||
|
|
||||||
type Filter = "alla" | "pagaende" | "klara";
|
type Filter = "alla" | "pagaende" | "klara";
|
||||||
|
|
||||||
|
// Självhostat läge: inloggning mot plattformstjänsten i klustret.
|
||||||
|
// Aktiverar synk, samarbete och AI-orkestern.
|
||||||
|
function PlattformInloggning() {
|
||||||
|
const [namn, setNamn] = useState(plattformNamn());
|
||||||
|
const [lage, setLage] = useState<"stangd" | "loggaIn" | "registrera">("stangd");
|
||||||
|
const [epost, setEpost] = useState("");
|
||||||
|
const [losenord, setLosenord] = useState("");
|
||||||
|
const [nyttNamn, setNyttNamn] = useState("");
|
||||||
|
const [fel, setFel] = useState("");
|
||||||
|
|
||||||
|
if (namn) {
|
||||||
|
return (
|
||||||
|
<Panel rubrik="Plattformskonto">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-lg">
|
||||||
|
Inloggad som <span className="font-extrabold">{namn}</span> — synk och AI aktiva.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="whitespace-nowrap rounded-lg border-2 border-zinc-600 px-4 py-2 font-bold text-zinc-300 hover:border-zinc-400"
|
||||||
|
onClick={() => {
|
||||||
|
loggaUtPlattform();
|
||||||
|
setNamn(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logga ut
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel rubrik="Plattformskonto">
|
||||||
|
{lage === "stangd" ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 text-zinc-300">
|
||||||
|
Logga in för synk mellan enheter, samarbete i ärenden och AI-orkestern. Utan inloggning
|
||||||
|
arbetar appen i lokalt läge.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<StorKnapp variant="sekundar" onClick={() => setLage("registrera")}>
|
||||||
|
Skapa konto
|
||||||
|
</StorKnapp>
|
||||||
|
<StorKnapp onClick={() => setLage("loggaIn")}>Logga in</StorKnapp>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFel("");
|
||||||
|
try {
|
||||||
|
const inloggadSom =
|
||||||
|
lage === "loggaIn"
|
||||||
|
? await loggaInPlattform(epost, losenord)
|
||||||
|
: await registreraPlattform(epost, losenord, nyttNamn);
|
||||||
|
setNamn(inloggadSom);
|
||||||
|
} catch (misslyckande) {
|
||||||
|
setFel(misslyckande instanceof Error ? misslyckande.message : "Något gick fel.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lage === "registrera" && <TextFalt label="Namn" varde={nyttNamn} satt={setNyttNamn} platshallare="T.ex. Anna" />}
|
||||||
|
<TextFalt label="E-post" varde={epost} satt={setEpost} platshallare="anna@verkstaden.se" />
|
||||||
|
<TextFalt label="Lösenord (minst 8 tecken)" varde={losenord} satt={setLosenord} losenord />
|
||||||
|
{fel && <p className="mb-3 font-bold text-red-400">{fel}</p>}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<StorKnapp variant="sekundar" onClick={() => setLage("stangd")}>
|
||||||
|
Avbryt
|
||||||
|
</StorKnapp>
|
||||||
|
<StorKnapp type="submit" disabled={!epost.trim() || losenord.length < 8 || (lage === "registrera" && !nyttNamn.trim())}>
|
||||||
|
{lage === "loggaIn" ? "Logga in" : "Skapa konto"}
|
||||||
|
</StorKnapp>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Dashboard enligt direktivet: endast det viktigaste — mina ärenden,
|
// Dashboard enligt direktivet: endast det viktigaste — mina ärenden,
|
||||||
// pågående, klara och starta nytt ärende.
|
// pågående, klara och starta nytt ärende.
|
||||||
export default function Arendelista() {
|
export default function Arendelista() {
|
||||||
@@ -90,6 +177,8 @@ export default function Arendelista() {
|
|||||||
<p className="text-center text-lg text-zinc-400">Inga ärenden i det här filtret.</p>
|
<p className="text-center text-lg text-zinc-400">Inga ärenden i det här filtret.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{plattformAktiv() && <PlattformInloggning />}
|
||||||
|
|
||||||
<Panel rubrik="AI-orkestern">
|
<Panel rubrik="AI-orkestern">
|
||||||
<p className="text-zinc-300">
|
<p className="text-zinc-300">
|
||||||
Ingår i tjänsten och drivs av plattformen — flera Claude-modeller med olika roller: handledning i
|
Ingår i tjänsten och drivs av plattformen — flera Claude-modeller med olika roller: handledning i
|
||||||
|
|||||||
@@ -15,11 +15,21 @@ interface DelatSvar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function hamtaDelat(kod: string): Promise<Arende | undefined> {
|
async function hamtaDelat(kod: string): Promise<Arende | undefined> {
|
||||||
|
const { plattformAktiv, PLATTFORM_URL } = await import("@/felsokning/plattform");
|
||||||
|
let data: unknown;
|
||||||
|
if (plattformAktiv()) {
|
||||||
|
// Självhostat: publik delningsendpoint i plattformstjänsten.
|
||||||
|
const res = await fetch(`${PLATTFORM_URL}/api/delad/${kod}`);
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
data = await res.json();
|
||||||
|
} else {
|
||||||
const { supabase } = await import("@/integrations/supabase/client");
|
const { supabase } = await import("@/integrations/supabase/client");
|
||||||
const { data, error } = await (supabase as unknown as {
|
const { data: rpcData, error } = await (supabase as unknown as {
|
||||||
rpc: (fn: string, args: object) => Promise<{ data: unknown; error: unknown }>;
|
rpc: (fn: string, args: object) => Promise<{ data: unknown; error: unknown }>;
|
||||||
}).rpc("hamta_delat_arende", { kod });
|
}).rpc("hamta_delat_arende", { kod });
|
||||||
if (error || !data) return undefined;
|
if (error || !rpcData) return undefined;
|
||||||
|
data = rpcData;
|
||||||
|
}
|
||||||
const svar = data as DelatSvar;
|
const svar = data as DelatSvar;
|
||||||
return {
|
return {
|
||||||
id: svar.arende.id,
|
id: svar.arende.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user